diff --git a/skills/README.md b/skills/README.md new file mode 100644 index 00000000..a5b56025 --- /dev/null +++ b/skills/README.md @@ -0,0 +1,23 @@ +# Opt-in skills + +Skills in this directory are **not** auto-installed by ductor — they are +deliberately kept out of `ductor_slack/_home_defaults/` so a fresh ductor +install does not pick them up by the skill-sync mechanism. + +To install one into a running ductor instance, copy (or symlink) the skill +directory into the live workspace skills root: + +```bash +cp -r skills/ ~/.ductor-slack/workspace/skills/ +``` + +The 30-second skill sync will then propagate it to `~/.claude/skills/` and +`$CODEX_HOME/skills/` per the rules in +`ductor_slack/_home_defaults/workspace/skills/RULES.md`. + +## What's here + +- `responsive-agent/` — pause-and-ask / resume protocol used by + super-tasker and standalone for human-in-the-loop continuation. +- `super-tasker/` — file-backed multi-track task orchestration. Depends on + `responsive-agent`; install both together. diff --git a/skills/responsive-agent/SKILL.md b/skills/responsive-agent/SKILL.md new file mode 100644 index 00000000..408c718f --- /dev/null +++ b/skills/responsive-agent/SKILL.md @@ -0,0 +1,152 @@ +--- +name: responsive-agent +description: Pause a task to ask a human in Slack, then resume with full saved context when they reply. Use when a task is blocked on info you don't have. Also activates automatically whenever the conversation context contains an anchor of the form `[ret-ref:XXXXXX]` — that anchor means a prior session of this agent paused work and the current Slack reply is the answer; the skill resumes the paused task by loading the saved context. +--- + +# Responsive Agent + +Turn the agent into a reactive system: pause mid-task to ask a specific person +in Slack, exit the session, and resume cleanly when that person replies. + +The mechanism relies on: + +- A short reference id `XXXXXX` (6 hex chars) saved to disk with the task + context. +- An anchor `[ret-ref:XXXXXX]` posted as the last line of the Slack ask message. +- When the human replies, the Slack thread context is fed into a fresh agent + session. The anchor lives in that context, so this skill triggers, loads the + saved task, and continues from where the previous session left off. + +## When to activate + +Two distinct entry points: + +1. **Pause** — Mid-task you need information from a specific human before you + can proceed. You decide who to ask and what to ask. +2. **Resume** — The conversation context (usually the thread parent) contains a + line matching `[ret-ref:XXXXXX]`. The Slack message you are now reading IS + the human's answer to a prior session's question. Pick up the saved task. + +If the conversation contains an anchor, always resume first — do not start +fresh work. + +## Pause flow + +When you need to ask a human: + +1. Pick the Slack channel id and the user to mention. Mentioning needs the + Slack user id (`U...` or `W...`) — handles like `@victor` do not notify. +2. Write a one- or two-line question for the human. Be specific. +3. Write the resume context for your future self — this is everything the + next agent session needs to continue the work without seeing this + conversation. +4. Run: + + ```bash + python3 ~/.ductor-slack/workspace/skills/responsive-agent/scripts/pause_and_ask.py \ + --channel C0XXXXXX \ + --mention U0XXXXXX \ + --question "" \ + --requester-channel "$ORIG_CHANNEL" \ + --requester-thread-ts "$ORIG_THREAD_TS" \ + --context-stdin <<'EOF' + ## Task + + + ## Progress so far + + + ## What I need from the human + + + ## How to resume once answered + + EOF + ``` + + The script automatically appends two things to the Slack message — do not + type them yourself: + - A short reply hint telling the recipient to `@`-mention this bot when + replying. Without that mention Slack will not deliver their reply to the + bot, so the paused task can never resume. + - The `[ret-ref:]` anchor on its own line. + + You can override the hint with `--reply-hint ""` (use `{bot}` as a + placeholder for the bot mention, e.g. `--reply-hint "回复时请 @ {bot} 才能继续"`) + or pass `--reply-hint ""` to disable it. Default hint is in English. + +5. The script prints `{"ok": true, "ref": "abc123", "channel": "...", "ts": "..."}`. +6. Briefly tell the user you posted the question to `<@U...>` in `#channel` + and that you will resume when they reply. Then stop. Do not poll, sleep, or + wait — the session ends naturally and the next reply will re-trigger you. + +`--requester-channel` / `--requester-thread-ts` are optional but recommended: +they let the resume session post the final answer back to the original +requester's thread. + +## Resume flow + +When this skill triggers because the context contains `[ret-ref:XXXXXX]`: + +1. Run: + + ```bash + python3 ~/.ductor-slack/workspace/skills/responsive-agent/scripts/resume.py XXXXXX + ``` + + The script prints the saved task context followed by a JSON `meta` line + with the original requester pointers and the ask message ts. + + If the saved context contains a scope anchor that another skill cares + about (currently `[stask-exec:]` for super-tasker), `resume.py` + re-emits it on the very first output line so the resumed agent re-enters + the right skill mode without having to scan the body first. + +2. Treat the latest Slack message in the current conversation context (the + human's reply) as the answer to the question that was asked. +3. Continue the task following the "How to resume" section of the saved + context. You can also re-enter the pause flow if you need to ask someone + else. +4. When the task is complete, decide per scenario where to reply: + - Whether to send any follow-up in the new thread (where the human just + answered) — and what to say there. + - Whether to post the final result back to the original requester's thread + using `requester_channel` / `requester_thread_ts` from meta — and what to + say there. + Either, both, or neither may be appropriate. Judge by what actually serves + the requester and the human who answered: e.g. thank/acknowledge the + answerer in-thread when warranted, deliver the deliverable to the original + requester when they are waiting, skip a channel when a message there would + be noise. Use `~/.ductor-slack/workspace/tools/user_tools/slack_send.py` to + send. +5. Once you have delivered the result, clean up: + + ```bash + python3 ~/.ductor-slack/workspace/skills/responsive-agent/scripts/cleanup.py XXXXXX + ``` + +## Robustness notes + +- Bot identity lookup is cached at `responsive_state/.bot_identity.json` after + the first successful `auth.test` call. If the bot token changes, delete this + file so the next run re-resolves the bot user id. +- The anchor may appear multiple times in the conversation (someone may quote + or copy it). All duplicates resolve to the same ref — pick any. +- The anchor must be on its own line, but it does not have to be the only + line in the message. If `resume.py` is given stdin instead of an arg, it + scans the input for the first `[ret-ref:XXXXXX]` match. +- Refs are 6 lowercase hex chars. Collisions are astronomically unlikely for + the active set, and the script refuses to overwrite an existing ref. +- State lives under `~/.ductor-slack/workspace/responsive_state//`. It is + not auto-expired — clean up after successful resume, or run `cleanup.py` + with `--older-than 7d` to prune stale state. + +## Storage layout + +``` +workspace/responsive_state/ +└── / + ├── context.md # task context to reload on resume + └── meta.json # channel, mention, ask_ts, requester_*, created_at +``` diff --git a/skills/responsive-agent/scripts/cleanup.py b/skills/responsive-agent/scripts/cleanup.py new file mode 100755 index 00000000..a8ce3a8a --- /dev/null +++ b/skills/responsive-agent/scripts/cleanup.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python3 +"""Remove saved state for a resolved ref, or prune old refs. + +Usage: + python3 cleanup.py abc123 + python3 cleanup.py --older-than 7d + python3 cleanup.py --list +""" +from __future__ import annotations + +import argparse +import datetime +import json +import re +import shutil +import sys +from pathlib import Path + +STATE_ROOT = Path.home() / ".ductor-slack" / "workspace" / "responsive_state" + + +def parse_duration(s: str) -> datetime.timedelta: + m = re.fullmatch(r"(\d+)([smhd])", s.strip()) + if not m: + sys.exit(f"error: invalid duration {s!r} (use forms like 30m, 6h, 7d)") + n = int(m.group(1)) + unit = m.group(2) + return datetime.timedelta( + seconds={"s": 1, "m": 60, "h": 3600, "d": 86400}[unit] * n + ) + + +def load_created_at(state_dir: Path) -> datetime.datetime | None: + meta_path = state_dir / "meta.json" + if meta_path.exists(): + try: + meta = json.loads(meta_path.read_text(encoding="utf-8")) + if meta.get("created_at"): + return datetime.datetime.fromisoformat(meta["created_at"]) + except (ValueError, OSError): + pass + return datetime.datetime.fromtimestamp( + state_dir.stat().st_mtime, tz=datetime.timezone.utc + ) + + +def main() -> int: + p = argparse.ArgumentParser(description=__doc__) + p.add_argument("ref", nargs="?", help="Ref to remove") + p.add_argument("--older-than", help="Remove all refs older than this (e.g. 7d, 12h)") + p.add_argument("--list", action="store_true", help="List active refs and exit") + args = p.parse_args() + + if not STATE_ROOT.exists(): + print(json.dumps({"ok": True, "removed": [], "note": "state root not present"})) + return 0 + + if args.list: + entries = [] + for d in sorted(STATE_ROOT.iterdir()): + if not d.is_dir(): + continue + created = load_created_at(d) + entries.append({"ref": d.name, "created_at": created.isoformat() if created else None}) + print(json.dumps({"ok": True, "refs": entries}, ensure_ascii=False)) + return 0 + + removed: list[str] = [] + + if args.ref: + target = STATE_ROOT / args.ref.strip().lower() + if not target.exists(): + sys.exit(f"error: ref {args.ref} not found at {target}") + shutil.rmtree(target) + removed.append(target.name) + + if args.older_than: + cutoff = datetime.datetime.now(datetime.timezone.utc) - parse_duration(args.older_than) + for d in STATE_ROOT.iterdir(): + if not d.is_dir(): + continue + created = load_created_at(d) + if created and created < cutoff: + shutil.rmtree(d) + removed.append(d.name) + + if not args.ref and not args.older_than: + sys.exit("error: provide a ref, --older-than, or --list") + + print(json.dumps({"ok": True, "removed": removed}, ensure_ascii=False)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/skills/responsive-agent/scripts/pause_and_ask.py b/skills/responsive-agent/scripts/pause_and_ask.py new file mode 100755 index 00000000..ff7622e4 --- /dev/null +++ b/skills/responsive-agent/scripts/pause_and_ask.py @@ -0,0 +1,212 @@ +#!/usr/bin/env python3 +"""Pause the current task, save its context, and post a Slack ask message. + +Generates a short reference id, persists the task context under +~/.ductor-slack/workspace/responsive_state//, then posts a NEW Slack +message (not a thread reply) to the target channel mentioning the chosen +human. The message ends with `[ret-ref:]` on its own line so that the +next agent session, triggered by the human's reply, can resume the task. + +Usage: + python3 pause_and_ask.py \\ + --channel C0XXXXXX \\ + --mention U0XXXXXX \\ + --question "How should I price the X plan?" \\ + --context-stdin + + python3 pause_and_ask.py \\ + --channel C0XXXXXX \\ + --mention U0XXXXXX \\ + --question "..." \\ + --context-file /path/to/ctx.md \\ + --requester-channel C111 \\ + --requester-thread-ts 1700000000.000001 +""" +from __future__ import annotations + +import argparse +import datetime +import json +import os +import secrets +import subprocess +import sys +import urllib.request +from pathlib import Path + +WORKSPACE = Path.home() / ".ductor-slack" / "workspace" +STATE_ROOT = WORKSPACE / "responsive_state" +SLACK_SEND = WORKSPACE / "tools" / "user_tools" / "slack_send.py" +CONFIG_PATH = Path.home() / ".ductor-slack" / "config" / "config.json" +IDENTITY_CACHE = STATE_ROOT / ".bot_identity.json" + + +def load_bot_token() -> str | None: + try: + cfg = json.loads(CONFIG_PATH.read_text(encoding="utf-8")) + except Exception: + return None + return ((cfg.get("slack") or {}).get("bot_token")) or None + + +def get_self_user_id() -> str | None: + """Return this bot's own Slack user id (U...). Cached on disk.""" + try: + cached = json.loads(IDENTITY_CACHE.read_text(encoding="utf-8")) + if isinstance(cached, dict) and cached.get("user_id"): + return cached["user_id"] + except Exception: + pass + + token = load_bot_token() + if not token: + return None + try: + req = urllib.request.Request( + "https://slack.com/api/auth.test", + headers={"Authorization": f"Bearer {token}"}, + method="POST", + ) + with urllib.request.urlopen(req, timeout=10) as resp: + body = json.loads(resp.read().decode("utf-8")) + except Exception: + return None + if not body.get("ok") or not body.get("user_id"): + return None + try: + IDENTITY_CACHE.parent.mkdir(parents=True, exist_ok=True) + IDENTITY_CACHE.write_text( + json.dumps({"user_id": body["user_id"], "user": body.get("user")}) + "\n", + encoding="utf-8", + ) + except OSError: + pass + return body["user_id"] + + +def new_ref() -> str: + for _ in range(20): + ref = secrets.token_hex(3) # 6 hex chars + if not (STATE_ROOT / ref).exists(): + return ref + sys.exit("error: could not allocate a free ref after 20 tries") + + +def format_mention(s: str) -> str: + s = s.strip() + if not s: + sys.exit("error: empty --mention") + if s.startswith("<@") and s.endswith(">"): + return s + if s[0] in ("U", "W") and s[1:].isalnum(): + return f"<@{s}>" + return s + + +def read_context(args: argparse.Namespace) -> str: + if args.context_file: + return Path(args.context_file).read_text(encoding="utf-8") + if args.context_stdin: + return sys.stdin.read() + sys.exit("error: provide --context-file or --context-stdin") + + +def main() -> int: + p = argparse.ArgumentParser(description=__doc__) + p.add_argument("--channel", required=True, help="Target channel id (e.g. C0XXX) to post the ask") + p.add_argument("--mention", required=True, help="Slack user id to mention (U... or W...) — handles like @name will not notify") + p.add_argument("--question", required=True, help="Short question to the human (one or two lines)") + p.add_argument("--context-file", help="Path to a markdown file containing the resume context") + p.add_argument("--context-stdin", action="store_true", help="Read resume context from stdin") + p.add_argument("--requester-channel", help="Original requester channel id (for reporting back)") + p.add_argument("--requester-thread-ts", help="Original requester thread ts (for reporting back)") + p.add_argument("--ref", help="Override the generated ref (advanced, must be 6 hex chars)") + p.add_argument( + "--reply-hint", + help=( + "Override the auto-appended reply hint. Use the placeholder {bot} which " + "gets replaced by <@BOT_USER_ID>. Pass an empty string to disable the hint." + ), + default=None, + ) + args = p.parse_args() + + context = read_context(args).strip() + if not context: + sys.exit("error: empty context — the resume agent will need at least the task description") + + STATE_ROOT.mkdir(parents=True, exist_ok=True) + + if args.ref: + ref = args.ref.strip().lower() + if len(ref) != 6 or not all(c in "0123456789abcdef" for c in ref): + sys.exit("error: --ref must be 6 lowercase hex chars") + if (STATE_ROOT / ref).exists(): + sys.exit(f"error: ref {ref} already exists") + else: + ref = new_ref() + + state_dir = STATE_ROOT / ref + state_dir.mkdir(parents=True) + + (state_dir / "context.md").write_text(context + "\n", encoding="utf-8") + + mention = format_mention(args.mention) + + # Build the reply hint. The recipient must @-mention this bot in their + # reply, otherwise Slack will not deliver the message to the bot and the + # paused task can never resume. + self_uid = get_self_user_id() + if args.reply_hint is None: + if self_uid: + hint = f"_Please reply with <@{self_uid}> mentioned so I can continue this task — without the mention I won't see your answer._" + else: + hint = "_Please @-mention this bot in your reply — without the mention I won't see your answer and can't continue the task._" + else: + hint = args.reply_hint.replace("{bot}", f"<@{self_uid}>" if self_uid else "@this-bot") + + parts = [f"{mention} {args.question.strip()}"] + if hint.strip(): + parts.append(hint.strip()) + parts.append(f"[ret-ref:{ref}]") + body = "\n\n".join(parts) + + proc = subprocess.run( + ["python3", str(SLACK_SEND), "--channel", args.channel, "--text", body], + capture_output=True, + text=True, + ) + if proc.returncode != 0: + # Roll back state so we don't leave a dangling ref + try: + (state_dir / "context.md").unlink() + state_dir.rmdir() + except OSError: + pass + sys.stderr.write(proc.stderr) + sys.stdout.write(proc.stdout) + return proc.returncode + + try: + send_result = json.loads(proc.stdout.strip().splitlines()[-1]) + except (ValueError, IndexError): + send_result = {"raw": proc.stdout} + + meta = { + "ref": ref, + "ask_channel": send_result.get("channel") or args.channel, + "ask_ts": send_result.get("ts"), + "ask_permalink": send_result.get("permalink"), + "mention": args.mention, + "requester_channel": args.requester_channel, + "requester_thread_ts": args.requester_thread_ts, + "created_at": datetime.datetime.now(datetime.timezone.utc).isoformat(), + } + (state_dir / "meta.json").write_text(json.dumps(meta, indent=2) + "\n", encoding="utf-8") + + print(json.dumps({"ok": True, **meta}, ensure_ascii=False)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/skills/responsive-agent/scripts/resume.py b/skills/responsive-agent/scripts/resume.py new file mode 100755 index 00000000..c92bd874 --- /dev/null +++ b/skills/responsive-agent/scripts/resume.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 +"""Resume a paused task by ref. + +Loads ~/.ductor-slack/workspace/responsive_state//context.md plus its +meta.json and writes both to stdout so the agent can pick up where the prior +session left off. + +Usage: + python3 resume.py abc123 + echo "...thread context..." | python3 resume.py --stdin +""" +from __future__ import annotations + +import argparse +import json +import re +import sys +from pathlib import Path + +STATE_ROOT = Path.home() / ".ductor-slack" / "workspace" / "responsive_state" +ANCHOR_RE = re.compile(r"\[ret-ref:([0-9a-f]{6})\]") +# Skill-mode anchors that callers can embed in the saved context to signal +# how the resumed session should behave. resume.py surfaces the first match +# on its first output line so the model sees it before reading any body. +SCOPE_ANCHOR_RES = ( + re.compile(r"\[stask-exec:[0-9a-f]{4,16}\]"), +) + + +def find_ref_in_text(text: str) -> str | None: + m = ANCHOR_RE.search(text) + return m.group(1) if m else None + + +def find_scope_anchor(text: str) -> str | None: + for pat in SCOPE_ANCHOR_RES: + m = pat.search(text) + if m: + return m.group(0) + return None + + +def main() -> int: + p = argparse.ArgumentParser(description=__doc__) + p.add_argument("ref", nargs="?", help="6-hex-char reference id") + p.add_argument("--stdin", action="store_true", help="Scan stdin for the first [ret-ref:XXXXXX] anchor") + args = p.parse_args() + + ref = args.ref + if not ref and args.stdin: + ref = find_ref_in_text(sys.stdin.read()) + if not ref: + sys.exit("error: provide a ref or pipe text containing [ret-ref:XXXXXX] with --stdin") + + ref = ref.strip().lower() + if len(ref) != 6 or not all(c in "0123456789abcdef" for c in ref): + sys.exit(f"error: invalid ref {ref!r} — must be 6 hex chars") + + state_dir = STATE_ROOT / ref + ctx_path = state_dir / "context.md" + meta_path = state_dir / "meta.json" + + if not ctx_path.exists(): + sys.exit(f"error: no saved context for ref {ref} at {state_dir}") + + body = ctx_path.read_text(encoding="utf-8") + anchor = find_scope_anchor(body) + if anchor: + # Surface the anchor on the very first line so the agent re-enters + # the right skill mode without having to scan the saved body first. + sys.stdout.write(f"{anchor}\n") + sys.stdout.write(f"=== resume context for ref {ref} ===\n") + sys.stdout.write(body) + if meta_path.exists(): + sys.stdout.write("\n=== meta ===\n") + sys.stdout.write(meta_path.read_text(encoding="utf-8")) + sys.stdout.write(f"\n=== end ref {ref} ===\n") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/skills/super-tasker/SKILL.md b/skills/super-tasker/SKILL.md new file mode 100644 index 00000000..a12144dc --- /dev/null +++ b/skills/super-tasker/SKILL.md @@ -0,0 +1,324 @@ +--- +name: super-tasker +description: Multi-track task management for long-running agentic work. The skill owns a file-backed task DB and operates in two modes — Lead (default, walks the open frontier and dispatches one round of work) and Executor (a single task running in its own background-task session). Activates on slash commands like `/super-tasker`, natural-language asks to "run the task lead", cron-injected lead prompts, executor-completion callbacks injected into the parent session, and worker prompts that include `[stask-exec:]`. Builds on the responsive-agent skill for human-in-the-loop continuation — there are no hard time / step / token budgets, and the agent cannot self-fire a lead pass; the loop is driven by external triggers. +--- + +# Super Tasker + +Multi-track task orchestration that survives across agent sessions. + +Designed for the ductor / Slack environment: there is no continuous agent +loop. A session ends when the model stops emitting tool calls. The "loop" +is a sequence of independent sessions stitched together by external +triggers; the skill keeps state on disk so each session can pick up where +the previous one left off. + +## Modes + +Two modes — the activation context tells you which one to enter. + +- **Lead** (default). Walk the open frontier, dispatch one round of work, + report, stop. Entered on `/super-tasker`, natural-language equivalents, + a cron-injected message, a `create_task.py` completion callback (an + executor finished and its result was injected into the parent session), + or a `responsive-agent` resume whose saved context is not executor-scoped. +- **Executor**. Carry out one specific task in a dedicated background-task + session spawned by `scripts/spawn_executor.py`. Entered only when the + initial prompt contains `[stask-exec:]`. + +If both signals are present, executor wins — finish the assigned task +before doing any lead work. + +## What triggers a lead pass + +There is **no in-process loop and no self-trigger**. The agent cannot send +itself a Slack message to wake up a new session — sending a message to +your own chat does not re-enter the model. A new lead pass only fires when +one of these external events drops a prompt into the agent's session: + +1. **User Slack message** — the user types `/super-tasker`, "do a lead + pass", or any natural-language equivalent. +2. **Executor completion callback** — `tools/task_tools/create_task.py` + delivers the finished task's result back into the parent session as a + message. That message re-enters the model and the skill should run a + lead pass on the new state. +3. **Responsive-agent resume** — a paused lead wakes up because the user + replied. The saved context is replayed, and if it does not carry an + executor anchor, treat it as a lead-pass continuation. +4. **Cron or webhook** — `tools/cron_tools/` or `tools/webhook_tools/` + fire a prompt into the agent. Cron is an external scheduler, not the + agent talking to itself. + +A lead pass ends after dispatch + report. Do not poll, do not loop, do +not try to schedule the next pass from inside the current one. The next +pass arrives from one of the four triggers above. + +## Data model + +Backing store: `~/.ductor-slack/workspace/super_tasker_state/`. + +``` +super_tasker_state/ +├── tasks/ +│ └── / +│ ├── task.json # canonical attributes + relations +│ ├── events.jsonl # append-only event feed +│ └── context/ # files the executor is allowed to read +└── index.json # id → name/status/depth cache (rebuildable) +``` + +`task.json` fields: + +| field | type | notes | +| -------------- | ------------------------------------- | ------------------------------------------------------------------------------------- | +| `id` | 8-hex string | stable, never reused | +| `name` | string | one-line title | +| `desc` | markdown string | what + why | +| `state` | markdown string | latest snapshot of progress; the executor overwrites this when it reports | +| `status` | `Open` `WIP` `Completed` `Dropped` `Merged` `Splitted` | see transitions below | +| `complexity` | `S` `M` `L` | size estimate | +| `depth` | int | 0 for user-created roots, +1 per split | +| `context_files`| list of absolute paths | the executor MUST limit reading to these + the task's own dir | +| `relations` | object | `split_from`, `merged_from`, `depends_on` (see below) | +| `created_at` | ISO-8601 | | +| `updated_at` | ISO-8601 | | + +Relations: + +- `split_from: | null` — the parent this task was split out of. +- `merged_from: [, ...]` — sources that were collapsed into this task. +- `depends_on: [, ...]` — must reach a terminal-positive status before + this task can move to `WIP`. + +`events.jsonl` rows (one per line): + +```json +{"ts":"2026-05-12T03:00:00Z","kind":"created","by":"lead","note":"..."} +{"ts":"...","kind":"status","from":"Open","to":"WIP","by":"executor"} +{"ts":"...","kind":"state","by":"executor","note":""} +{"ts":"...","kind":"reeval-request","by":"executor","note":"too big, want split"} +{"ts":"...","kind":"user-note","by":"user","note":"prefer option B"} +{"ts":"...","kind":"split","children":["...","..."],"by":"lead"} +{"ts":"...","kind":"merge","sources":["...","..."],"into":"","by":"lead"} +``` + +The event feed is the source of truth for "why did this move". `state` is +the latest snapshot for quick reading. + +### Status transitions + +``` +Open ──► WIP ──► Completed + │ │ ──► Dropped + │ │ ──► Splitted (children created — this task is now terminal) + │ │ ──► Merged (collapsed into another — terminal) + └─► Splitted / Merged / Dropped (allowed without entering WIP) +``` + +`Splitted`, `Merged`, `Completed`, `Dropped` are terminal — **one-way +state transitions, no rollup**. Concretely: + +- `a` splits into `a1, a2` → `a` is permanently `Splitted` and is never + revived, re-opened, or re-tracked. The active set replaces `a` with + `{a1, a2}`. There is no "complete the parent when all children are done" + rollup — the parent has already left the working set. +- `b1, b2` merge into `b3` → `b1, b2` are permanently `Merged` and are no + longer tracked. The active set replaces them with `{b3}`. Completion + of the original goal is reached when `b3` itself terminates positively. +- A goal is "done" when every currently-active leaf descended from it + has reached a terminal status. The lead never walks back up to + reconcile a Splitted/Merged ancestor. + +### Frontier definition + +The **frontier** is the lead's working set: every non-terminal task whose +`split_from` parent is either absent or itself terminal. + +A separate, stricter notion of **top-level** is used only for the +user-facing "list my major initiatives" view: + +- `relations.split_from is None` AND +- no other task has this task in its `relations.merged_from`. + +The lead pass walks the **frontier**, not top-level. (Children of a +Splitted parent are on the frontier; the Splitted parent is not.) + +### Recursion depth limit + +`depth` is capped at **3**. A task at depth 3 cannot be split further — +the lead must execute it or merge it sideways. `tasker.py split` rejects +attempts beyond the cap. + +## Lead pass + +Run on every activation that is not in executor mode. + +1. `python3 scripts/tasker.py brief --json` — get the frontier summary + (every non-terminal task whose split_from parent is terminal-or-absent, + with depth, complexity, last event, blocked deps, and any pending + reeval signals). +2. For each active root, decide one of: + - **idle** — task is `WIP` with an executor already in flight (last + event is recent and there is no completion). Skip. + - **execute** — task is `Open`, complexity `S`, dependencies all + terminal-positive (`Completed` or `Merged`). Spawn an executor. + - **plan** — task is `Open`, complexity `M`/`L`, depth < 3, AND has a + fresh `reeval-request` event OR has never been planned. Spawn a + planner subagent (still uses `spawn_executor.py`, with the planner + prompt template). The planner decides: stay-and-execute, split into + N children, or merge with siblings. + - **wait** — dependencies not satisfied. Skip; lead will re-check + next pass. + - **ask** — task is ambiguous in a way only the user can resolve. + Pause via the **responsive-agent** skill. Do not invent a default. +3. After delegating, write a short summary to Slack: + - which roots you looked at, + - what you delegated and why, + - what is waiting on the user or on dependencies. + Then stop. Do not poll, do not sleep, do not try to keep the session + alive. The next lead pass is fired by one of the four external triggers + above (user msg, executor completion callback, responsive-agent resume, + cron/webhook). + +### Re-evaluation cadence + +Open design point in the draft — the chosen default is **triggered, not +on-every-pass**. Reasons: + +- Every re-eval is a subagent call. Doing it for every Open task on every + lead pass is expensive and noisy. +- The cases that actually need re-eval are well-defined and observable + in the event feed. + +Re-eval is triggered when **any** of these is true for a task: + +- The executor appended a `reeval-request` event. +- The task is `Open` and has never been planned (no prior `split`, + `merge`, or `execute` event). +- The user appended a `user-note` that explicitly says "reevaluate" / + "rethink" / "拆一下". +- The task has been `Open` for ≥ `STASK_STALE_DAYS` (default 3) with no + events — likely abandoned, worth reconsidering. + +Tune the staleness threshold per workspace via env or by editing the +`brief` output filter in `tasker.py`. + +## Executor mode + +Entered only when the prompt contains `[stask-exec:]`. + +Hard rules: + +1. **Scope.** Read only the task's own dir (`task.json`, `events.jsonl`, + `context/`) and the absolute paths in `context_files`. Do not read + other tasks or unrelated parts of the workspace. The launcher already + filtered context — trust it. +2. **First action.** `tasker.py update --status WIP` and append a + `state` event with one line of plan. +3. **Progress reporting.** Each meaningful step → append a `state` event. + Overwrite `task.state` with the latest snapshot. Keep it short — the + feed has the history. +4. **Need info from a human?** Pause via the **responsive-agent** skill. + Include the task id in the question. Do not block. +5. **Realize it is too big?** Append a `reeval-request` event with a + concrete proposed breakdown and stop. The lead will pick it up next + pass. +6. **Done.** `tasker.py update --status Completed` and write a + final `state` event with the deliverable pointer (file path, link, + summary). Do not message the requester directly unless the launcher's + prompt told you to — the lead is responsible for reporting up. + +## User intervention surface + +The user does not edit `events.jsonl` directly. All interventions are +verbs the user can speak to ductor in Slack — the agent translates them +to tasker.py calls. + +| user says… | agent runs | +| ----------------------------------------------------- | ---------------------------------------------------------------- | +| "list my tasks" / "show open tasks" | `tasker.py list --top-level --status Open WIP` | +| "show task abc12345" | `tasker.py show abc12345` | +| "add a task: build the X" / "new task: …" | `tasker.py add --name "…" --desc "…" [--complexity M]` | +| "drop task abc12345 — out of date" | `tasker.py update abc12345 --status Dropped --note "out of date"`| +| "for task abc12345, prefer option B" | `tasker.py event abc12345 --kind user-note --note "prefer B"` | +| "reeval task abc12345" | `tasker.py event abc12345 --kind reeval-request --note "user"` | +| "merge abc12345 + def67890 into one" | `tasker.py merge abc12345 def67890 --name "…" --desc "…"` | +| "run the task lead" / `/super-tasker` | enter Lead mode (run a lead pass) | + +The lead reads `user-note` events on the next pass and adjusts plans +accordingly. + +## Integration with responsive-agent + +The lead and the executor both pause through the responsive-agent skill. +The responsive-agent skill is the only continuation mechanism — there are +no internal timeouts or retry loops in super-tasker. + +When pausing from an executor, **always** include the `[stask-exec:]` +anchor in the saved context so the resume can route correctly: + +```text +## Task +[stask-exec:ab12cd34] + +``` + +On resume, `responsive-agent`'s `resume.py` scans the saved context for +`[stask-exec:]` and, if found, surfaces that anchor on the first +output line. The replaying agent therefore re-enters executor mode for +that task without having to read the body first. If no executor anchor is +present, the resume is a lead-pass continuation. + +## Scripts + +All scripts live under `scripts/` and are invoked via `python3`: + +- `scripts/tasker.py` — the only DB tool. Subcommands: `init`, `add`, + `list`, `show`, `update`, `event`, `split`, `merge`, `brief`, `gc`. + Run with `--help` per subcommand. Output is JSON unless `--text` is + passed. +- `scripts/spawn_executor.py` — wrapper that builds the executor prompt + (with `[stask-exec:]` and the task's context window) and calls + `tools/task_tools/create_task.py`. Use this from lead instead of + hand-rolling the prompt. + +## Cron / activation patterns + +- **Manual lead pass**: the user sends `/super-tasker` (or natural + language) in any Slack chat. This is the primary entry point. +- **Cron-driven lead pass**: create a cron via `tools/cron_tools/` whose + payload is a prompt like `[super-tasker:lead-pass] do a lead pass`. + Ductor's cron runner injects the payload into the agent's session as a + fresh prompt — that is what wakes the model up. The bracketed token is + purely a hint for the agent; it does nothing on its own. +- **Executor completion**: handled automatically by ductor. When the + background task spawned via `create_task.py` finishes, its result is + injected into the parent session as a message. The skill should react + by running a lead pass on the new state. +- **Worker resume**: handled automatically by responsive-agent — the + saved context carries the `[stask-exec:]` anchor and `resume.py` + surfaces it at the top of the replay so the agent re-enters executor + mode without having to scan the body. + +Do **not** try to wake yourself up. Sending a Slack message to your own +chat (whether via the messenger tools or any other means) does not +re-enter the model — only the four external triggers do. + +## Storage hygiene + +- `tasker.py gc` prunes context dirs of tasks in terminal status older + than `STASK_GC_DAYS` (default 30). It leaves `task.json` and + `events.jsonl` as audit trail. +- `tasker.py rebuild-index` recomputes `index.json` from the on-disk + task dirs. Run after manual edits. + +## Non-goals + +- This is **not** an agentic loop framework. There are no in-loop + budgets (time, steps, tokens). Control comes from invocation cadence + (cron + Slack), file persistence, and responsive-agent. +- This is **not** a project manager UI. The user lives in Slack; the + surface is verbs, not a board. +- The skill does not auto-spawn sub-agents (different bots). It uses + background tasks via `tools/task_tools/`. Sub-agent creation remains + user-initiated only, per ductor rules. diff --git a/skills/super-tasker/scripts/spawn_executor.py b/skills/super-tasker/scripts/spawn_executor.py new file mode 100755 index 00000000..02938bd3 --- /dev/null +++ b/skills/super-tasker/scripts/spawn_executor.py @@ -0,0 +1,165 @@ +#!/usr/bin/env python3 +"""spawn_executor.py — launch a background task to execute one super-tasker task. + +Wraps tools/task_tools/create_task.py with the super-tasker executor prompt. +The prompt contains the `[stask-exec:]` anchor so the spawned session +enters Executor mode (see SKILL.md). + +Usage: + python3 scripts/spawn_executor.py [--role execute|plan] + [--extra "extra instructions"] + [--name "human name"] + [--priority background|interactive|batch] + +Defaults: role=execute (run the task), priority=background. + +Pass --role plan to spawn a planner subagent for an M/L task — the planner +decides whether to split / merge / execute and writes the corresponding events. +""" +from __future__ import annotations + +import argparse +import json +import os +import subprocess +import sys +from pathlib import Path + +THIS_DIR = Path(__file__).resolve().parent +SKILL_DIR = THIS_DIR.parent +TASKER = THIS_DIR / "tasker.py" + +DUCTOR_HOME = Path(os.environ.get("DUCTOR_HOME") or (Path.home() / ".ductor-slack")) +TASK_TOOLS = DUCTOR_HOME / "workspace" / "tools" / "task_tools" +CREATE_TASK = TASK_TOOLS / "create_task.py" + + +EXEC_TEMPLATE = """\ +[stask-exec:{task_id}] +[super-tasker mode=executor role={role} task={task_id}] + +You are running as the **{role}** for super-tasker task `{task_id}`. + +Read the skill spec at: + {skill_dir}/SKILL.md + +The DB CLI is at: + {tasker} + +Task snapshot at spawn time: +{task_snapshot} + +Recent events: +{events_tail} + +Allowed reads (context window for this task): + - {task_dir}/task.json + - {task_dir}/events.jsonl + - {task_dir}/context/ (everything in here) + - The absolute paths in task.context_files (listed above) + +Hard rules: + 1. Read ONLY the files listed above. Do not browse the wider workspace. + 2. First call: `python3 {tasker} update {task_id} --status WIP --by executor` + and append a one-line plan via `--state ""`. + 3. Append a `state` event for each meaningful step. + 4. If you need a human, use the responsive-agent skill. Include the + `[stask-exec:{task_id}]` anchor in the saved context so the resumed + session re-enters executor mode for this task. responsive-agent's + resume.py will surface that anchor on its first output line. + 5. If the task is too big, append a `reeval-request` event with a concrete + proposed breakdown and stop. Do NOT split it yourself — that is the + lead's job. + 6. When done: `update --status Completed --state ""`. + +{role_extra} + +{extra} +""" + +ROLE_EXTRAS = { + "execute": ( + "Your role is to FINISH this task. Do not split or merge. If you " + "discover it is actually too big, follow rule 5 and stop." + ), + "plan": ( + "Your role is PLANNER. Decide one of:\n" + " (a) stay-and-execute (downgrade complexity if needed and run the task)\n" + " (b) split into N children with dependencies\n" + " (c) merge with a sibling and create a combined task\n" + "Use these tasker.py commands:\n" + " - split: `tasker.py split {task_id} --child 'name|complexity[|desc]' ...`\n" + " - merge: `tasker.py merge --name '...' --complexity M`\n" + " - downgrade: `tasker.py update {task_id} --complexity S` then execute.\n" + "After deciding, append a `state` event explaining the choice. If you " + "split, the lead will pick up the children on the next pass — you do " + "NOT spawn workers for them." + ), +} + + +def load_snapshot(task_id: str) -> tuple[dict, list[dict], Path]: + # Run tasker.py show to get a consistent snapshot. + proc = subprocess.run( + ["python3", str(TASKER), "show", task_id, "--tail", "20"], + capture_output=True, text=True, check=False, + ) + if proc.returncode != 0: + raise SystemExit(f"tasker.py show failed: {proc.stderr.strip() or proc.stdout.strip()}") + data = json.loads(proc.stdout) + task = data["task"] + events = data["events"] + # Resolve task_dir from STASK_STATE_DIR or default. + root = Path(os.environ.get("STASK_STATE_DIR") or (DUCTOR_HOME / "workspace" / "super_tasker_state")) + return task, events, root / "tasks" / task_id + + +def main(argv: list[str]) -> int: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("task_id") + ap.add_argument("--role", choices=("execute", "plan"), default="execute") + ap.add_argument("--extra", default="", help="extra instructions appended to the prompt") + ap.add_argument("--name", default="", help="human-readable task name for ductor's task list") + ap.add_argument("--priority", default="background", choices=("interactive", "background", "batch")) + ap.add_argument("--dry-run", action="store_true", help="print the prompt instead of spawning") + args = ap.parse_args(argv) + + if not CREATE_TASK.exists(): + raise SystemExit(f"create_task.py not found at {CREATE_TASK} — is DUCTOR_HOME set correctly?") + + task, events, task_dir = load_snapshot(args.task_id) + snapshot_lines = json.dumps(task, indent=2, ensure_ascii=False) + events_tail = ( + "\n".join(json.dumps(e, ensure_ascii=False) for e in events[-10:]) + if events else "(no events yet)" + ) + + prompt = EXEC_TEMPLATE.format( + task_id=args.task_id, + role=args.role, + role_extra=ROLE_EXTRAS[args.role], + skill_dir=SKILL_DIR, + tasker=TASKER, + task_dir=task_dir, + task_snapshot=snapshot_lines, + events_tail=events_tail, + extra=args.extra, + ) + + if args.dry_run: + sys.stdout.write(prompt) + return 0 + + cmd = ["python3", str(CREATE_TASK)] + name = args.name or f"super-tasker {args.role} {args.task_id}" + cmd += ["--name", name, "--priority", args.priority, prompt] + + proc = subprocess.run(cmd, capture_output=True, text=True, check=False) + sys.stdout.write(proc.stdout) + if proc.stderr: + sys.stderr.write(proc.stderr) + return proc.returncode + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/skills/super-tasker/scripts/tasker.py b/skills/super-tasker/scripts/tasker.py new file mode 100755 index 00000000..8f2f14d3 --- /dev/null +++ b/skills/super-tasker/scripts/tasker.py @@ -0,0 +1,648 @@ +#!/usr/bin/env python3 +"""tasker.py — file-backed task DB for the super-tasker skill. + +State root: ~/.ductor-slack/workspace/super_tasker_state/ + +Subcommands (all emit JSON unless --text): + init create the state root + add create a new task (top-level or sub) + list list tasks, with filters + show print task.json + events tail + update mutate status / complexity / state / name / desc + event append an event row + split mark a task Splitted, create N children + merge mark sources Merged, create one new task + brief lead-pass summary (active roots + signals) + gc prune context dirs of long-terminal tasks + rebuild-index recompute index.json from task dirs + +Status enum: Open WIP Completed Dropped Merged Splitted +Complexity: S M L +""" +from __future__ import annotations + +import argparse +import datetime as _dt +import json +import os +import secrets +import sys +from pathlib import Path +from typing import Any, Iterable + +STATUSES = ("Open", "WIP", "Completed", "Dropped", "Merged", "Splitted") +TERMINAL = ("Completed", "Dropped", "Merged", "Splitted") +TERMINAL_POSITIVE = ("Completed", "Merged") # dependencies count as satisfied +COMPLEXITIES = ("S", "M", "L") +EVENT_KINDS = ( + "created", + "status", + "state", + "complexity", + "rename", + "reeval-request", + "user-note", + "split", + "merge", + "depend", +) +MAX_DEPTH = 3 + + +# --------------------------------------------------------------------------- +# Paths and io helpers +# --------------------------------------------------------------------------- + +def state_root() -> Path: + override = os.environ.get("STASK_STATE_DIR") + if override: + return Path(override).expanduser() + return Path.home() / ".ductor-slack" / "workspace" / "super_tasker_state" + + +def tasks_dir() -> Path: + return state_root() / "tasks" + + +def index_path() -> Path: + return state_root() / "index.json" + + +def task_dir(task_id: str) -> Path: + return tasks_dir() / task_id + + +def now_iso() -> str: + return _dt.datetime.now(_dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + + +def new_id() -> str: + # 8 hex chars — ~4 billion slots, collision-free for any realistic backlog. + return secrets.token_hex(4) + + +def ensure_state() -> None: + tasks_dir().mkdir(parents=True, exist_ok=True) + if not index_path().exists(): + index_path().write_text("{}\n") + + +def read_json(path: Path) -> Any: + with path.open("r", encoding="utf-8") as fh: + return json.load(fh) + + +def write_json(path: Path, data: Any) -> None: + tmp = path.with_suffix(path.suffix + ".tmp") + with tmp.open("w", encoding="utf-8") as fh: + json.dump(data, fh, indent=2, ensure_ascii=False) + fh.write("\n") + tmp.replace(path) + + +def load_task(task_id: str) -> dict: + p = task_dir(task_id) / "task.json" + if not p.exists(): + raise SystemExit(f"task not found: {task_id}") + return read_json(p) + + +def save_task(task: dict) -> None: + task["updated_at"] = now_iso() + write_json(task_dir(task["id"]) / "task.json", task) + update_index_entry(task) + + +def append_event(task_id: str, kind: str, **extra) -> dict: + if kind not in EVENT_KINDS: + raise SystemExit(f"unknown event kind: {kind}") + row = {"ts": now_iso(), "kind": kind, **extra} + with (task_dir(task_id) / "events.jsonl").open("a", encoding="utf-8") as fh: + fh.write(json.dumps(row, ensure_ascii=False) + "\n") + return row + + +def read_events(task_id: str) -> list[dict]: + p = task_dir(task_id) / "events.jsonl" + if not p.exists(): + return [] + out: list[dict] = [] + with p.open("r", encoding="utf-8") as fh: + for line in fh: + line = line.strip() + if not line: + continue + try: + out.append(json.loads(line)) + except json.JSONDecodeError: + continue + return out + + +# --------------------------------------------------------------------------- +# Index — flat map of id -> {name, status, complexity, depth, top_level} +# --------------------------------------------------------------------------- + +def load_index() -> dict: + if not index_path().exists(): + return {} + try: + return read_json(index_path()) + except json.JSONDecodeError: + return {} + + +def save_index(idx: dict) -> None: + write_json(index_path(), idx) + + +def update_index_entry(task: dict) -> None: + idx = load_index() + idx[task["id"]] = { + "name": task["name"], + "status": task["status"], + "complexity": task["complexity"], + "depth": task["depth"], + } + save_index(idx) + + +def all_task_ids() -> list[str]: + if not tasks_dir().exists(): + return [] + return sorted(p.name for p in tasks_dir().iterdir() if (p / "task.json").exists()) + + +def all_tasks() -> list[dict]: + return [load_task(tid) for tid in all_task_ids()] + + +# --------------------------------------------------------------------------- +# Relations / top-level +# --------------------------------------------------------------------------- + +def is_top_level(task: dict, all_tasks_cache: list[dict] | None = None) -> bool: + """Victor's strict definition: a user-created root with no parent at all. + + Used for the user-facing `list --top-level` view (major initiatives). + """ + if task["relations"].get("split_from"): + return False + if all_tasks_cache is None: + all_tasks_cache = all_tasks() + for other in all_tasks_cache: + if task["id"] in other["relations"].get("merged_from", []): + return False + return True + + +def is_frontier(task: dict, by_id: dict[str, dict] | None = None) -> bool: + """A task is on the frontier (lead's working set) if it is non-terminal + AND its split_from parent is either absent or terminal. Merge sources are + terminal (`Merged`) and so are not on the frontier; the merge result is. + """ + if task["status"] in TERMINAL: + return False + parent_id = task["relations"].get("split_from") + if not parent_id: + return True + if by_id is None: + by_id = {t["id"]: t for t in all_tasks()} + parent = by_id.get(parent_id) + if parent is None: + return True + return parent["status"] in TERMINAL + + +def deps_satisfied(task: dict) -> tuple[bool, list[str]]: + """Return (satisfied, unsatisfied_ids).""" + blockers: list[str] = [] + for dep in task["relations"].get("depends_on", []): + try: + dep_task = load_task(dep) + except SystemExit: + blockers.append(dep) + continue + if dep_task["status"] not in TERMINAL_POSITIVE: + blockers.append(dep) + return (not blockers, blockers) + + +# --------------------------------------------------------------------------- +# Mutations +# --------------------------------------------------------------------------- + +def make_task( + name: str, + desc: str, + complexity: str, + depth: int = 0, + split_from: str | None = None, + merged_from: list[str] | None = None, + depends_on: list[str] | None = None, + context_files: list[str] | None = None, + state_text: str = "", +) -> dict: + if complexity not in COMPLEXITIES: + raise SystemExit(f"complexity must be one of {COMPLEXITIES}") + if depth > MAX_DEPTH: + raise SystemExit(f"depth {depth} exceeds MAX_DEPTH={MAX_DEPTH}") + tid = new_id() + task_dir(tid).mkdir(parents=True, exist_ok=False) + (task_dir(tid) / "context").mkdir(exist_ok=True) + (task_dir(tid) / "events.jsonl").touch() + task = { + "id": tid, + "name": name, + "desc": desc, + "state": state_text, + "status": "Open", + "complexity": complexity, + "depth": depth, + "context_files": context_files or [], + "relations": { + "split_from": split_from, + "merged_from": merged_from or [], + "depends_on": depends_on or [], + }, + "created_at": now_iso(), + "updated_at": now_iso(), + } + save_task(task) + append_event(tid, "created", by="lead", note=f"complexity={complexity} depth={depth}") + return task + + +def transition_status(task: dict, new_status: str, by: str, note: str = "") -> dict: + if new_status not in STATUSES: + raise SystemExit(f"status must be one of {STATUSES}") + if task["status"] in TERMINAL: + raise SystemExit(f"task {task['id']} is terminal ({task['status']}), cannot change") + if task["status"] == new_status: + return task + append_event(task["id"], "status", **{"from": task["status"], "to": new_status, "by": by, "note": note}) + task["status"] = new_status + save_task(task) + return task + + +# --------------------------------------------------------------------------- +# Subcommands +# --------------------------------------------------------------------------- + +def cmd_init(args: argparse.Namespace) -> Any: + ensure_state() + return {"ok": True, "root": str(state_root())} + + +def cmd_add(args: argparse.Namespace) -> Any: + ensure_state() + task = make_task( + name=args.name, + desc=args.desc or "", + complexity=args.complexity, + depth=args.depth, + split_from=args.split_from, + depends_on=args.depends_on or [], + context_files=args.context_files or [], + state_text=args.state or "", + ) + return task + + +def cmd_list(args: argparse.Namespace) -> Any: + ensure_state() + tasks = all_tasks() + by_id = {t["id"]: t for t in tasks} + out: list[dict] = [] + for t in tasks: + if args.status and t["status"] not in args.status: + continue + if args.complexity and t["complexity"] not in args.complexity: + continue + if args.top_level and not is_top_level(t, tasks): + continue + if args.frontier and not is_frontier(t, by_id): + continue + out.append({ + "id": t["id"], + "name": t["name"], + "status": t["status"], + "complexity": t["complexity"], + "depth": t["depth"], + "top_level": is_top_level(t, tasks), + "frontier": is_frontier(t, by_id), + "deps": t["relations"]["depends_on"], + "split_from": t["relations"]["split_from"], + "updated_at": t["updated_at"], + }) + return out + + +def cmd_show(args: argparse.Namespace) -> Any: + task = load_task(args.id) + events = read_events(args.id) + if args.tail: + events = events[-args.tail:] + return {"task": task, "events": events, "top_level": is_top_level(task)} + + +def cmd_update(args: argparse.Namespace) -> Any: + task = load_task(args.id) + if args.status: + task = transition_status(task, args.status, by=args.by, note=args.note or "") + if args.complexity: + if args.complexity not in COMPLEXITIES: + raise SystemExit(f"complexity must be one of {COMPLEXITIES}") + if task["complexity"] != args.complexity: + append_event(task["id"], "complexity", **{"from": task["complexity"], "to": args.complexity, "by": args.by, "note": args.note or ""}) + task["complexity"] = args.complexity + if args.name: + append_event(task["id"], "rename", **{"from": task["name"], "to": args.name, "by": args.by}) + task["name"] = args.name + if args.desc is not None: + task["desc"] = args.desc + if args.state is not None: + task["state"] = args.state + append_event(task["id"], "state", by=args.by, note=args.note or "") + if args.add_context: + task["context_files"] = sorted(set(task["context_files"]) | set(args.add_context)) + if args.add_dep: + task["relations"]["depends_on"] = sorted(set(task["relations"]["depends_on"]) | set(args.add_dep)) + for dep in args.add_dep: + append_event(task["id"], "depend", on=dep, by=args.by) + save_task(task) + return task + + +def cmd_event(args: argparse.Namespace) -> Any: + load_task(args.id) # validate existence + return append_event(args.id, args.kind, by=args.by, note=args.note or "") + + +def cmd_split(args: argparse.Namespace) -> Any: + parent = load_task(args.id) + if parent["status"] in TERMINAL: + raise SystemExit(f"cannot split terminal task {parent['id']} ({parent['status']})") + if parent["depth"] >= MAX_DEPTH: + raise SystemExit(f"depth {parent['depth']} at MAX_DEPTH; cannot split further") + if not args.children: + raise SystemExit("provide at least one --child") + children: list[dict] = [] + for spec in args.children: + # spec format: "name|complexity[|desc]" + parts = spec.split("|", 2) + if len(parts) < 2: + raise SystemExit(f"bad --child spec: {spec!r}; expected name|complexity[|desc]") + c_name, c_complexity = parts[0], parts[1] + c_desc = parts[2] if len(parts) > 2 else "" + child = make_task( + name=c_name, + desc=c_desc, + complexity=c_complexity, + depth=parent["depth"] + 1, + split_from=parent["id"], + context_files=list(parent["context_files"]), + ) + children.append(child) + append_event(parent["id"], "split", children=[c["id"] for c in children], by=args.by, note=args.note or "") + parent = transition_status(parent, "Splitted", by=args.by, note=args.note or "") + return {"parent": parent, "children": children} + + +def cmd_merge(args: argparse.Namespace) -> Any: + sources = [load_task(sid) for sid in args.sources] + for s in sources: + if s["status"] in TERMINAL: + raise SystemExit(f"cannot merge terminal task {s['id']} ({s['status']})") + depth = min(s["depth"] for s in sources) + merged_ctx: list[str] = [] + for s in sources: + merged_ctx.extend(s["context_files"]) + merged_ctx = sorted(set(merged_ctx)) + result = make_task( + name=args.name, + desc=args.desc or "", + complexity=args.complexity, + depth=depth, + merged_from=[s["id"] for s in sources], + context_files=merged_ctx, + ) + for s in sources: + append_event(s["id"], "merge", into=result["id"], by=args.by, note=args.note or "") + transition_status(s, "Merged", by=args.by, note=f"merged into {result['id']}") + return {"result": result, "sources": [s["id"] for s in sources]} + + +def _stale_days(task: dict) -> float: + try: + ts = _dt.datetime.strptime(task["updated_at"], "%Y-%m-%dT%H:%M:%SZ") + except ValueError: + return 0.0 + ts = ts.replace(tzinfo=_dt.timezone.utc) + return (_dt.datetime.now(_dt.timezone.utc) - ts).total_seconds() / 86400.0 + + +def _has_event_kind(task_id: str, kind: str, since_event_kinds: Iterable[str] = ()) -> bool: + """True if the latest run of `since_event_kinds`-resetting events is followed + by at least one `kind` event. Used to detect *unhandled* reeval-requests.""" + reset = set(since_event_kinds) + found = False + for ev in read_events(task_id): + if reset and ev["kind"] in reset: + found = False + if ev["kind"] == kind: + found = True + return found + + +def cmd_brief(args: argparse.Namespace) -> Any: + ensure_state() + stale_days = float(os.environ.get("STASK_STALE_DAYS", "3")) + tasks = all_tasks() + by_id = {t["id"]: t for t in tasks} + active_roots: list[dict] = [] + for t in tasks: + if not is_frontier(t, by_id): + continue + sat, blockers = deps_satisfied(t) + events = read_events(t["id"]) + last_event = events[-1] if events else None + # has the executor (or user) asked for re-eval that hasn't been resolved yet? + # split/merge events reset the signal. + reeval_pending = _has_event_kind(t["id"], "reeval-request", since_event_kinds=("split", "merge")) + user_note_pending = _has_event_kind(t["id"], "user-note", since_event_kinds=("split", "merge", "status")) + planned_yet = any(ev["kind"] in ("split", "merge") for ev in events) or any( + ev["kind"] == "status" and ev.get("to") == "WIP" for ev in events + ) + signals = { + "deps_satisfied": sat, + "blockers": blockers, + "reeval_pending": reeval_pending, + "user_note_pending": user_note_pending, + "never_planned": not planned_yet, + "stale_days": round(_stale_days(t), 2), + "stale": _stale_days(t) >= stale_days and t["status"] == "Open" and not events[1:], + } + # suggested action — heuristic, the lead is free to override + if not sat: + action = "wait" + elif t["status"] == "WIP": + action = "idle" + elif t["complexity"] == "S": + action = "execute" + elif signals["reeval_pending"] or signals["never_planned"] or signals["stale"]: + action = "plan" if t["depth"] < MAX_DEPTH else "execute" + else: + action = "execute" + active_roots.append({ + "id": t["id"], + "name": t["name"], + "status": t["status"], + "complexity": t["complexity"], + "depth": t["depth"], + "split_from": t["relations"].get("split_from"), + "last_event": last_event, + "signals": signals, + "suggested_action": action, + }) + return {"now": now_iso(), "frontier": active_roots, "total": len(tasks)} + + +def cmd_gc(args: argparse.Namespace) -> Any: + gc_days = float(os.environ.get("STASK_GC_DAYS", "30")) + pruned: list[str] = [] + for t in all_tasks(): + if t["status"] not in TERMINAL: + continue + if _stale_days(t) < gc_days: + continue + ctx_dir = task_dir(t["id"]) / "context" + if ctx_dir.exists() and any(ctx_dir.iterdir()): + for child in ctx_dir.iterdir(): + if child.is_file(): + child.unlink() + else: + # leave subdirs untouched; user may have nested artefacts + pass + pruned.append(t["id"]) + return {"pruned": pruned, "gc_days": gc_days} + + +def cmd_rebuild_index(args: argparse.Namespace) -> Any: + idx: dict = {} + for t in all_tasks(): + idx[t["id"]] = { + "name": t["name"], + "status": t["status"], + "complexity": t["complexity"], + "depth": t["depth"], + } + save_index(idx) + return {"ok": True, "count": len(idx)} + + +# --------------------------------------------------------------------------- +# CLI +# --------------------------------------------------------------------------- + +def build_parser() -> argparse.ArgumentParser: + p = argparse.ArgumentParser(prog="tasker.py", description=__doc__) + p.add_argument("--text", action="store_true", help="pretty-print JSON instead of one-line JSON") + common = argparse.ArgumentParser(add_help=False) + common.add_argument("--text", action="store_true", help=argparse.SUPPRESS) + sub = p.add_subparsers(dest="cmd", required=True) + + sub.add_parser("init", parents=[common]) + + pa = sub.add_parser("add", help="create a new task", parents=[common]) + pa.add_argument("--name", required=True) + pa.add_argument("--desc", default="") + pa.add_argument("--complexity", choices=COMPLEXITIES, default="M") + pa.add_argument("--depth", type=int, default=0) + pa.add_argument("--split-from", default=None, help="parent task id (sets relations.split_from)") + pa.add_argument("--depends-on", nargs="*", default=[]) + pa.add_argument("--context-files", nargs="*", default=[]) + pa.add_argument("--state", default="") + + pl = sub.add_parser("list", parents=[common]) + pl.add_argument("--status", nargs="*", choices=STATUSES) + pl.add_argument("--complexity", nargs="*", choices=COMPLEXITIES) + pl.add_argument("--top-level", action="store_true", + help="strict roots only (Victor's definition: no SplitFrom parent + not in any merged_from)") + pl.add_argument("--frontier", action="store_true", + help="lead's working set: non-terminal tasks whose parent is terminal-or-absent") + + ps = sub.add_parser("show", parents=[common]) + ps.add_argument("id") + ps.add_argument("--tail", type=int, default=0, help="only the last N events (0=all)") + + pu = sub.add_parser("update", parents=[common]) + pu.add_argument("id") + pu.add_argument("--status", choices=STATUSES) + pu.add_argument("--complexity", choices=COMPLEXITIES) + pu.add_argument("--name") + pu.add_argument("--desc") + pu.add_argument("--state") + pu.add_argument("--add-context", nargs="*", default=[]) + pu.add_argument("--add-dep", nargs="*", default=[]) + pu.add_argument("--by", default="agent") + pu.add_argument("--note", default="") + + pe = sub.add_parser("event", parents=[common]) + pe.add_argument("id") + pe.add_argument("--kind", required=True, choices=EVENT_KINDS) + pe.add_argument("--note", default="") + pe.add_argument("--by", default="agent") + + psp = sub.add_parser("split", parents=[common]) + psp.add_argument("id") + psp.add_argument("--child", dest="children", action="append", required=True, + help="child spec 'name|complexity[|desc]'; repeat for each child") + psp.add_argument("--by", default="lead") + psp.add_argument("--note", default="") + + pm = sub.add_parser("merge", parents=[common]) + pm.add_argument("sources", nargs="+") + pm.add_argument("--name", required=True) + pm.add_argument("--desc", default="") + pm.add_argument("--complexity", choices=COMPLEXITIES, default="M") + pm.add_argument("--by", default="lead") + pm.add_argument("--note", default="") + + sub.add_parser("brief", parents=[common]) + sub.add_parser("gc", parents=[common]) + sub.add_parser("rebuild-index", parents=[common]) + + return p + + +def main(argv: list[str]) -> int: + parser = build_parser() + args = parser.parse_args(argv) + handler = { + "init": cmd_init, + "add": cmd_add, + "list": cmd_list, + "show": cmd_show, + "update": cmd_update, + "event": cmd_event, + "split": cmd_split, + "merge": cmd_merge, + "brief": cmd_brief, + "gc": cmd_gc, + "rebuild-index": cmd_rebuild_index, + }[args.cmd] + result = handler(args) + if args.text: + if isinstance(result, (dict, list)): + print(json.dumps(result, indent=2, ensure_ascii=False)) + else: + print(result) + else: + print(json.dumps(result, ensure_ascii=False)) + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:]))