Run a squadron of AI coding agents in parallel — each one isolated, briefed, and tasked to ship.
A small toolkit that lets you run multiple AI coding agents at the same time — each one in its own Git worktree and Zellij tab, all coordinating through a shared task tracker (Jira, Notion, or GitHub Projects).
You stay in the cockpit. The agents fly the missions:
- PM grooms the backlog and creates tasks
- Planner reads the code and writes the spec into the tracker
- Worker picks up the spec, implements it on its own branch, runs tests, opens a PR
Pick which AI tool you fly with (Claude Code or Kiro) and which tracker you brief them through (Jira, Notion, or GitHub Projects). One combo, or all of them.
Inspired by How I run 4–8 parallel coding agents.
| Combo | AI Agent | Task Tracker | Folder |
|---|---|---|---|
| claude-jira | Claude Code | Jira (Atlassian MCP) | claude-jira/ |
| claude-notion | Claude Code | Notion (Notion MCP) | claude-notion/ |
| claude-gh | Claude Code | GitHub Projects (gh CLI) | claude-gh/ |
| claude-local | Claude Code | Local markdown files (Obsidian-style) | claude-local/ |
| kiro-notion | Kiro CLI | Notion (Notion MCP) | kiro-notion/ |
| kiro-gh | Kiro CLI | GitHub Projects (gh CLI) | kiro-gh/ |
| kiro-local | Kiro CLI | Local markdown files (Obsidian-style) | kiro-local/ |
All seven share the same shape — same roles, same task-work / task-done commands, same Zellij workflow. The only thing that changes is which AI flies the missions and where the briefings (or markdown task files) live.
You always need:
- Zellij (≥ 0.44) — the multiplexer that hosts each agent in its own tab
- gh CLI — opens pull requests
- Git
- Either Claude Code or Kiro CLI
- For Jira / Notion trackers, the matching MCP server; for GitHub Projects, just the
ghCLI authenticated (gh auth status) — see Per-combo setup
git clone https://github.com/martin-conur/agentic-workflow ~/agentic-workflow
cd ~/agentic-workflow
./install.sh./install.sh shows an interactive picker (uses fzf or gum if installed, falls back to a numbered menu). Or be explicit:
./install.sh claude-gh # install one combo
./install.sh all # install all sevenThe installer drops slash commands / agents into your AI tool's config and links task-work, task-done, task-init, task-board, task-config, task-remove, task-pm, radio, and ci-guard into ~/.local/bin.
In any Git repo you want to use this with:
cd ~/my-project
task-init # interactive picker
# or skip the picker:
task-init claude-ghThis writes a workflow config (e.g. .claude/gh-workflow.md or .kiro/steering/notion-workflow.md) so your agents know which board to read from. After that, task-work and task-done auto-detect the right combo from that file — see How the dispatchers work.
task-init is safe to re-run any time you want to pull in new template scaffolding or restore deleted files. Two orthogonal axes of control:
Scope — which categories of files to touch:
| Flag | Files touched |
|---|---|
| (none) | workflow doc + slash commands / agents + tasks/ for local |
--workflow |
workflow doc only (+ CLAUDE.md import for Claude loadouts) |
--commands |
slash commands / agents only |
Overwrite policy — what to do when a target file already exists:
| Flag | Existing file behavior |
|---|---|
| (TTY) | per-file prompt: [k]eep / [o]verwrite / [d]iff (default = keep) |
| (non-TTY) | silently keep (exit 0) — script-safe |
--force |
overwrite everything in scope, no prompt |
--restore |
fill missing files only; never touch anything that exists |
--force and --restore are mutually exclusive.
When the workflow doc is re-rendered, previously-filled {OWNER} / {REPO} / {PROJECT} / {SITE} / {KEY} / {BOARD} values are carried forward automatically. Precedence: this-run flag → existing-file value → {PLACEHOLDER}. So task-init claude-gh --force after you've already filled in your IDs does the right thing (refreshes the template, keeps your values).
The workflow doc is a managed region. It is the one installed file you are actively encouraged to edit — repo-specific guidance belongs in it — and also the one a documented upgrade re-renders. So task-init writes it with a boundary inside:
<!-- task-init:managed:start -->
…the rendered template — task-init owns this and rebuilds it on every re-run…
<!-- task-init:managed:end -->
## Repo-specific notes ← yours, foreverA re-run rebuilds only what is between the markers and copies everything outside them through verbatim. Anything you add below the end marker — what "Green" means in this repo, a pre-PR checklist, local conventions — survives --force. Because that refresh cannot touch your content, it no longer prompts about the workflow doc at all; --restore and the non-TTY default still leave the file completely alone.
A doc written before this existed has no markers. The first re-run adopts it: if it is byte-identical to the template it is simply re-wrapped, and if you have edited it the old file is copied to <doc>.bak first, with a note telling you to move what you want to keep below the end marker. Nothing is deleted either way.
Common use cases:
task-init claude-gh --restore # restore a deleted slash command, leave everything else alone
task-init claude-gh --commands --force # refresh slash commands to current templates
task-init claude-gh --workflow # pull in template updates, keep current scope's other filesOpen Zellij, start your AI tool, and brief the PM:
/pm show me the backlog
/pm create task for "add login button"
/planner plan the new task
task-work <task-url> # spawns a fresh tab with a worker on its own branch
When the worker is done it opens a PR. Run task-done in the worker's tab to clean up the worktree and close the tab.
┌─ Zellij Tab 1 (main branch, always open) ──────────────────────┐
│ │
│ /pm → grooms backlog, creates tasks in the tracker │
│ /planner → reads code, writes spec into the task │
│ task-work → spawns a new tab with a worker on a fresh branch │
│ │
└────────────────────────────────────────────────────────────────┘
│
│ task-work creates a Git worktree + new Zellij tab
▼
┌─ Zellij Tabs 2…N (one per task, isolated worktrees) ───────────┐
│ │
│ /worker → reads spec, implements, tests, commits │
│ task-done → opens PR, removes worktree, closes the tab │
│ │
└────────────────────────────────────────────────────────────────┘
Each worker has its own checkout of the repo, so 4–8 of them can fly in parallel without colliding.
task-init is a project-aware dispatcher that lives at the repo root: it detects the loadout and execs that loadout's own copy. After install, they're symlinked into ~/.local/bin and work the same regardless of which combo was installed last. (task-board, task-pm, radio, ci-guard, task-config, task-remove and task-recreate-worker are canonical single copies instead — see task-config for why task-config and task-remove in particular cannot be dispatchers.)
task-done and task-work are a third thing: they detect like a dispatcher and then compose instead of dispatching (#236, #237). There is one bin/task-done and one bin/task-work, and the loadout decides which modules they source — a tracker module out of lib/trackers/ and, for task-work, an agent module out of lib/agents/.
task-done composes a tracker only: its seven per-loadout copies were a three-variant file wearing seven hats (claude-gh, claude-notion, kiro-gh and kiro-notion byte-identical; the two locals byte-identical to each other; claude-jira seven lines off), and removing a worktree, sweeping radio state and closing a tab never depended on which agent was in the tab.
task-work composes both, because its 2124 lines of variation ran along two orthogonal axes rather than seven shapes. The tracker decides ref parsing, the slug a ref derives, the usage synopsis, the .info key (GH_URL / JIRA_REF / NOTION_URL / TASK_FILE), the worker-prompt payload and — on local only — the state.json + board bookkeeping. The agent decides the extra flags (--plan on claude; -m / -a on kiro), the preflight (kiro's #218 agent check), the launch line and the completion message. Everything else — worktree creation, the auto-refresh, the commit-msg guard, the radio env prefix, --auto's effect on radio auto-submit, the tab id capture — is the one shared body. The agent module gets first refusal on each command-line token, so its flags cannot be interleaved into the shared case; tests/task_work_agents.bats pins that no agent module claims a shared flag.
A loadout is therefore {tracker × agent}, and the modules are keyed on the axes rather than on the combo: four tracker files and two agent files, not seven of each. lib/trackers/_default.sh carries the behaviour most trackers share, so a module overrides only what differs — gh.sh and notion.sh override nothing for task-done, and for task-work the trackers supply two predicates (aw_tracker_is_ref, aw_tracker_ref_slug) that drive one shared positional-resolution chain.
When you run one of them inside a project, the dispatcher detects the impl by looking at which workflow doc is present:
| File present | Combo |
|---|---|
.claude/jira-workflow.md |
claude-jira |
.claude/notion-workflow.md |
claude-notion |
.claude/gh-workflow.md |
claude-gh |
.claude/local-workflow.md |
claude-local |
.kiro/steering/notion-workflow.md |
kiro-notion |
.kiro/steering/gh-workflow.md |
kiro-gh |
.kiro/steering/local-workflow.md |
kiro-local |
So you can have different combos in different projects and never have to think about it.
Overrides (in priority order):
--impl <name>flagAW_IMPL=<name>environment variable- Auto-detection from the workflow file
task-done is worktree-aware: when run from a task worktree (which has no workflow doc), it falls back to inspecting the main worktree so detection still works. task-board detects from --repo PATH when that flag is given, since task-work and task-done call it that way from a worktree.
task-board detects like a dispatcher, then either renders or refuses. Since #238 there is one copy, bin/task-board: the two per-loadout bodies it used to exec were byte-identical, so the render now lives in the root file below the loadout check. It renders tasks/_board.md out of local task-file frontmatter, which only the two local-tracking loadouts have — so on a gh / jira / notion repo it exits non-zero naming the detected loadout and where that repo's board actually lives:
$ task-board # in a claude-gh repo
Error: task-board is only available on the local-tracking loadouts
(claude-local, kiro-local); this repo uses 'claude-gh'.
It renders tasks/_board.md from local task-file frontmatter,
which a 'claude-gh' repo has none of — its board lives in GitHub Projects.
Every loadout links it anyway, on purpose: a command that explains itself beats one that is silently missing. Before #215 only the two *-local installers linked task-board, each straight at its own copy — so it was absent everywhere else, and installing both local loadouts left whichever ran last owning the symlink.
task-config is the one task-force command that is deliberately not a dispatcher. task-work and task-done compose (#236, #237) and task-board refuses off the local loadouts (#215), because all three act within one loadout and only need to resolve which; task-config acts across them, has to know about a loadout the repo is not on, and has to keep working when detection is ambiguous — which is exactly when you reach for it. So it is a canonical single copy, like task-pm and radio.
$ task-config show
loadout : claude-gh
assistant : claude
tracker : gh (owner=martin-conur repo=task-force project=1)
config : .claude/gh-workflow.md
owns : .claude/commands/pm.md
.claude/commands/planner.md
.claude/commands/worker.md
.claude/commands/reviewer.md
CLAUDE.md (the @.claude/gh-workflow.md import line)
.claude/settings.json (radio hooks + seeded allow-list)
show never refuses — it describes and signals. The states every other command treats as fatal are output here, because describing them is the whole point:
$ task-config show # a repo with no loadout
loadout : none
No loadout configured in /path/to/repo; run task-init to pick one:
task-init # interactive picker
task-init claude-gh # or name one: claude-jira claude-notion claude-gh …
$ task-config show # a repo with two
Ambiguous: 2 loadouts are configured in /path/to/repo.
Every task-force dispatcher refuses in this state.
… a full block for each match …
Pick one, then remove the other:
task-config set loadout <name> --impl <the-one-to-replace>
Both exit non-zero, so a script can still branch on it. Before this, the only way to find the second workflow doc behind Error: multiple agentic-workflow impls detected was to know which seven paths to stat.
task-config set assistant kiro # same tracker, swap claude → kiro
task-config set tracker local # same assistant, swap the tracker
task-config set loadout claude-notion # swap both at onceFive steps, every time: detect the current loadout, carry the tracker settings off its workflow doc, remove its artifacts, hand the settings to <new-loadout>/bin/task-init --force, report what moved. Step 4 is a delegation, not a reimplementation — task-init already knows how to render every template, preserve placeholders and merge hooks idempotently, and a second copy of that here would be a second thing to keep in step.
--dry-run prints the plan and changes nothing; otherwise a TTY run confirms first, and --yes skips the prompt. In an ambiguous repo, --impl <name> says which loadout to replace.
An --impl — or an ambient $AW_IMPL — naming a loadout that is not configured in this repo is refused by set rather than believed. aw_all_impls answers "is this a loadout name" and says nothing about what the repo has, so before #227 the pinned name was taken at face value and every value below it derived from that name instead of from reality: task-config set tracker notion --impl kiro-gh on a claude-gh repo removed kiro-gh's nonexistent artifacts and installed kiro-notion beside the untouched .claude/gh-workflow.md — two loadouts configured, which is the ambiguous state this command exists to prevent.
show is the deliberate exception, and does not refuse: it is the command you reach for when a repo is confusing, so a refusal would break the one contract that makes it useful. It annotates and signals instead, because describing is not the same as asserting:
$ task-config show --impl kiro-gh # in a claude-gh repo
loadout : kiro-gh (NOT configured here — pinned via --impl / $AW_IMPL)
assistant : kiro
tracker : gh (unset — fill in .kiro/steering/gh-workflow.md)
config : .kiro/steering/gh-workflow.md
$ echo $?
1
Without that first-line annotation the tracker line reads as "kiro-gh is configured, the values just aren't filled in" — an affirmative invitation to go edit a file that does not exist, in the one command whose whole job is telling you what is configured.
The non-zero exit is the third instance of the same describe-but-signal rule the no-loadout and ambiguous cases follow, and was the only one of the three exiting 0 — so task-config show --impl X && do_something proceeded on a phantom, which is the same success-signal-for-work-that-did-not-happen shape as the --impl bug the guard above exists for. The annotation and the exit status are driven by the same doc-existence test, so the line printed and the status returned cannot drift apart.
Settings carry across a set assistant, and cannot across a set tracker. claude and kiro render the same template shape per tracker, so owner / repo / project move over cleanly on a gh repo. Different trackers share no fields at all, so there is nothing to carry and task-init prompts for the new ones exactly as on a first install. That asymmetry is correct rather than a gap.
One real gap, on notion. Carry-over works by handing task-init the old values as flags, and the notion loadouts have no settings flags at all — only --force / --restore / --workflow / --commands / --hooks / --help-ids. So task-config show reads the Notion IDs out of a notion workflow doc and prints them, but a claude-notion ↔ kiro-notion switch cannot carry them, and you have to paste them into the new doc yourself. That is the worst tracker for it: the IDs are opaque strings that cannot be derived from a URL, which is why task-init claude-notion --help-ids exists. #225 adds the flags; once it lands, notion carry-over works through this same code path with no change to task-config. Run task-config show before switching a notion repo and keep the output, or copy the three bullets out of the old doc first.
kiro-jira is refused by name, because the assistant × tracker grid has exactly one hole (#92):
$ task-config set tracker jira # in a kiro-gh repo
Error: there is no 'kiro-jira' loadout.
The assistant × tracker grid has one hole: jira is not
available on kiro (#92). Everything else exists.
Removal is the inverse of task-init's idempotent merge (#183, #212): it takes out only task-init's own entries, and deletes a file only when nothing else was in it.
| Artifact | What removal does |
|---|---|
.claude/<t>-workflow.md / .kiro/steering/<t>-workflow.md |
deleted — it is the detection key, so leaving it is the multi-match state this exists to avoid. Repo-specific sections below the managed-region end marker (#183) are copied to <doc>.bak first, the way task-init's own adoption path does it |
.claude/commands/{pm,planner,worker,reviewer}.md |
deleted only where the file still matches the copy your loadout ships. One you have edited is kept — and keeps its directory. A command of your own beside them is never touched |
.kiro/agents/{pm,planner,worker,reviewer}.json |
the radio hooks are stripped first, then the file is deleted only if what remains matches the shipped agent config. A customized one keeps your edits and loses only our hooks |
.kiro/hooks/radio-*.json (inert, pre-#218) |
deleted, and only the ones recognisably radio's |
CLAUDE.md |
the @.claude/<t>-workflow.md line is stripped; the file survives with your own content, and is deleted only if that line was all it held |
.claude/settings.json |
the radio hook entries (matched as (.command // "") | startswith("radio "), the same test task-init's merge uses on itself) and the seeded allow-list literals are stripped; your own hooks, allow entries, deny, model and everything else stay |
tasks/ (local loadouts) |
the README.md / _board.md scaffolding goes; your backlog stays, and the run says how many files it kept |
.gitignore (local loadouts) |
only the .git/task-force/ line is removed |
.claude/, .kiro/, .claude/commands/, … |
reclaimed only once removal has actually emptied them |
The role files get the most care because they are the likeliest place a switch could destroy work. task-init installs them through install_file, whose keep / prompt policies let an existing file survive a re-run — and on kiro it then merges the radio hooks into whatever survived (#218, #222), so an agent config can be your work carrying our entries. So removal never deletes a role file it cannot prove is task-init's own, and errs toward keeping: a file that doesn't match the shipped copy — because you edited it, or because the checkout has moved on since you installed — is kept, with a line saying so rather than silently.
One accepted imprecision: the seeded allow-list is matched as a literal set, so a permission you independently wanted and task-init also seeds (Read, Bash(ls *)) goes away with a switch. --dry-run is the mitigation. A provenance manifest was weighed and rejected — task-config only ever removes one named loadout's artifacts, never "everything task-init ever did to this repo", so the set is derivable from the loadout name alone.
The removal engine lives in lib/loadout-artifacts.sh rather than inside task-config, because task-remove is its second consumer.
task-remove is that second consumer: the same removal walk with no install after it. Canonical rather than dispatching, for task-config's reasons.
task-remove --dry-run # the plan, changing nothing
task-remove # TTY: previews, then asks
task-remove --yes # no prompt
task-remove --purge --dry-run # what the override would additionally take
task-remove --impl kiro-gh --yes # in an ambiguous repo, just that oneTwo situations want it. The project is done and the workflow config is dead weight. Or — the one that actually stings — a PR against a repo that doesn't use task-force: you task-init'd someone else's repo to work in it, and the diff would otherwise carry .claude/commands/, a settings.json hook merge, a CLAUDE.md import line and a workflow doc the upstream maintainer never asked for. The old workaround for that (commit task-init's output as one commit, git revert it before opening the PR) is cheap and exact, and still works; it degrades once anything else has touched .claude/settings.json since that commit, or when the init was never isolated into a commit in the first place.
It removes exactly what a switch removes — same table, same keep-bias, same <doc>.bak — plus one artifact a switch has no business touching: the commit-msg ci-guard hook task-work installs (#194). Where the chain-install preserved a pre-existing hook as commit-msg.local, that name is put back. Only a hook carrying task-force's own marker is removed, so a commit-msg of yours — or husky's, via core.hooksPath — is named and left alone. Git shares one hooks directory across every worktree of a repo, so this is a repo-wide removal made from whichever worktree you are standing in, and the run says so.
With no --impl, every loadout detected in the repo is removed. "Take task-force out" means all of it, and leaving one behind is the multi-match state every dispatcher refuses on. An --impl / $AW_IMPL naming a loadout that is not configured here is refused: it would otherwise walk a loadout that isn't there, remove nothing, and still print task-force removed from <root> — and for the strip-before-a-PR case that success line is the only thing you would check before shipping every artifact you meant to take out (#227).
| Why | |
|---|---|
~/.local/bin/task-* symlinks, the shell-rc PATH line |
That is the global install, shared by every repo on this machine. Removing it because one project ended would break every other project. A --global flag is a separate ask; until then, rm the symlinks and the rc line by hand |
live worktrees, ~/.task-force radio state |
task-done --remove-worktree already owns worktree teardown and mailbox sweeping |
your CLAUDE.md, settings.json, .gitignore and tasks/ backlog |
Only task-init's own entries come out of them; a file is deleted only when nothing else was in it. --purge does not change this — see below |
The run prints that boundary every time, because "it didn't happen" is not something you can see in the output otherwise.
By default a role file that no longer matches the copy your loadout ships is kept and reported, because task-init's keep policy means it may be your work. That bias is right, and it is also imprecise in one direction worth naming: it fires on a merely stale install too. A repo set up from an older checkout has role files that no longer byte-match what the current checkout ships, so they are kept even though you never touched them. For a task-config set that is cosmetic residue. For task-remove it is a file in the diff your reviewer reads.
--purge deletes what the default would keep: a role file that differs from the shipped copy, the <doc>.bak (the one this run would have written, and any an earlier run left), and the tasks/ backlog — "the project is done" being a real reason to want that gone.
What it does not do is widen removal to files that are only partly ours. CLAUDE.md still loses only the import line, and a settings.json with your own hooks still keeps them. The override changes what counts as task-force's, not what counts as yours.
Run it without --purge first and read the list. That ordering is the whole safety argument — the default names every file it keeps, and the run points at --purge only when there is something it would actually take, so you act on a list you have seen rather than on the tool's guess. --purge --dry-run shows it before it happens.
A machine restart kills every role on the box at once. On 2026-09-28 that was ten clean unregister lines in one second across four repos — and not one line of work lost, because the work was never in the tab. The worktree, its branch, task-work's <worktree-base>/.<slug>.info sidecar, radio's <role>.loadout / <role>.agent (#188) and <role>.auto-submit (#246) sidecars and the role's mailbox all sit on disk and survive a reboot untouched. What dies with the zellij server is the tab, the radio session file, and $TASK_FORCE_ROLE (#229).
Nothing covered getting back in. task-work <slug> refuses, because the branch and worktree already exist — which is right, and #38 made it a hard stop on purpose. task-done is the opposite operation. So the recovery was a hand-rolled tab, a cd, a guess about resuming, and a session radio had no name for: the worker on #223 was rebuilt that way, finished the work, opened its PR, and could not deliver its report to PM.
task-recreate-worker issue-223 # fresh session; tab, role and mail rebound
task-recreate-worker issue-223 --auto # hands-off: auto-submit (and auto permission mode on claude)
task-recreate-worker issue-223 --auto-submit # auto-submit only; otherwise as recorded
task-recreate-worker add-auth --resume # resume the old session (id recovered, else picker)
task-recreate-worker issue-223 --force # a session still looks live; do it anywayIt opens a fresh tab on the existing worktree and launches the agent with the same role env task-work injects. That last part is the whole point: SessionStart registers the role, so the worker is wakeable again and its report goes out as itself instead of from: unknown. Before it launches, it reports what it found — the task reference (read under the tracker's own sidecar key, GH_URL / NOTION_URL / JIRA_REF / TASK_FILE, and relaunched with it), the base branch, unpushed commits, whether a PR already exists, and how much mail queued while the role was offline (plus, separately, anything gc already moved to dead-letter/, which a register will not redeliver).
It never creates, reuses or removes a worktree. If the worktree is gone, that is new work and it says so, naming task-work. The two commands must not overlap: silently recreating the tree would hand the rebuilt worker an empty checkout and lose whatever the branch was carrying.
Fresh is the default; --resume is the opt-in. A fresh start is the safer one — its register-time backlog drain (#168) puts the queued handoff in front of the agent on turn one, and its role env is right from the first turn. --resume is for uncommitted exploration worth keeping, and it is claude-only. It recovers the session id from the SessionEnd payload radio logged on the last unregister, pinned to this role and this worktree's own cwd and a transcript file that still exists — and when any of those three does not hold it hands over to Claude's picker and says which happened. It is a hint, not a mechanism: the radio log self-rotates past ~1MB, and a hard kill fires no SessionEnd at all. Resuming the wrong session silently would be far worse than a picker.
It refuses when the role still looks live — a zellij tab of that name is open, or the session file's heartbeat is newer than an hour, which it asks radio orphans rather than re-deriving. The first hour after a reboot looks exactly like a live session, so that refusal has a way through: read it (it names which signal fired), then --force.
The stale TAB_ID is the trap worth knowing about. zellij's server restarted, so the id still sitting in the sidecar is either absent or now belongs to an unrelated tab, and since #218 radio register recovers TAB_ID from that file when its own name lookup misses — so a naive re-register adopts it. This strips it before the tab is spawned and writes the new one after: a register that beats us to the write then finds nothing to adopt and resolves the new tab by name, which by then is the right one.
Pick the section that matches what you installed.
Need: Claude Code with the Atlassian Remote MCP added (verify with claude mcp list).
./install.sh claude-jira
cd ~/my-project
task-init claude-jira --site https://acme.atlassian.net --key PROJ --board "My Board"This writes .claude/jira-workflow.md and references it from CLAUDE.md so every session loads it. See Re-running task-init for the --force / --restore / --workflow / --commands flags.
| Role | How to invoke |
|---|---|
/pm |
typed in Claude Code |
/planner |
typed in Claude Code |
/worker |
auto-launched by task-work PROJ-123 |
Need: Claude Code with the Notion MCP added:
claude mcp add --transport http notion https://mcp.notion.com/mcp./install.sh claude-notion
cd ~/my-project
task-init claude-notion
# Edit .claude/notion-workflow.md and drop in your Notion database IDsSpawn workers with task-work <notion-url>. Same /pm, /planner, /worker roles as claude-jira.
Need: the gh CLI authenticated (gh auth status; gh auth login if not — needs repo + project scopes).
./install.sh claude-gh
cd ~/my-project
task-init claude-gh # auto-detects owner/repo from your git remotetask-init claude-gh seeds a read-only gh allow-list into .claude/settings.json so the PM / planner / worker can read freely:
gh issue view */gh issue list */gh issue comment *gh project view */gh project item-list */gh project field-list */gh project list *gh search issues *gh pr view */gh pr diff */gh pr list *gh label list */gh repo view */gh auth status
Mutations (gh issue edit, gh pr merge, gh project item-edit, …) are deliberately excluded and stay confirmation-gated.
Spawn workers with task-work <github-issue-url>. The issue number becomes the worktree slug (issue-42).
Optional add-on: if you frequently mutate Projects v2 iteration / number fields, add the GitHub MCP:
claude mcp add --transport stdio github -- npx -y @github/github-mcp-server(requires GITHUB_PERSONAL_ACCESS_TOKEN in your environment)
Need: nothing extra — no MCP, no remote tracker. Tasks live as files committed inside the repo.
./install.sh claude-local
cd ~/my-project
task-init claude-local # creates tasks/, .claude/local-workflow.md, and slash commandstask-init writes .claude/local-workflow.md and references it from CLAUDE.md, plus drops a tasks/ directory. task-board is a single root copy installed by every loadout — on this one it renders the board, where on the tracker-backed loadouts it refuses (#215, #238).
What "local tracking" means — there is no Jira, Notion, or GitHub board. The markdown files in tasks/ are the database, and tasks/_board.md is an auto-generated kanban view. Everything renders cleanly in Obsidian, so you can plan and read tasks from your editor of choice.
Layout under <repo>/tasks/:
- One
NNN-slug.mdper task (e.g.001-add-login-flow.md).NNNis the zero-padded id the PM allocates;slugis a kebab-case short title. - Each file opens with YAML frontmatter (
id,title,status,priority,tags,created,branch,pr) followed by## Problem,## Solution,## Files to Create/Modify,## Verification. tasks/_board.mdis the auto-generated board — never hand-edit. It has three columns: Todo / In Progress / Done.
Lifecycle — todo → in-progress → done. The worker bumps status in the task file's frontmatter on its first commit (in-progress) and again before opening the PR (done). The PM is the only role that creates new task files and allocates ids.
task-board — regenerates tasks/_board.md from tasks/*.md frontmatter, overlaying live worktree state. It runs automatically after task-work and task-done (and whenever the PM mutates a task), but you can also run it manually to refresh the view:
task-board # uses $(git rev-parse --show-toplevel)
task-board --repo ~/other # explicit repoLive state — .git/task-force/state.json is a gitignored, per-clone sidecar that tracks which worktrees are currently active. task-work writes a row; task-done removes it. Frontmatter is the durable, committed state; the sidecar is the live overlay. If a task appears in the sidecar, the board forces it into the In Progress column regardless of frontmatter.
Spawn workers with task-work tasks/NNN-slug.md. The slug (filename minus the NNN- prefix and .md suffix) becomes the worktree name — 001-add-login-flow.md → worktree add-login-flow on branch task/add-login-flow.
| Role | How to invoke |
|---|---|
/pm |
typed in Claude Code |
/planner |
typed in Claude Code |
/worker |
auto-launched by task-work tasks/NNN-slug.md |
Need: Kiro CLI with Notion MCP configured.
./install.sh kiro-notion
cd ~/my-project
task-init kiro-notion
# Edit .kiro/steering/notion-workflow.md with your Notion database IDsAgents are bound to Kiro shortcuts:
| Agent | Shortcut |
|---|---|
pm |
ctrl+shift+p |
planner |
ctrl+shift+l |
worker |
ctrl+shift+w |
Need: the gh CLI authenticated (gh auth status; gh auth login if not — needs repo + project scopes). The bundled Kiro agents shell out to gh via execute_bash.
./install.sh kiro-gh
cd ~/my-project
task-init kiro-ghSame Kiro shortcuts as kiro-notion.
Optional add-on: if you frequently mutate Projects v2 iteration / number fields, add the GitHub MCP to each agent's mcpServers block in .kiro/agents/*.json and set GITHUB_PERSONAL_ACCESS_TOKEN in your environment.
Need: nothing extra — no MCP, no remote tracker. Same model as claude-local, just driven by Kiro instead of Claude Code.
./install.sh kiro-local
cd ~/my-project
task-init kiro-local # creates tasks/, .kiro/steering/local-workflow.md, and agentsTasks live in <repo>/tasks/*.md with the same NNN-slug filenames, frontmatter schema (id, title, status, priority, tags, created, branch, pr), and four-section body (## Problem, ## Solution, ## Files to Create/Modify, ## Verification) as claude-local. tasks/_board.md is auto-generated by task-board.
Lifecycle — todo → in-progress → done, with the worker mutating frontmatter on first commit and again before the PR. The PM allocates ids and creates new task files. The board script triggers automatically from task-work / task-done / PM mutations, and can be run by hand:
task-boardLive state — .git/task-force/state.json is the gitignored, per-clone sidecar that overlays live worktree state on top of the committed frontmatter. Same model as claude-local.
Spawn workers with task-work tasks/NNN-slug.md. Same Kiro shortcuts as kiro-notion.
Once you've installed a Claude loadout, task-init auto-installs radio — a low-latency mailbox CLI under ~/.task-force/radio/ that lets the PM agent and worker agents ping each other directly. No human courier, seconds-level wake-up when the recipient tab is idle, queue-and-defer when busy. The kiro-* loadouts merge the corresponding hooks into each .kiro/agents/*.json instead — with weaker delivery guarantees; see kiro delivery.
Radio is the canonical coordination channel between the planner, PM, and workers — every role transition in the workflow runs through it. The PM / planner / worker prompts shell out to radio send at every documented handoff point.
These are the five transitions that make up a full PR cycle. Each one is a single radio send call, baked into the corresponding agent's prompt:
| From | When | Command |
|---|---|---|
| Planner | spec written into the issue | radio send --to pm --intent spec-ready --issue <N> |
| Worker | PR opened | radio send --to pm --intent review-requested --pr <N> |
| Worker | new commits pushed after review | radio send --to pm --intent re-review-requested --pr <N> |
| PM | review requested changes | radio send --to <worker-role> --intent changes-requested --pr <N> |
| PM | PR merged | radio send --to <worker-role> --intent approved-and-merged --pr <N> |
PR review content still lives in gh pr comment / gh pr review (or the equivalent on Jira / Notion / local); radio only carries the routing ping.
Agent-authored prose goes in on stdin, never through a double-quoted shell string (#234). Inside "…" the shell command-substitutes every backticked token, so a review that quotes pwd -P posts that command's output where the code should be, and a backticked identifier naming no command — $WORKTREE_DIR, _helper() — posts as nothing at all: the finding's subject is deleted and the sentence reads as if a word were missing, with nothing to signal that anything was removed. It is also arbitrary execution driven by prose, in a worktree, and review prose routinely quotes rm, git reset and gh pr merge while discussing a diff. So every prompt that authors prose — reviewer, PM, planner, worker — feeds gh its body with --body-file -, radio send its body on stdin, and git commit its message with -F -, all from a quote-delimited heredoc (<<'BODY' … BODY, never <<BODY):
gh pr comment 42 --body-file - <<'REVIEW'
## Spec compliance
… `backticks`, $vars, $(subshells) and don't-style apostrophes all reach GitHub verbatim …
REVIEWSingle-quoting -b '…' is not the fix — prose contains apostrophes, and the first one ends the string. The reviewer's PR comment is the failure that named this: the reviewer self-destructs by design, so its comment is the only surviving artifact and a mangled one is an unrecoverable loss of the analysis.
Role names are addressable strings, not free-form: the PM is pm-<reponame> (per-repo since #165, so two repos' PMs never collide), and each worker is worker-<reponame>-<slug> (e.g. worker-task-force-issue-42). List live ones with ls ~/.task-force/radio/sessions/. Workers always send --to pm; radio resolves it to the right pm-<reponame> using the injected $TASK_FORCE_PM_ROLE (or the sender's own identity). One PM can oversee several repos with task-pm --also <repo>, which aliases pm-<other> into its single inbox.
All of radio's state (sessions, mailboxes, the log) lives under $TASK_FORCE_HOME, which defaults to ~/.task-force. Point it at a scratch directory to probe or test without touching live roles. A new zellij tab starts from the zellij server's environment, not the shell that ran the launcher, so task-work, task-reviewer (claude and kiro) and task-recreate-worker put a set TASK_FORCE_HOME on the tab's command (aw_radio_home_env_prefix in lib/zellij-tab.sh). Before #262 they did not: the launcher logged to the scratch home while the agent it started registered in the real one, and a probe whose role name matched a live worker would have overwritten that worker's session file. task-pm execs in place and inherits the variable. When it is unset, nothing is added and the default applies.
| Command | What it does |
|---|---|
radio send --to <role> --intent <kind> [--pr N] [--issue N] [--body TEXT] |
Send a message (e.g. --to pm --intent review-requested --pr 42); body can come from stdin |
radio check |
List unread messages addressed to this role. In a session with no $TASK_FORCE_ROLE (a resume — see Roleless sessions) it derives the role from disk, or fails loudly; it never exits 0 in silence while mail waits (#229) |
radio read <id> |
Print one message AND mark it acknowledged (moves inbox/ → processed/) |
radio read --peek <id> |
Print without acknowledging — for inspection / debugging |
radio ack <id> |
Mark it acknowledged (idempotent — no-op if already processed by a prior read) |
radio register / radio unregister [--manual] |
Add/remove this tab's session file (~/.task-force/radio/sessions/<role>.info). unregister defaults to not removing anything, whatever its stdin looks like: it wipes only when a piped SessionEnd payload names a real-exit reason (logout / prompt_input_exit / other), and logs a skip for clear / resume, an unparseable payload, an empty one, or any payload at all on a host without jq (where no reason can be read, so none can be trusted). A terminal on stdin does not authorize a wipe (it did until #198) — so bare radio unregister typed by hand is a no-op that prints refusing to wipe <role> … re-run with --manual on stderr and explains itself, rather than silently tearing down the session. --manual (alias --force) is the explicit opt-in for deliberate cleanup — it bypasses the stdin inspection entirely, works from any stdin shape, and is what task-done passes. A wipe removes the .info (and any --also aliases pointing at it); the .loadout / .agent sidecars beside it deliberately survive — see Session state and self-heal. register is gated on the resolved --role rather than on $TASK_FORCE_ROLE, so it is the way out of a roleless session (#229) — run it from the tab that role owns |
radio ready / radio busy |
Toggle this session's STATE field — drives the wake-up vs. queue decision on the sender side |
radio stop-hook |
Stop-hook entrypoint: empty inbox → mark idle; unread messages → mark busy and emit Stop-hook block JSON so the agent continues and drains them. On the forced continuation it re-blocks only for ids that weren't in the block it caused |
radio prompt-hook |
UserPromptSubmit-hook entrypoint: mark busy; if the inbox has unread messages, print a one-line summary that Claude Code injects into the model's context |
radio orphans |
List session files whose heartbeat is >1h stale |
radio gc [--dry-run] [--max-age-days N] |
Prune the radio home: archive a dead role's still-unread mail to dead-letter/<role>/, drop that role's mailbox (no session file or a >1h-stale heartbeat, + newest entry older than N days, default 14) along with its .loadout / .agent sidecars, expire old processed/ messages, and rotate an oversized log. Runs automatically (quietly) on a fresh register, so it usually needs no manual invocation; --dry-run reports without touching anything |
radio send reads the recipient's session file. If STATE=idle, it resolves the recipient's tab/pane id via zellij action list-tabs --json / list-panes --json --tab and writes radio check\n straight into that pane with zellij action write-chars --pane-id — no focus switch, so the sender's tab stays put. A persisted TAB_ID is only meaningful within one zellij server lifetime, so before driving a wake by id, send verifies the tab still at that id still bears the recipient's own tab name (one list-tabs call, emoji prefix stripped from both sides). A .info that outlived a zellij restart carries a TAB_ID that now addresses an unrelated tab — the name won't match, so send re-resolves by tab name and repairs the file on a hit, or queues on a miss, never blindly writing into whatever tab inherited the id. ZELLIJ_SESSION= (the server name at register time) is a diagnostic and drives one extra guard: if it names a different but still-live server (zellij list-sessions), the real owner is unreachable from here, so send queues rather than misdeliver into this server's same-named look-alike tab. If STATE=busy, the message is queued with no wake attempt — no interrupting the recipient mid-turn. Delivery then happens at the end of the recipient's current turn: its Stop hook (radio stop-hook) sees the non-empty inbox and emits Stop-hook block JSON, which makes Claude Code continue the agent so it drains the queue immediately (radio check, then radio read each message). A stop_hook_active payload — this Stop was itself caused by the previous block — only re-blocks when the inbox has grown since: the hook records the ids each block was about in BLOCKED_IDS= and compares the unread set against them, so a message that lands mid-drain earns its own continuation while the same ids ignored twice still stop (#197).
The wake-up's last byte decides whether the agent acts on it. write-chars types into the recipient's TUI input buffer, so terminating with LF leaves radio check sitting in the prompt box until a human presses Enter, while CR is the Enter. The recipient picks: a session file carrying AUTO_SUBMIT=1 gets CR, everything else gets LF. task-work and task-reviewer export TASK_FORCE_AUTO_SUBMIT=1 by default for their radio register to persist (#254) — nobody types in a worker's or a reviewer's tab, so an LF wake there is a radio check nobody will ever submit. task-pm is the other way round: off by default since #254, because the PM's box is the one the user types in and a CR wake submits whatever is half-typed there; task-pm --auto-submit opts in for a PM left to run unattended (#189 had it on by default). --no-auto-submit restores LF on any launcher — use it for a worker you mean to step into and steer by hand, since the human gate is what stops an incoming wake from submitting a half-typed prompt. The choice is read off the recipient's session file, after the --also alias hop, so aliases inherit their primary PM's setting.
Auto-submit survives a hand repair (#246). register also records the setting in a <role>.auto-submit sidecar beside <role>.loadout / <role>.agent, and treats the env as tri-state: 1 is on, any other non-empty value (launchers say 0) is an explicit off, and unset means "restore what this role had". Every launcher states its choice, so inheritance only ever applies to a session nobody launched with an opinion — which is exactly the documented #229 repair, radio register --role … --tab … typed from a shell that never had the launch env. Before this, that repair brought a role back without AUTO_SUBMIT, silently costing a keypress per wake for the rest of its life. gc sweeps the sidecar with its siblings.
radio send tells you which of those happened, on stdout (always exit 0 — queuing is legitimate; only usage errors exit 2). The sending agent reads the line and acts on it instead of assuming delivery:
| Outcome line | Meaning |
|---|---|
delivered — woke <role> (tab_id=N) |
The recipient was idle and reachable; radio check was written to its pane. |
queued — <role> is busy; it will drain on its next Stop |
Recipient mid-turn; the stop-hook flush picks it up. (awaiting recipients get their own line — drain is via prompt-hook on the next prompt.) |
queued — <role> is idle but wake failed (<reason>); …surface on its next prompt/register |
Idle but unreachable (no zellij / no tab / not zellij-registered / stale or unreachable tab / no pane / write failed); no auto-redelivery until the recipient is next prompted or re-registers. |
queued — <role> …; it polls its own inbox — the message surfaces when it next runs radio check |
The recipient is a kiro agent. It has no Stop drain and no prompt-hook inbox summary (see kiro delivery), so every queued outcome — busy, awaiting, wake-failed — ends in this clause instead of naming a hook it doesn't have. |
WARNING — no session for <role>; …nobody is listening |
No session file — the role isn't running. Also WARNING — <role> looks dead … when a non-idle session's heartbeat is >1h stale (see radio orphans). |
Because radio send now writes to stdout, anything capturing its output must discard it (radio send … >/dev/null).
task-init claude-* writes these into your project's .claude/settings.json automatically:
| Hook | Command | Why |
|---|---|---|
SessionStart |
radio register |
Claims the role's session file — or no-ops, logging one line, when the session has no role to claim (it fires on startup / resume / compact / clear, but a resumed process carries no identity env: Roleless sessions) — and on a fresh start injects a summary of any inbox that queued while the role was offline (its stdout is injected into context, like UserPromptSubmit; claude loadouts only). A fresh pm-<repo> register also adopts any orphaned literal-pm backlog (write-only post-#165) into its own inbox, provenance-stamped, and surfaces it in that summary — only the messages whose from: names this repo, so another repo's backlog is left for its own PM (#210) |
UserPromptSubmit |
radio prompt-hook |
Marks the session busy — and surfaces any unread inbox into the model's context (its stdout is injected, unlike Stop's) |
Stop |
radio stop-hook |
Marks idle — or blocks the stop so the agent drains queued messages first |
PostToolUse |
radio busy |
State flip only — deliberately NOT prompt-hook; it fires after every tool call, and the inbox summary belongs at prompt time, not sprayed mid-turn |
For the kiro loadouts the equivalent wiring is merged into the hooks field of each .kiro/agents/*.json and runs off kiro-cli's triggers — but it is not equivalent in effect. See below.
Until #218 this section described something stricter, and for the wrong reason. The
three kiro hooks were written to <repo>/.kiro/hooks/*.json — the Kiro IDE's
agent-hooks directory, not a path kiro-cli reads — so none of them had ever run.
No kiro role registered, no state ever flipped, and every radio send to one
degraded to no session … message queued. What looked like a weak delivery
guarantee was no wiring at all.
They now live in the hooks field of each .kiro/agents/*.json, which is where
kiro-cli actually looks:
| kiro hook | Command | What happens |
|---|---|---|
agentSpawn |
radio register … |
Claims the session file — and its #168 offline-backlog summary does reach the model: kiro injects hook stdout. |
userPromptSubmit |
radio busy |
State flip only. Not prompt-hook — that is a deliberate hold, not a limitation (#221). |
stop |
radio ready |
State flip back to idle, once per turn. No block-and-drain: whether kiro honours Stop-hook block JSON is a separate contract from stdout injection and is untested (#221). |
| — | (no session-end trigger) | Unchanged: nothing unregisters on tab close except task-done. radio orphans is the cleanup (#98). |
kiro does inject hook stdout into the model's context. This README asserted the
opposite for months, citing an issue that was about something else entirely. The
claim came from never observing injection — which had the same single cause as
everything above. Verified on kiro-cli 2.24.0 with a nonce control: a
userPromptSubmit hook echoed a random value never present in the prompt, and the
model reproduced it exactly.
So kiro has one of claude's three context-injecting backstops today — the register report — and the other two are open questions rather than dead ends:
- Kiro agents still pull. Every prompt in
kiro-*/agents/*.jsoncarries a standing instruction to runradio checkat the top of each turn. With no prompt-hook summary and no Stop drain, that poll is still what closes the gap for an idle role, and it is still the honest primary mechanism. - The zellij wake works. It always did — the logs show
send: woke … via tab_id=Nfor kiro roles on the rare occasions a session file existed. Registration, not the wake, was the defect. - Kiro workers auto-submit by default, so the push path can finish. A plain
task-workopts the worker into the CR (auto-submit) wake-up (#254; before that it took--autoor--auto-submit, #206), so a wake that lands is acted on instead of sitting in the prompt box for a human Enter. It does not touch kiro's permission model — that is still-a/--trust-all.
Re-deriving what kiro can now support — prompt-hook, the Stop drain, and whether #190's "declare kiro best-effort" decision still holds — is tracked in #221. A heartbeat-driven unregister for the missing session-end trigger is #127.
Upgrading: this wiring is installer-written. An existing kiro project must re-run
task-init kiro-<tracker>to get it; nothing self-applies. The re-run also removes the inert.kiro/hooks/radio-*.jsonfiles, leaving any hook of your own in that directory alone.
A queued message arriving at an idle worker won't kick it into motion on its own — the worker only sees the message on its next turn (a human keystroke or its own next prompt). When that turn comes, the UserPromptSubmit hook (radio prompt-hook) injects a summary of the pending inbox into the model's context, so the backlog surfaces even if every send-time wake attempt failed. This is deliberate for workers: radio is notification + queue, not auto-action. If you want fully autonomous handoffs, dispatch the worker with task-work --auto and bake all the instructions into the issue body — that also opts the worker into the CR (auto-submit) wake-up, so a live ping drains without a keystroke. A PM launched with task-pm has that opt-in on by default (#189).
On kiro the userPromptSubmit hook is a plain radio busy with no inbox summary, so the worker's own radio check at the top of its next turn is what surfaces the backlog — same "next turn" latency, one less safety net. Its agentSpawn register report does land (kiro injects hook stdout), so a backlog that accumulated while the role was offline surfaces on the next fresh start. task-work --auto works there too (#206), buying the auto-submit.
~/.task-force/radio/sessions/<role>.info is a soft cache, not a lock. Hooks wipe it more often than anyone intends — that is the whole reason busy / ready re-seed a missing one instead of failing — so the durable identity lives in two ~10-byte sidecars next to it:
| File | Holds | Why it can't live in .info |
|---|---|---|
<role>.loadout |
claude-gh, kiro-local, … |
A re-seed rebuilds the .info it is replacing, so it can't read the old LOADOUT= line — and $TASK_FORCE_LOADOUT isn't in a hook subshell's environment |
<role>.agent |
claude / kiro |
Same story for $TASK_FORCE_AGENT: without the sidecar every re-seed writes AGENT=claude, silently mislabelling kiro workers |
The sidecars survive unregister — hook-invoked or --manual. They exist precisely to outlive the session file, and deleting them alongside it defeated the point: 78 of 79 observed re-seeds wrote LOADOUT=unknown because the sidecar was already gone by the time they ran (#188). A genuine register overwrites them, and radio gc reclaims them once the role has no session file at all, so nothing accumulates.
The Stop hook's BLOCKED_IDS= field is the mirror image, and lives in the .info because that file is disposable. It records the message ids a Stop-hook block was about so the next Stop can tell a new arrival from the same unread message (#197) — state whose only correct lifetime is this session's. A sidecar would outlive unregister like the two above and could suppress a legitimate block for a later, unrelated session of the same role; wiped with the .info, it degrades to "no recorded set", which simply falls back to the old flag-only behavior.
TAB_ID gets the same treatment from the other direction. A re-seed resolves it by asking zellij for the tab id behind $ZELLIJ_TAB; when that lookup misses (no zellij, no jq, a repainted tab), it falls back to the TAB_ID= line task-work / task-reviewer wrote into their own <worktree-base>/.<slug>.info at tab creation — a file radio never writes, so it stays authoritative however many times the session file is wiped. This matters because an empty TAB_ID is not a small degradation: radio send reports the recipient as "not zellij-registered" and queues with no wake attempt, for the rest of that role's life. A re-seed now writes an empty TAB_ID only when there is genuinely no binding anywhere (non-zellij / CI paths), and logs that case on its own line:
ensure_session: re-seeded worker-foo … tab_id=41 tab_id_src=info-file loadout=claude-gh agent=claude
ensure_session: no tab binding for worker-foo (zellij lookup miss, no TAB_ID in $INFO_FILE) — TAB_ID left empty; sends to it will queue with no wake
The role name itself gets the same from-disk recovery as TAB_ID, for the same
reason — and the reason is not a hook that failed to fire. SessionStart fires on
all four sources (startup, resume, compact, clear; verified with a canary
hook). But a resume is a new process, and the identity env is a command prefix
on the one task-work launched — bash -ic "TASK_FORCE_ROLE=… ZELLIJ_TAB=… claude …" — so it lives in no shell and nothing can hand it back. /compact and
/clear keep the same process and really do re-register; a resume runs the hook
with an empty environment, and --role $TASK_FORCE_ROLE (unquoted, in the hook
command) collapses to nothing.
Before #229 every beat keyed off that variable then degraded silently: send
wrote from: unknown into the machine-wide literal-pm inbox that #210 makes
adoptable first-come, check exited 0 with no output while mail sat in the inbox,
the role was unwakeable, and register — the documented repair — no-opped,
because the dispatcher gated it on the very variable it exists to restore, before
ever reading --role. All four left no log line, so "the hook never fired" and
"the hook fired and no-opped" were indistinguishable.
Now:
| Invocation | With no $TASK_FORCE_ROLE |
|---|---|
check / read / ack / send |
Derive the role from disk, announce it on stderr, and refuse loudly if it can't be derived. In a worktree, <worktree-base>/.<slug>.info + the main repo name rebuild worker-<reponame>-<slug> (or reviewer-<reponame>-pr<N>, tab slug review-pr<N>); in a main checkout the repo name gives pm-<reponame>, but only if that role already has radio state on disk — otherwise a plain claude in the repo root would claim the PM's address. No candidate is claimed while its session file has a fresh heartbeat: only a process holding the role env can write one, so that means someone else is it. |
busy / ready / awaiting / stop-hook / prompt-hook / unregister |
Unchanged silent no-ops (#93). Recovery is for commands typed on purpose; these fire automatically in every plain claude session and must stay quiet. |
register --role <role> … |
Runs. This is the repair, and it is why it had to be fixed first. It reports on stderr whether the role came back wakeable, and warns explicitly when the tab lookup missed and left TAB_ID empty — a state strictly worse than no session, because senders then get a cheerful queued instead of WARNING — no session. Run it from the tab that role owns. |
Recovery deliberately stops at identity: it does not write a session file, because
a register whose tab lookup misses is the empty-TAB_ID trap above. That lookup is
by tab name within the zellij session, so it can resolve from another tab — the
role's own tab is simply the one place it is guaranteed to. grep 'recover-role:' ~/.task-force/radio/log shows every recovery and refusal.
That repair is for a resumed session, whose tab ids are still the live
server's. After a zellij restart, reach for task-recreate-worker
instead: the restart renumbered every tab, and the <worktree-base>/.<slug>.info
sidecar task-done closes the tab by still names the old id — which, by teardown
time, can be another worker's tab. task-recreate-worker strips and rewrites that
id (#218). Two backstops since #260: a hand register that resolves the tab live
from zellij also replaces a stale sidecar TAB_ID, and task-done closes a tab
only when the tab at that id still bears its slug (⏸️ <slug> / ▶️ <slug>
included). On a mismatch it leaves the tab open and logs
tab-close: id N is now <name>, not <slug>; on 2026-10-18 the same mismatch
closed a freshly launched worker's tab instead.
If a tab dies unexpectedly (or Claude resumes — the hook fires, but a resumed process has no role to register: Roleless sessions), the session file's LAST_HEARTBEAT will go stale. Run radio orphans to list any session older than an hour. Safe to rm ~/.task-force/radio/sessions/<role>.info or just leave it — the next legitimate radio register overwrites it.
On kiro this is the routine cleanup step, not just the crash path: agentStop fires per turn, not on session close, so there is no kiro analogue of claude's SessionEnd → radio unregister. task-done covers the worker happy path (it unregisters before removing the worktree), but any kiro tab closed without it leaves a session file still advertising STATE=idle — which makes radio send try to wake a tab that's gone. Run radio orphans periodically and delete what it lists. Heartbeat-driven auto-unregister is #127.
Every radio defect found so far has been a notification failure, not a delivery
failure: a session file wiped out from under a live role, a wake-up that typed text
nobody submitted, a stop-hook that let an unread message sit. In none of them was the
message lost — the inbox had it the whole time; what broke was the signal that something
needed attention. So when radio looks broken, start at the log
(~/.task-force/radio/log), not at the mailbox.
| Symptom | Likely cause | What to check |
|---|---|---|
| "I pinged the worker and nothing happened." | The send never had a live, wakeable recipient — no session file, a stale one, or an empty/unresolvable TAB_ID. |
1. Re-read the sender's own outcome line (see the outcome table) — only delivered means a keystroke landed; every other line names its own reason. 2. ls ~/.task-force/radio/sessions/ — is the role there at all, spelled exactly? 3. radio orphans — a >1h-stale heartbeat means the tab is gone. 4. grep 'to=<role>' ~/.task-force/radio/log | tail and read the send: line right after each send id=. 5. In the recipient's own tab, radio check: if the message is listed, delivery worked and the agent simply didn't act on it. |
"The agent has radio check sitting unsubmitted in its prompt box." |
The wake was delivered but not submitted: the recipient's session file has no AUTO_SUBMIT=1, so the wake ended with LF instead of CR (#189). |
grep AUTO_SUBMIT ~/.task-force/radio/sessions/<role>.info — no line means LF. Pressing Enter completes that delivery by hand. On a PM this is the default since #254 — the box is yours, and a submitting wake would send whatever you had half-typed; relaunch with task-pm --auto-submit only for a PM meant to run unattended. On a worker it is not: it was launched with --no-auto-submit, or registered before #254 without --auto / --auto-submit. Relaunch it with plain task-work (or task-recreate-worker --auto-submit) for good. The flag is read off the recipient's file, after any --also alias hop. A role that lost it to a hand radio register before #246 regains it on its next register, as long as it had been recorded once. |
"A role keeps disappearing from sessions/." |
Session flapping — something fires SessionEnd → radio unregister on intra-session events (/clear, /compact, resume, or an unexplained cascade) and the wipe takes TAB_ID with it. #187 / #198 made unregister refuse unless a wipe is explicitly authorized. |
Compare the three counters below. Post-#187, skipping should carry the bulk of the traffic and unregister role= should be close to proceeding plus however many --manual calls were made. A gap has two causes, and --manual is the likelier: it short-circuits the block that emits both other lines, so it writes only role= — anything calling it in a loop inflates that counter alone, notably a test suite that hasn't isolated $TASK_FORCE_HOME and is unregistering your live role (#205). Otherwise an old radio binary is running, since every non---manual call now logs one or the other; radio on PATH is a symlink into a checkout, so run ls -l "$(command -v radio)" and confirm that tree is current. |
"I ran radio unregister and nothing happened." |
Expected behaviour since #198, not a bug. A bare unregister has no SessionEnd payload naming a real exit, so it refuses. |
It printed refusing to wipe <role> … re-run with --manual on stderr, and logged a skipping line. Pass --manual if you actually meant to tear the session down. |
| "The PM merged, but the worker never cleaned up its worktree." | The approved-and-merged ping arrived after that worker had exited, so it was never delivered — and the worker never heard to run task-done. Since #201 gc archives such mail rather than keeping a mailbox nobody will open alive forever. |
ls ~/.task-force/radio/dead-letter/<role>/ — the message is there, id and frontmatter intact. grep 'gc: dead-lettered' ~/.task-force/radio/log lists every message gc has archived and when. Remove the stranded worktree by hand (task-done --remove-worktree from inside it). See Undelivered mail is never deleted. |
"radio check says nothing, but mail is in the inbox" — or a report arrives from: unknown. |
The session has no $TASK_FORCE_ROLE: it never registered, which is what every resumed session looks like. Fixed in #229 — see Roleless sessions. |
grep 'recover-role:' ~/.task-force/radio/log — what was recovered, and what was refused and why. grep 'register: no-op' ~/.task-force/radio/log — a hook that fired with an empty environment (before #229 this left no trace at all). Repair it with radio register --role <role> --tab <tab> --repo "$(git rev-parse --show-toplevel)" --agent claude, from that role's own tab, and check the stderr line says wakeable rather than EMPTY TAB_ID. |
"task-done says no tab id captured and the tab stays open." |
The tab's id was never recorded at launch. Before #242 that capture failed silently; now task-work (and task-reviewer / task-recreate-worker) says so on stderr the moment it happens and logs why — no-zellij-bin, not-in-zellij, no-jq, list-tabs-empty, race, or no-match together with the tab names zellij reported. task-done's skip line now also says which source was empty. |
grep 'tab-id:' ~/.task-force/radio/log — one line per missed capture, with the slug and the reason. A sidecar written before the capture existed (#117) has no TAB_ID= at all. Close the tab by hand; an unscoped close-tab closes whichever tab is focused (#107). |
"task-done says Not closing zellij tab id=N: it is now '<name>' and leaves the tab open." |
The id in the worker's .info sidecar is stale: zellij restarted since it was captured, and that id now belongs to a different tab. Before #260 task-done closed it anyway — on 2026-10-18 that killed a freshly launched worker. A resume in place keeps the old id; task-recreate-worker is the command that rewrites it. |
grep 'tab-close:' ~/.task-force/radio/log — one line per refusal, naming the tab that holds the id now. Close your own tab by hand. After a restart, recover workers with task-recreate-worker, or re-run radio register --tab … from the tab, which since #260 also rewrites a stale sidecar id. |
| "A worker's tab is gone, but its worktree is still there." | A restart (machine, or the zellij server) took the tab, the session file and $TASK_FORCE_ROLE with it. The work is untouched — this is a re-binding problem, not a lost-work one. |
task-recreate-worker <slug> opens a fresh tab on that worktree with the role env injected, so it registers and drains its inbox. It reports what it found first — unpushed commits, an existing PR, queued mail. If it says the role still looks live, read which signal fired, then --force. See task-recreate-worker. |
| "The worker says it's idling for a message that's in its own inbox." | Fixed in #197. Pre-#197 the stop-hook gave up on the stop_hook_active flag alone, so a message that landed during a forced drain turn got no continuation of its own — terminal for an idle --auto worker nobody was going to prompt again. |
grep BLOCKED_IDS ~/.task-force/radio/sessions/<role>.info — the ids the last block was about. In the log, arrived during the drain turn is a correct re-block; no new message since the block is the loop-breaker firing because the agent ignored the same ids twice. |
~/.task-force/radio/log is the single best diagnostic and the only place several of
these states are distinguishable at all. It is append-only, one UTC-stamped line per
event, shared by every repo and role on the machine, and rotated by radio gc once
it passes ~1MB. Read timestamps, not just counts: a log spans radio versions, so a field
missing from an old line means "written by an older binary", not "unset".
L=~/.task-force/radio/log
# Delivery rate — how many sends actually woke someone vs. queued
grep -c 'send id=' "$L" # messages sent
grep -c 'send: woke' "$L" # …that landed as a keystroke in a live pane
# Wake failures bucketed by reason (role names stripped)
grep -oE 'is busy|is awaiting|looks dead|no session for|has no TAB_ID|tab id unresolved|no writable pane|write-chars failed' "$L" \
| sort | uniq -c | sort -rn
# Wipes vs. refusals (#187 / #198). `role=` counts every actual wipe and is
# the ONLY line a --manual wipe writes, so role= minus proceeding is the
# --manual traffic — deliberate (task-done) or not (#205).
grep -c 'unregister role=' "$L"
grep -c 'unregister: proceeding' "$L"
grep -c 'unregister: skipping' "$L"
# A refusal records the stdin shape it came from (#198) — `tty` was a human typing it
grep -oE 'skipping \(empty payload on [a-z-]+ stdin' "$L" | sort | uniq -c
# Re-seed losses (#188): where a rebuilt session got its tab binding.
# `none` is the bad one — that role is unwakeable until a real `register`.
grep -oE 'tab_id_src=[a-z-]+' "$L" | sort | uniq -c
grep -c 'no tab binding for' "$L"
grep -c 'loadout=unknown' "$L" # sidecar was missing at re-seed time
# Stop-hook (#197): a re-block is now distinct from a give-up
grep 'arrived during the drain turn' "$L" # re-blocked — correct, new mail mid-drain
grep 'no new message since the block' "$L" # gave up — same ids ignored twiceunregister is not a cleanup command you reach for by hand. It wipes only when a
piped SessionEnd payload names a real-exit reason (logout / prompt_input_exit /
other), and logs a skipping line for everything else: clear / resume, a payload
with no parseable reason, an empty payload, and any payload at all on a host without
jq (where no reason can be read, so none can be trusted). A terminal on stdin does not
authorize a wipe either — it only adds the stderr hint (#198).
--manual (alias --force) is the explicit opt-in. It bypasses the stdin inspection
entirely, works from any stdin shape, and is what task-done passes. A wipe removes the
.info and any --also aliases pointing at it; the .loadout / .agent sidecars
beside it deliberately survive, so the next busy / ready re-seed rebuilds a session
that still knows what it is (see Session state and self-heal).
Nothing in radio deletes a message you haven't read. A message is written to
~/.task-force/radio/mailbox/<role>/inbox/ before any wake is attempted, so every
outcome other than delivered means "on disk, waiting" — not "gone". radio read <id>
(or radio ack <id>) is the only thing that moves it to processed/.
radio gc respects that. It expires processed/ messages past the cutoff, and it
never touches a live role's inbox/ at any age — nor a dead role's, while that mail
is still inside the cutoff (the role may yet come back).
What it no longer does is protect a mailbox forever. Mail addressed to a role that has
exited is never delivered and never read, and before #201 the empty-inbox reclaim
gate made every such mailbox immortal — the sweep meant to clean them up was the one
thing keeping them alive (342 messages across 171 dead roles, on the machine that filed
the ticket). Once a role is dead (no session file, or a >1h-stale heartbeat) and its
mail has aged past the cutoff, gc moves that mail to
~/.task-force/radio/dead-letter/<role>/ — original filename, id and frontmatter
untouched — and then reclaims the emptied mailbox.
That is an archive, not a delete. Nothing unread is ever rm'd, the dead-letter
directory has no TTL of its own, and the files stay readable as plain markdown. It is
where a merge ping that outlived its worker ends up, and it is usually the explanation
for a leaked worktree: the worker exited before approved-and-merged arrived, so it
never heard to run task-done. A fresh PM register reports what it finds, once:
[radio] 7 message(s) addressed to 3 role(s) that had already exited were never
delivered, and have been archived to /Users/you/.task-force/radio/dead-letter/
(worker-repo-a, worker-repo-b, worker-repo-c). Nothing was deleted — read them as
plain files, and re-send anything whose handoff still matters.
That report is routed, not global — ~/.task-force/radio is shared by every repo on
the machine, so a single report would be consumed by whichever PM happened to boot
first. Each archived message is attributed to the PM that owns it: its repo: field if
it has one, otherwise the pm-… that sent it (the stranded mail is overwhelmingly
PM→worker, so this is the signal that usually fires), otherwise the dead role itself
when that was a PM. A message matching none of those lands in an unscoped report any
PM may claim — and since the role names are always listed, a PM reading someone else's
entry can tell. A PM also claims the reports of any --also aliases it answers for,
which never fresh-register on their own.
So there are two places to look, and which one holds the message tells you which problem you have:
ls ~/.task-force/radio/mailbox/<role>/inbox/ # waiting — the role may still read it
ls ~/.task-force/radio/dead-letter/ # never delivered — the role is gone
grep -c 'gc: dead-lettered' ~/.task-force/radio/log
radio gc --dry-run # what a sweep would archive and reclaim, without doing eitherIf the role comes back before the cutoff, its SessionStart register surfaces the whole
backlog in the summary it injects.
Every backstop in the list above — the Stop-hook drain, the prompt-hook injection, the
register backlog report — works by putting hook stdout into the model's context, which
kiro does not do. For a kiro recipient the zellij keystroke is the only push path,
and a missed wake to an idle role has only the agent's own radio check poll behind
it. Diagnose a kiro role by its AGENT= line
(grep AGENT ~/.task-force/radio/sessions/<role>.info) and read
kiro delivery before concluding anything
is broken — a queued message with no drain is the documented behaviour there, not a
failure. Note that a kiro role with no session file at all is a different thing and
is a bug post-#218: its agentSpawn hook should have registered it.
Here's what an end-to-end PR cycle looks like once everything is wired up. Eight beats, each handed off via radio:
1. Spin up the PM. In any zellij tab:
task-pmRenames the current tab to pm-<reponame>, registers via the SessionStart hook, and starts the PM agent in-place. Radio wakes addressed to it type radio check and wait for your Enter, since this is the tab you type in (#254); task-pm --auto-submit makes them submit themselves, for a PM left to run unattended.
2. PM grooms the backlog and dispatches a worker. From the PM tab:
/pm show me the backlog # or: gh issue list --state open
/pm let's do issue 42
PM picks one and spawns a worker:
task-work issue-42 https://github.com/<owner>/<repo>/issues/42 --autoThis creates a worktree, opens a new zellij tab, and launches the worker agent. Focus stays on the PM's tab (--focus jumps to the new one), and radio wakes into the worker submit themselves, so the PM's pings reach it without anyone pressing Enter there (#254; --no-auto-submit if you plan to type in the worker's tab). --auto is the recommended permission mode — it runs the worker under auto-approve since PM-filed specs should be self-contained.
3. Planner (optional). If the issue needs a design pass first, the PM dispatches a planner instead (task-work … --plan). The planner reads the code, writes the spec into the issue body, and ends with:
radio send --to pm --intent spec-ready --issue 42PM's tab gets focused; on the next turn the PM picks it up and dispatches a worker for the same issue.
4. Worker implements. In the worker tab the agent reads the spec, edits files, runs tests, and commits with the issue title as a prefix. The commit-msg hook task-work installed checks that message for a literal CI-skip marker first — see CI-skip markers and the ci-guard hook.
5. Worker opens the PR, bumps Status, pings PM. Once the implementation is in, the worker opens the PR:
gh pr create --base main --head task/issue-42 --fill --body-file - <<'BODY'
## Summary
Two or three plain sentences for you, not for a reviewer: what changed and why.
Closes #42
…the rest of the description…
BODYThe body always opens with ## Summary, on every loadout. It is the part of a PR you read when "#42" no longer tells you what the work was, so it is written for you rather than for the reviewer, and the reviewer flags a PR without one as a nit. Closes … and the rest of the description follow it.
Then bumps the project Status field to In Review (or keeps it at In Progress if the project has no In Review column — the worker reads the mapping from .claude/gh-workflow.md), and pings the PM:
radio send --to pm --intent review-requested --pr 42If PM is idle, its pane is woken with a radio check; if PM is mid-turn, the message queues and PM's Stop hook (radio stop-hook) forces it to drain the ping at the end of that turn.
6. Worker idles. The worker stops here. It does not run task-done yet — cleanup waits for PM's explicit go-ahead in step 8.
7. PM reviews. Round-trip if needed. From the PM tab:
gh pr view 42
gh pr diff 42
gh pr review 42 --comment --body-file - <<'REVIEW' # or: gh pr comment 42 --body-file -
… the review, with `backticks` intact — see the stdin rule above …
REVIEWIf requesting changes (worker roles are worker-<reponame>-<slug>; see ls ~/.task-force/radio/sessions/):
radio send --to worker-task-force-issue-42 --intent changes-requested --pr 42The worker picks up the comments, pushes fixes, and pings back:
radio send --to pm --intent re-review-requested --pr 42Loop until the PR is clean.
8. PM merges and signals cleanup. PM squash-merges, then radios the worker:
gh pr merge 42 --squash --delete-branch
radio send --to worker-task-force-issue-42 --intent approved-and-merged --pr 42On its next turn the worker sees the ping, sets the project Status field to Done, and runs task-done --remove-worktree itself — removing the worktree and closing its own zellij tab. Done.
GitHub scans the entire head-commit message for its CI-skip markers, not just the subject line. An agent that merely quotes one while describing another commit suppresses its own workflow run — and the failure is the worst kind of quiet:
$ gh run list --branch task/kiro-radio-best-effort --json headSha,conclusion
00b91232 success <- final head, merged
48b88ee9 success <- amended, marker removed
76861921 failure <- pre-amend head
06afcf15, the commit carrying three literal markers, does not appear at
all. Not queued, not skipped-with-a-record, not cancelled. It was the PR head
for nine minutes and GitHub never created a run for it. There is no artifact in
the CI record, and absence is indistinguishable from "not looked at yet" when
you're scanning a PR. Both workers this happened to reported "CI green" in good
faith, off an empty check list.
Awareness is not the fix — the second occurrence was written by an agent that
was correctly explaining the first, in the commit message that suppressed its
own run. So the guard is mechanical. Every task-work run installs a
commit-msg git hook that refuses the commit:
$ git commit -m 'describe the [skip ci] commit'
✗ ci-guard: CI-skip marker found in the commit message (.git/COMMIT_EDITMSG)
line 1: [skip ci]
GitHub reads the WHOLE commit message, not just the subject line, so
this commit would get ZERO workflow runs — no failing checks, no checks
at all. That is invisible in 'gh pr view'.
Writing *about* a marker? Break it, e.g. skip-ci (or drop the brackets).
Genuinely want to skip CI? git commit --no-verify
Disable this guard entirely? TASK_FORCE_NO_CI_GUARD=1
Git shares one hooks directory across a repo's worktrees, so the hook covers
every commit in the repo, not only the worktree it was installed from — which
matters, because the sync commit that triggered both occurrences was made on
main. A pre-existing commit-msg hook is never clobbered: it's preserved as
commit-msg.local and chained from the stub. Installation is idempotent, and
the hook no-ops if ci-guard isn't on PATH, so uninstalling task-force never
locks you out of committing.
The guard is also a command in its own right:
ci-guard check [<rev|range>] # scan committed messages (default: HEAD)
ci-guard scan <file>|- # scan a message file or stdin
ci-guard install-hook [<dir>] # (re)install the hook by handThe companion habit — the worker and reviewer prompts now both carry it — is that verifying CI means confirming a run exists and passed for the exact SHA. "Did CI pass" cannot catch a suppressed run, because there is nothing to check:
gh run list -c "$(git rev-parse HEAD)" --limit 1 --json databaseId --jq 'length'
# 0 => no run for this commit; "green" is not a claim you can makeAn empty check list is a red flag, not a pass.
To shift PR review off the PM's (Opus) tab and onto a cheaper Sonnet model, dispatch a one-shot reviewer worker per PR:
task-reviewer <pr-url-or-number> [<spec-identifier>]task-reviewer spawns a fresh zellij tab + git worktree on the PR's head ref, then runs the /reviewer slash command (or the kiro reviewer agent) inside it on Sonnet 5.5 — ANTHROPIC_MODEL=claude-sonnet-5-5 by default on claude (pre-set the env var to override), --model claude-sonnet-5.5 on kiro (-m to override). The kiro reviewer also reads $TASK_WORK_MODEL ahead of that default, so a worker override exported in your shell dispatches reviewers on the same model, at its price (claude-opus-5.5 is 2.00x kiro credits, Sonnet 1.30x); unset it or pass -m to keep reviewers on Sonnet. Claude Code and kiro-cli spell the same model differently, hyphens versus dots. The PM's tab stays focused. Add Bash(task-reviewer *) to the project's .claude/settings.json permissions.allow so the PM can dispatch a reviewer without a permission prompt.
The reviewer:
- Reads the spec (passed as the second arg — issue number / URL for
claude-gh/kiro-gh; Jira key forclaude-jira; Notion page URL forclaude-notion; local task slug or path forclaude-local). Onclaude-gh/kiro-ghonly, the wrapper also auto-detects from the PR body's firstCloses #N/Fixes #N/Resolves #Nline (case-insensitive); non-gh loadouts require the spec identifier explicitly because PR bodies don't carry their tracker's linking convention. - Reads the PR diff + comments.
- Cross-checks the diff against the spec, then runs the
code-reviewskill on top (claude variants — kiro stays prompt-driven). - Posts one thorough PR comment with spec-compliance findings, code-review findings, and a verdict (
clean,clean-with-nits, orchanges-requested) — viagh pr comment --body-file -off a quoted heredoc, so the backticked code its findings are made of survives the shell. - Radios PM back with
review-complete-cleanorreview-complete-with-findings. - Auto-destructs — on the claude loadouts the reviewer's final action is
task-done --remove-worktree, which removes the review worktree and closes its own tab. It is single-shot and self-cleaning: the analysis is durable in the PR comment, so nothing is lost when the tab goes. The kirorevieweragents still idle with the tab open — clean those up by hand.
PM still decides whether to merge or request changes — the reviewer never approves, merges, closes, or mutates Status. Re-read a review with gh pr view <N> --comments rather than hunting for a tab that cleaned itself up. The one case a claude reviewer keeps its tab is a failed radio delivery: the verdict never reached PM, and that outcome is the one thing its PR comment does not record, so it holds open to say so.
Tight-PR norm. The reviewer frames every finding as fix-in-this-PR rather than "defer to a follow-up", and the PM forwards all in-scope findings — blockers and nits — in a single changes-requested round. Only work genuinely out of the PR's scope gets deferred, and the PM grooms that into a ticket during the session instead of leaving it as a loose "later."
task-reviewer 42 # claude-gh / kiro-gh: PR by number, auto-detect issue
task-reviewer https://github.com/owner/repo/pull/42 # PR by URL
task-reviewer 42 38 # claude-gh / kiro-gh: PR + GitHub issue explicit
task-reviewer 42 PROJ-123 # claude-jira: PR + Jira key explicit (no auto-detect)
task-reviewer 42 https://notion.so/page-id # claude-notion: PR + Notion page URL explicit
task-reviewer 42 042-add-login # claude-local: PR + local task slug explicit
task-reviewer 42 --no-auto # opt out of auto-permission (interactive review)--auto is the default — the reviewer's authority list rules out merge / push / approve / close / Status, so auto-permission is safe for the review flow, and matches the "dispatch and walk away" intent of the command. Pass --no-auto to drop into the interactive permission-prompt mode. Radio auto-submit is a separate setting, also on by default, and --no-auto no longer switches it off (#246): clicking through a reviewer's tool calls says nothing about whether PM's pings into it should need an Enter. --no-auto-submit is its own opt-out. Kiro reviewers default to --trust-all-tools for the same reason; opt out with --no-trust-all, which likewise leaves auto-submit on (#146). Both agents run the one canonical bin/task-reviewer, so a kiro-gh reviewer resolves its spec exactly as a claude-gh one does.
Common to every combo:
-b, --base BRANCH— branch the PR will target (default: branch you're on whentask-workruns)-f, --from REF— git ref to fork the new worktree's branch from (default: current HEAD). Accepts any refgitaccepts (branches, tags, SHAs,origin/foo). Use this to stack a PR on an in-flight branch (--from task/issue-46 --base main) or to spike offorigin/mainwithout checking it out first.--no-launch— create the worktree and open the tab at that directory, but don't auto-start the agent (you pick the model/command yourself)--impl <name>— force a specific combo, bypassing auto-detection
Radio auto-submit and focus, on every combo (#246, #254):
--auto-submit— radio wakes submit themselves (CR) instead of waiting in the prompt box for an Enter. The default since #254,--planincluded: nobody types in a worker's tab. The flag only makes a launch line explicit.--no-auto-submit— keep the Enter gate. Use it for a worker you mean to step into and steer by hand: an incoming wake would otherwise submit whatever is half-typed in its box.--focus— switch to the new tab. By default focus stays on the tab you rantask-workfrom, so the PM can dispatch without being pulled away (#254; before that only--autokept it).
claude-* combos also accept -p/--plan and --auto. On claude, --auto is --permission-mode auto and nothing else since #254 — auto-submit and stay-on-your-tab, which it used to imply, are now every launch's defaults. #246 had already split the halves: a worker can answer a wake hands-off without auto-accepting edits (--auto-submit, which also composes with --plan), and a worker kept off auto mode because its task is destructive — a release cut that tags and publishes — no longer loses auto-submit as a side effect. Before #246 permission policy and radio delivery were one flag, so the worker you supervised most carefully was also the one whose handoffs stalled.
kiro-* combos also accept -m/--model MODEL, -a/--trust-all, and --auto. The model defaults to $TASK_WORK_MODEL, else claude-opus-5.5, and task-pm and task-recreate-worker launch on the same default (#259). It always goes on the launch line as --model, because kiro-cli ranks that flag above an agent config's model field and both above your chat.defaultModel setting, so a task-force launch no longer runs on whatever chat.defaultModel says. claude-opus-5.5 is an experimental preview in kiro-cli; if kiro withdraws it, set TASK_WORK_MODEL. On kiro, --auto is accepted and does nothing since #254 — the permission model is -a/--trust-all (#206), and the auto-submit and focus it once governed are now the defaults. Before #206 kiro had no --auto case at all, so the flag aborted the launch and a kiro worker could never be woken without a keypress.
A kiro worker's first turn is the task reference, passed as kiro-cli chat's positional [INPUT]. kiro-cli runs it as soon as it starts, unless a startup prompt gets there first, and in a dispatched tab nobody answers one, so the worker shows its welcome message and sits idle as if it had never been given a ref (#157). Two such prompts are within reach. The classic UI's deprecation dialog is avoided by pinning --tui on the launch line when the installed kiro-cli accepts it, which also overrides a chat.ui = legacy setting. kiro-cli also documents a trust-all confirmation at startup for -a/--trust-all. That one was not observed here, and it is not ours to skip, so task-work and the kiro task-reviewer, which is trust-all by default, warn on stderr unless chat.disableTrustAllConfirmation is true. Set it with kiro-cli settings chat.disableTrustAllConfirmation true, or answer the prompt in the worker's tab.
A third prompt comes with kiro-cli's V3 engine (#257): "Your agent configs are still in the V2 format. Upgrade them to run on V3?" It was observed on kiro-cli 2.32.0 holding the first turn on the exact launch line task-work builds, --tui included, whenever the launch runs on V3: today that means chat.agentEngine = v3. kiro-cli's own help calls v2 "the pre-3.0 default", so V3 is expected to become the default with 3.0, and then every kiro launch is exposed. The dialog is the chat.enableAutoAgentUpgrade setting. When the setting is unset it prompts, true rewrites the configs without asking, and false never asks. kiro-cli has no flag or env var that sets it for one launch. So task-work, task-recreate-worker and the kiro task-reviewer warn on stderr when the launch would run on V3 and the setting is unset. None of them warns under --no-launch. Set it once with kiro-cli settings chat.enableAutoAgentUpgrade false. With false, V3 loads the agent configs task-init wrote as they are, and the radio hooks fire. true skips the dialog too, but kiro-cli then rewrites .kiro/agents/*.json into its universal format, where hooks is an array of {name, trigger, action: {type, command}, timeout} entries. The same rewrite happens when you answer "Enable auto-upgrade" in the dialog or run kiro-cli agent upgrade yourself. The radio hooks survive it. Since #267, task-init and task-remove handle that format too. A re-run of task-init recognises radio's entries by their command and adds any that are missing in the array shape, with the name radio-<trigger> and the 10-second timeout the upgrade itself gives an entry. task-remove and task-config set strip only those entries. Neither one converts a config from one format to the other.
Tests live in tests/ and use bats-core.
git submodule update --init --recursive # first time only
./run_tests.sh # run everything
./run_tests.sh task_done # run a single suiteRadio-home isolation. The suite drives destructive radio commands —
task-done calls radio unregister --manual, which wipes a session file
unconditionally. tests/setup_suite.bash therefore points every run at a
throwaway $TASK_FORCE_HOME, so no test can reach the ~/.task-force of
whoever ran it (#203; before the fix a full run wiped the runner's own live
session 57 times, which made an agent worker unaddressable mid-run). You get
that for free from ./run_tests.sh and from a bare bats tests/foo.bats.
If $TASK_FORCE_HOME is missing or aimed back at the real home, loading
tests/helpers/common.bash aborts the run with an explanation rather than
writing to the live mailbox.
PATH isolation. The same shape, a different variable (#223). task-work and
task-done resolve task-board from $PATH in preference to this checkout's own
root copy, and install.sh plants ~/.local/bin/task-board as a symlink into
whichever checkout ran the installer last — so on any machine that has ever
installed task-force, the suite was rendering its fixtures' boards with another
clone's code. That copy is the root bin/task-board, which refuses on a fixture repo
carrying no workflow doc, and a || true swallowed the refusal whole: the only
symptom was a missing tasks/_board.md two assertions later. CI installs
nothing, so CI stayed green — the suite was red for exactly the people most
likely to run it. tests/setup_suite.bash now rewrites $PATH once per run so no
task-force command is reachable by name, mirroring each holding directory as
symlinks minus our own commands so everything else it provides (jq, gh, …)
still resolves. Loading tests/helpers/common.bash aborts the run if one is
reachable anyway. A test that wants a task-board on $PATH plants its own —
tests/board_regen.bats covers both resolution branches on purpose, since a
branch that only fires by accident of the developer's environment is how this
bug survived.
Test suites (click to expand)
| Suite | Covers |
|---|---|
install.bats |
Root install.sh — direct args, fzf/gum TUI, numbered-menu fallback |
task_init_dispatcher.bats |
Root task-init — impl dispatch, passthrough flags, TUI selector |
task_work_dispatcher.bats |
Root bin/task-work — auto-detect impl, --impl/AW_IMPL overrides, passthrough |
task_work.bats |
Canonical bin/task-work's shared body, run as claude-gh — slugs, worktree + --from / auto-refresh, .info + TAB_ID, launch quoting |
task_work_impls.bats |
The per-impl table — one row per aw_all_impls entry: its tracker's .info key, its agent's launch line, both axes in --help, --auto → TASK_FORCE_AUTO_SUBMIT=1 |
task_work_trackers.bats |
task-work's tracker axis — notion / jira / local ref parsing, payloads, local's state.json + board |
task_work_agents.bats |
task-work's agent axis — claude --plan / --auto / auto-submit, kiro -m / -a, shared flags identical under both |
task_done_dispatcher.bats |
Root bin/task-done — same detection + worktree-aware fallback |
loadout_modules.bats |
lib/trackers/ + lib/agents/ — every axis name in aw_all_impls has a module, no orphan modules, every hook resolves for all seven impls, and removing any module fails loudly |
jira_task_init.bats |
claude-jira/bin/task-init — placeholder substitution, CLAUDE.md, --force |
claude_notion_task_init.bats |
claude-notion/bin/task-init — template copy, CLAUDE.md, --force |
claude_gh_task_init.bats |
claude-gh/bin/task-init — owner/repo/project substitution, remote auto-detect |
claude_local_task_init.bats |
claude-local/bin/task-init — tasks/ scaffolding, .claude/local-workflow.md, slash commands |
kiro_notion_task_init.bats |
kiro-notion/bin/task-init — template copy, --force |
kiro_gh_task_init.bats |
kiro-gh/bin/task-init — owner/repo/project substitution |
kiro_local_task_init.bats |
kiro-local/bin/task-init — tasks/ scaffolding, .kiro/steering/local-workflow.md, agents |
task_board.bats |
bin/task-board rendering — frontmatter parsing, sidecar overlay, _board.md regen |
task_board_dispatcher.bats |
bin/task-board detection — local-loadout render, --repo-driven detection, refusal on gh/jira/notion |
task_config.bats |
bin/task-config — show on all seven loadouts + the zero/ambiguous states, set round trips, user-content survival, --dry-run inertness |
task_remove.bats |
bin/task-remove — zero artifacts left on all seven loadouts (asserted against la_owned_paths), user-content survival, --purge, the ci-guard hook and its commit-msg.local restore, the global install left alone, --dry-run inertness |
task_done.bats |
Canonical bin/task-done — cleanup, PR section, guards; pinned per loadout with AW_IMPL so each tracker module is proven to have actually run |
radio_home_isolation.bats |
The suite's own radio-home isolation — nothing lands under $HOME/.task-force |
path_isolation.bats |
The suite's own $PATH isolation — no task-force command is reachable by name, and the board tests survive a foreign task-board on $PATH |
board_regen.bats |
lib/board-regen.sh — both task-board resolution branches ($PATH copy, sibling copy), and a failed render warning instead of aborting |
Infrastructure:
tests/helpers/common.bash—setup_repo,setup_stubs,setup_worktree,teardown_all,assert_stub_calledtests/setup_suite.bash— runs once per bats invocation; exports the run-scoped$TASK_FORCE_HOMEtests/helpers/radio_home.bash—task_force_home_is_isolated,require_isolated_task_force_hometests/helpers/path_isolation.bash—sanitize_path_of_task_force,require_task_force_free_path,task_force_commands_on_pathtests/helpers/stubs/— fakes forzellij,gh,kiro-cli,claude; every call lands in$STUB_CALLS_DIR/*.callstests/libs/— bats-core, bats-support, bats-assert as git submodules
- Create
<impl-name>/withinstall.sh,bin/task-init, and asteering/*.example.md. Nobin/task-workand nobin/task-done— both are composed fromlib/trackers/<tracker>.shandlib/agents/<agent>.sh(#236, #237), so a combo reusing an existing tracker and an existing agent needs no new module file at all. - Add
<impl-name>to the picker andcasestatement in the rootinstall.sh. - Add
<impl-name>to the picker andcasestatement in the roottask-init. - Add
<impl-name>toaw_all_implsinlib/detect-impl.sh, and the workflow doc filename toaw_impl_workflow_doc, so dispatchers can route to it. If the combo introduces a new tracker or agent (rather than a new pairing of existing ones), addlib/trackers/<tracker>.shorlib/agents/<agent>.sh—tests/loadout_modules.batsderives fromaw_all_implsand will fail until you do. - Add a row to the loadout table and a per-combo section in this README.
- Write tests — one
.batsfile per script, and a row's expectations intests/task_work_impls.batsif the combo brings a new tracker or agent (the table fails on an axis value it has no expectation for).
#!/usr/bin/env bats
bats_load_library bats-support
bats_load_library bats-assert
load helpers/common
setup() { setup_repo; setup_stubs; cd "$MAIN_REPO"; }
teardown() { teardown_all; }
# Anything that shells out to radio or task-done also wants a per-test mailbox:
# setup() { setup_repo; setup_stubs; setup_task_force_home; cd "$MAIN_REPO"; }
@test "description" {
run "$MY_SCRIPT" arg
assert_success
assert_output --partial "expected text"
assert_stub_called zellij "some-command"
}Add script path variables to tests/helpers/common.bash. The runner picks up every tests/*.bats file automatically.
set -euo pipefailin every script- Quote every variable
- No package managers, no compiled code — pure shell
- 2-space indentation
main is protected via the GitHub API (not a YAML file), so the config lives off-repo. The current rules:
- Required status checks (all four must be green before merge):
ShellCheck,Bats tests (ubuntu-latest),Bats tests (macos-latest),Loadout drift check strict=true— PR branch must be up-to-date withmainenforce_admins=true— the maintainer can't bypass either- Force-pushes and branch deletion blocked
Re-apply (or restore after an accidental clear):
gh api -X PUT repos/martin-conur/task-force/branches/main/protection --input - <<'JSON'
{
"required_status_checks": {
"strict": true,
"contexts": [
"ShellCheck",
"Bats tests (ubuntu-latest)",
"Bats tests (macos-latest)",
"Loadout drift check"
]
},
"enforce_admins": true,
"required_pull_request_reviews": null,
"restrictions": null
}
JSONInspect: gh api repos/martin-conur/task-force/branches/main/protection | jq.
MIT © Martin Conur
