From 32cc09b82ea1cccad046504da8d6d6c087cbee6d Mon Sep 17 00:00:00 2001 From: "Ethan Tan (AfterShip)" Date: Tue, 12 May 2026 06:28:33 +0000 Subject: [PATCH 1/3] Add super-tasker skill (file-backed multi-track task orchestration) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A new skill bundled at workspace/skills/super-tasker/ that owns a file-backed task DB and operates in two modes: - Lead (default): scans the open frontier, decides per-task whether to execute, plan, wait, or ask, and delegates work via background tasks. - Executor: spawned with [stask-exec:] anchor, scoped strictly to one task's context window. Designed for the ductor / Slack environment — no internal agentic loop, no time/step/token budgets. Continuation is driven by responsive-agent resumes and cron ticks. State lives at ~/.ductor-slack/workspace/super_tasker_state/. Includes: - SKILL.md spec (modes, data model, transitions, frontier semantics, re-eval cadence, executor rules, user intervention verbs) - scripts/tasker.py — single-file CLI (init/add/list/show/update/event/ split/merge/brief/gc/rebuild-index) - scripts/spawn_executor.py — wrapper that builds the executor prompt with the right anchor and context window and calls tools/task_tools/create_task.py. Honors Victor's design constraints: Status enum, S/M/L complexity, SplitFrom/MergedFrom/DependsOn relations, MAX_DEPTH=3, Splitted as a terminal state, top-level definition for user listing. Re-evaluation cadence defaults to triggered (reeval-request / never-planned / user-note / stale) rather than every-pass, with rationale in SKILL.md. Depends on the responsive-agent skill for human-in-the-loop continuation. --- .../workspace/skills/super-tasker/SKILL.md | 264 +++++++ .../super-tasker/scripts/spawn_executor.py | 164 +++++ .../skills/super-tasker/scripts/tasker.py | 648 ++++++++++++++++++ 3 files changed, 1076 insertions(+) create mode 100644 ductor_slack/_home_defaults/workspace/skills/super-tasker/SKILL.md create mode 100755 ductor_slack/_home_defaults/workspace/skills/super-tasker/scripts/spawn_executor.py create mode 100755 ductor_slack/_home_defaults/workspace/skills/super-tasker/scripts/tasker.py diff --git a/ductor_slack/_home_defaults/workspace/skills/super-tasker/SKILL.md b/ductor_slack/_home_defaults/workspace/skills/super-tasker/SKILL.md new file mode 100644 index 00000000..755dae4f --- /dev/null +++ b/ductor_slack/_home_defaults/workspace/skills/super-tasker/SKILL.md @@ -0,0 +1,264 @@ +--- +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, scans the open tree and orchestrates work) and Executor (a single task is delegated to a worker). Activates on slash commands like `/super-tasker`, natural-language asks to "run the task lead", cron pings that include `[super-tasker:lead-pass]`, 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; the loop is driven by Slack events and cron ticks. +--- + +# Super Tasker + +Multi-track task orchestration that survives across agent sessions. + +Designed for the ductor / Slack environment: there is no continuous agent +loop. The "loop" is a sequence of independent sessions (cron ticks, Slack +replies, responsive-agent resumes). The skill keeps state on disk so each +session can pick up exactly where the previous one left off. + +## Modes + +Two modes — the activation context tells you which one to enter. + +- **Lead** (default). Scan the open task tree, decide what to do next, and + delegate work. Entered on `/super-tasker`, natural-language equivalents, + cron pings, or any time the skill is invoked without an executor anchor. +- **Executor**. Carry out one specific task. Entered only when the prompt + contains `[stask-exec:]`. The wrapper that spawned you put that + anchor there. + +If both signals are present, executor wins — finish the assigned task +before doing any lead work. + +## 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. Per Victor's +spec: a split task stays in `Splitted` forever and is never revived. + +### Top-level definition + +A task is **top-level** iff: + +- `relations.split_from is None` AND +- no other task has this task in its `relations.merged_from`. + +The lead pass only considers top-level tasks with `status in {Open, WIP}`. + +### 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 active-root summary + (Open + WIP top-level tasks, with their 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. The next lead pass is triggered + by the next user message, the cron tick, or the executor completing. + +### 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, **always** include the task id and the mode in the question +context so the resume can route correctly: + +```text +## Task +[super-tasker mode=executor task=ab12cd34] + +``` + +On resume, responsive-agent will replay the saved context. If the context +contains `[stask-exec:]`, treat the next session as executor for that +task; otherwise treat it as 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 + +- Daily / hourly lead tick (suggested): create a cron via + `tools/cron_tools/` that pings ductor with a message like + `[super-tasker:lead-pass] do a lead pass`. The bracketed token both + triggers activation and reminds the agent which mode. +- Manual: send `/super-tasker` (or natural language) in any Slack chat. +- Worker resume: handled automatically by responsive-agent — the saved + context carries the `[stask-exec:]` anchor. + +## 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/ductor_slack/_home_defaults/workspace/skills/super-tasker/scripts/spawn_executor.py b/ductor_slack/_home_defaults/workspace/skills/super-tasker/scripts/spawn_executor.py new file mode 100755 index 00000000..f13e0986 --- /dev/null +++ b/ductor_slack/_home_defaults/workspace/skills/super-tasker/scripts/spawn_executor.py @@ -0,0 +1,164 @@ +#!/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 + `[super-tasker mode=executor task={task_id}]` in the saved context so + the resumed session re-enters executor mode. + 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/ductor_slack/_home_defaults/workspace/skills/super-tasker/scripts/tasker.py b/ductor_slack/_home_defaults/workspace/skills/super-tasker/scripts/tasker.py new file mode 100755 index 00000000..8f2f14d3 --- /dev/null +++ b/ductor_slack/_home_defaults/workspace/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:])) From 40e3909e8362bdc776ba190add209049fc075528 Mon Sep 17 00:00:00 2001 From: "Ethan Tan (AfterShip)" Date: Tue, 12 May 2026 06:41:06 +0000 Subject: [PATCH 2/3] Bundle responsive-agent skill alongside super-tasker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit super-tasker depends on responsive-agent for pause/resume continuation (no in-loop budgets — long work is suspended and resumed via Slack). Ship them together so a fresh install gets a working super-tasker. Co-Authored-By: Claude Opus 4.7 --- .../skills/responsive-agent/SKILL.md | 147 ++++++++++++ .../responsive-agent/scripts/cleanup.py | 95 ++++++++ .../responsive-agent/scripts/pause_and_ask.py | 212 ++++++++++++++++++ .../skills/responsive-agent/scripts/resume.py | 62 +++++ 4 files changed, 516 insertions(+) create mode 100644 ductor_slack/_home_defaults/workspace/skills/responsive-agent/SKILL.md create mode 100755 ductor_slack/_home_defaults/workspace/skills/responsive-agent/scripts/cleanup.py create mode 100755 ductor_slack/_home_defaults/workspace/skills/responsive-agent/scripts/pause_and_ask.py create mode 100755 ductor_slack/_home_defaults/workspace/skills/responsive-agent/scripts/resume.py diff --git a/ductor_slack/_home_defaults/workspace/skills/responsive-agent/SKILL.md b/ductor_slack/_home_defaults/workspace/skills/responsive-agent/SKILL.md new file mode 100644 index 00000000..5e184b9f --- /dev/null +++ b/ductor_slack/_home_defaults/workspace/skills/responsive-agent/SKILL.md @@ -0,0 +1,147 @@ +--- +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. + +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/ductor_slack/_home_defaults/workspace/skills/responsive-agent/scripts/cleanup.py b/ductor_slack/_home_defaults/workspace/skills/responsive-agent/scripts/cleanup.py new file mode 100755 index 00000000..a8ce3a8a --- /dev/null +++ b/ductor_slack/_home_defaults/workspace/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/ductor_slack/_home_defaults/workspace/skills/responsive-agent/scripts/pause_and_ask.py b/ductor_slack/_home_defaults/workspace/skills/responsive-agent/scripts/pause_and_ask.py new file mode 100755 index 00000000..ff7622e4 --- /dev/null +++ b/ductor_slack/_home_defaults/workspace/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/ductor_slack/_home_defaults/workspace/skills/responsive-agent/scripts/resume.py b/ductor_slack/_home_defaults/workspace/skills/responsive-agent/scripts/resume.py new file mode 100755 index 00000000..8b452986 --- /dev/null +++ b/ductor_slack/_home_defaults/workspace/skills/responsive-agent/scripts/resume.py @@ -0,0 +1,62 @@ +#!/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})\]") + + +def find_ref_in_text(text: str) -> str | None: + m = ANCHOR_RE.search(text) + return m.group(1) if m else 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}") + + sys.stdout.write(f"=== resume context for ref {ref} ===\n") + sys.stdout.write(ctx_path.read_text(encoding="utf-8")) + 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()) From 57ebf1772319ab50b8ccecd3ba1d4b6366ea5708 Mon Sep 17 00:00:00 2001 From: "Ethan Tan (AfterShip)" Date: Tue, 12 May 2026 11:00:10 +0000 Subject: [PATCH 3/3] Move super-tasker + responsive-agent out of _home_defaults (opt-in) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both skills now live at repo-root skills// instead of ductor_slack/_home_defaults/workspace/skills/, so a fresh ductor install does not auto-pick them up via the skill-sync mechanism. Per Victor: the skill should not be installed by default — install is a deliberate copy into the live workspace. skills/README.md documents the manual install path. --- skills/README.md | 23 ++++ .../responsive-agent/SKILL.md | 5 + .../responsive-agent/scripts/cleanup.py | 0 .../responsive-agent/scripts/pause_and_ask.py | 0 .../responsive-agent/scripts/resume.py | 22 ++- .../skills => skills}/super-tasker/SKILL.md | 126 +++++++++++++----- .../super-tasker/scripts/spawn_executor.py | 7 +- .../super-tasker/scripts/tasker.py | 0 8 files changed, 146 insertions(+), 37 deletions(-) create mode 100644 skills/README.md rename {ductor_slack/_home_defaults/workspace/skills => skills}/responsive-agent/SKILL.md (95%) rename {ductor_slack/_home_defaults/workspace/skills => skills}/responsive-agent/scripts/cleanup.py (100%) rename {ductor_slack/_home_defaults/workspace/skills => skills}/responsive-agent/scripts/pause_and_ask.py (100%) rename {ductor_slack/_home_defaults/workspace/skills => skills}/responsive-agent/scripts/resume.py (70%) rename {ductor_slack/_home_defaults/workspace/skills => skills}/super-tasker/SKILL.md (64%) rename {ductor_slack/_home_defaults/workspace/skills => skills}/super-tasker/scripts/spawn_executor.py (96%) rename {ductor_slack/_home_defaults/workspace/skills => skills}/super-tasker/scripts/tasker.py (100%) 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/ductor_slack/_home_defaults/workspace/skills/responsive-agent/SKILL.md b/skills/responsive-agent/SKILL.md similarity index 95% rename from ductor_slack/_home_defaults/workspace/skills/responsive-agent/SKILL.md rename to skills/responsive-agent/SKILL.md index 5e184b9f..408c718f 100644 --- a/ductor_slack/_home_defaults/workspace/skills/responsive-agent/SKILL.md +++ b/skills/responsive-agent/SKILL.md @@ -98,6 +98,11 @@ When this skill triggers because the context contains `[ret-ref: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 diff --git a/ductor_slack/_home_defaults/workspace/skills/responsive-agent/scripts/cleanup.py b/skills/responsive-agent/scripts/cleanup.py similarity index 100% rename from ductor_slack/_home_defaults/workspace/skills/responsive-agent/scripts/cleanup.py rename to skills/responsive-agent/scripts/cleanup.py diff --git a/ductor_slack/_home_defaults/workspace/skills/responsive-agent/scripts/pause_and_ask.py b/skills/responsive-agent/scripts/pause_and_ask.py similarity index 100% rename from ductor_slack/_home_defaults/workspace/skills/responsive-agent/scripts/pause_and_ask.py rename to skills/responsive-agent/scripts/pause_and_ask.py diff --git a/ductor_slack/_home_defaults/workspace/skills/responsive-agent/scripts/resume.py b/skills/responsive-agent/scripts/resume.py similarity index 70% rename from ductor_slack/_home_defaults/workspace/skills/responsive-agent/scripts/resume.py rename to skills/responsive-agent/scripts/resume.py index 8b452986..c92bd874 100755 --- a/ductor_slack/_home_defaults/workspace/skills/responsive-agent/scripts/resume.py +++ b/skills/responsive-agent/scripts/resume.py @@ -19,6 +19,12 @@ 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: @@ -26,6 +32,14 @@ def find_ref_in_text(text: str) -> str | None: 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") @@ -49,8 +63,14 @@ def main() -> int: 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(ctx_path.read_text(encoding="utf-8")) + sys.stdout.write(body) if meta_path.exists(): sys.stdout.write("\n=== meta ===\n") sys.stdout.write(meta_path.read_text(encoding="utf-8")) diff --git a/ductor_slack/_home_defaults/workspace/skills/super-tasker/SKILL.md b/skills/super-tasker/SKILL.md similarity index 64% rename from ductor_slack/_home_defaults/workspace/skills/super-tasker/SKILL.md rename to skills/super-tasker/SKILL.md index 755dae4f..a12144dc 100644 --- a/ductor_slack/_home_defaults/workspace/skills/super-tasker/SKILL.md +++ b/skills/super-tasker/SKILL.md @@ -1,6 +1,6 @@ --- 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, scans the open tree and orchestrates work) and Executor (a single task is delegated to a worker). Activates on slash commands like `/super-tasker`, natural-language asks to "run the task lead", cron pings that include `[super-tasker:lead-pass]`, 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; the loop is driven by Slack events and cron ticks. +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 @@ -8,24 +8,51 @@ description: Multi-track task management for long-running agentic work. The skil Multi-track task orchestration that survives across agent sessions. Designed for the ductor / Slack environment: there is no continuous agent -loop. The "loop" is a sequence of independent sessions (cron ticks, Slack -replies, responsive-agent resumes). The skill keeps state on disk so each -session can pick up exactly where the previous one left off. +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). Scan the open task tree, decide what to do next, and - delegate work. Entered on `/super-tasker`, natural-language equivalents, - cron pings, or any time the skill is invoked without an executor anchor. -- **Executor**. Carry out one specific task. Entered only when the prompt - contains `[stask-exec:]`. The wrapper that spawned you put that - anchor there. +- **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/`. @@ -88,17 +115,33 @@ Open ──► WIP ──► Completed └─► Splitted / Merged / Dropped (allowed without entering WIP) ``` -`Splitted`, `Merged`, `Completed`, `Dropped` are terminal. Per Victor's -spec: a split task stays in `Splitted` forever and is never revived. +`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 -### Top-level definition +The **frontier** is the lead's working set: every non-terminal task whose +`split_from` parent is either absent or itself terminal. -A task is **top-level** iff: +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 only considers top-level tasks with `status in {Open, WIP}`. +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 @@ -110,9 +153,10 @@ attempts beyond the cap. Run on every activation that is not in executor mode. -1. `python3 scripts/tasker.py brief --json` — get the active-root summary - (Open + WIP top-level tasks, with their depth, complexity, last event, - blocked deps, and any pending reeval signals). +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. @@ -131,8 +175,10 @@ Run on every activation that is not in executor mode. - 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. The next lead pass is triggered - by the next user message, the cron tick, or the executor completing. + 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 @@ -208,18 +254,20 @@ 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, **always** include the task id and the mode in the question -context so the resume can route correctly: +When pausing from an executor, **always** include the `[stask-exec:]` +anchor in the saved context so the resume can route correctly: ```text ## Task -[super-tasker mode=executor task=ab12cd34] +[stask-exec:ab12cd34] ``` -On resume, responsive-agent will replay the saved context. If the context -contains `[stask-exec:]`, treat the next session as executor for that -task; otherwise treat it as a lead-pass continuation. +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 @@ -236,13 +284,25 @@ All scripts live under `scripts/` and are invoked via `python3`: ## Cron / activation patterns -- Daily / hourly lead tick (suggested): create a cron via - `tools/cron_tools/` that pings ductor with a message like - `[super-tasker:lead-pass] do a lead pass`. The bracketed token both - triggers activation and reminds the agent which mode. -- Manual: send `/super-tasker` (or natural language) in any Slack chat. -- Worker resume: handled automatically by responsive-agent — the saved - context carries the `[stask-exec:]` anchor. +- **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 diff --git a/ductor_slack/_home_defaults/workspace/skills/super-tasker/scripts/spawn_executor.py b/skills/super-tasker/scripts/spawn_executor.py similarity index 96% rename from ductor_slack/_home_defaults/workspace/skills/super-tasker/scripts/spawn_executor.py rename to skills/super-tasker/scripts/spawn_executor.py index f13e0986..02938bd3 100755 --- a/ductor_slack/_home_defaults/workspace/skills/super-tasker/scripts/spawn_executor.py +++ b/skills/super-tasker/scripts/spawn_executor.py @@ -63,9 +63,10 @@ 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 - `[super-tasker mode=executor task={task_id}]` in the saved context so - the resumed session re-enters executor mode. + 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. diff --git a/ductor_slack/_home_defaults/workspace/skills/super-tasker/scripts/tasker.py b/skills/super-tasker/scripts/tasker.py similarity index 100% rename from ductor_slack/_home_defaults/workspace/skills/super-tasker/scripts/tasker.py rename to skills/super-tasker/scripts/tasker.py