Auto-Trigger Rules
ALWAYS invoke this skill (without waiting for /squad-run) when:
User mentions a squad task ID and requests implementation:
- "implement task #NNN" / "build task NNN" / "do NNN" / "run NNN"
- Any message pairing a task number with implement / build / work on / do
Claude has proposed implementing a specific squad task and the user confirms:
- Pattern: Claude says "Shall I implement task #NNN [title]?" → User replies "yes", "ok", "go", "do it"
- This confirmation must trigger
/squad-run <ID>automatically — do not implement manually
User says "next task" / "continue" when a task is in progress:
- Fetch board context first, identify next todo task, then run it
When auto-triggered: extract task ID and call /squad-run <ID> — never implement code manually and patch squad state afterward.
Shared context: read
../squad/shared.mdfor pipeline levels, status transitions, API endpoints, error handling, and agent context flow. Safety principles: read../squad/principles.md— mandatory, not optional. Schema: read../squad/schema.mdfor full DB schema, column descriptions, and JSON field formats.
Commands
In Codex environments, this skill may be invoked directly as a slash command text such as $squad-run <ID> or $squad-run <ID> --auto.
/squad-run step <ID> — Single Step
Execute only the next pipeline step then exit. Same logic as /squad-run but no loop.
/squad-run <ID> [--auto] — Run Full Pipeline
Default: pause for user confirmation at Plan Review and Impl Review approvals.
--auto: fully automatic (circuit breaker still fires).
Orchestration Loop (Level-Aware)
Per-step contract (every agent step): record → gate → commit → side-effects.
The agent records its verdict (approved/reject | pass/fail + comment) by POSTing the matching
verdict endpoint; it does NOT move status. The orchestrator then: (1) reads the recorded
verdict, (2) gates (default mode: the card SITS in its review state until the user signal;
`--auto` skips the human gate only), (3) commits the move via the generic `PATCH /api/task/:id`
to the verdict-correct next status with `current_agent:null` and `actor:"Orchestrator"` (re-issuing a move that is already
applied is a safe no-op), (4) runs side-effects (git commit + note) AFTER the move.
L1 Quick:
todo → orchestrator PATCH {status:impl, current_agent:Worker, actor:Orchestrator} → Worker@impl implements
→ orchestrator PATCH {status:done, current_agent:null, actor:Orchestrator} → side-effects (commit + note)
L2 Standard:
todo → [orchestrator: todo→plan, current_agent=Worker, actor:Orchestrator] → Worker@plan
→ orchestrator PATCH {status:impl, current_agent:null, actor:Orchestrator} (skip plan_review)
impl → Worker@impl (implements + writes tests) → orchestrator PATCH {status:impl_review, current_agent:null, actor:Orchestrator}
impl_review → Reviewer@impl_review returns verdict → orchestrator records → [user confirm] →
orchestrator PATCH {status: done | impl, current_agent:null, actor:Orchestrator} → side-effects after done
L3 Full:
todo → [orchestrator: todo→plan, current_agent=Worker, actor:Orchestrator] → Worker@plan
→ orchestrator PATCH {status:plan_review, current_agent:null, actor:Orchestrator}
plan_review → Reviewer@plan_review returns verdict → orchestrator records → [user confirm] →
orchestrator PATCH {status: impl (approve) | plan (reject), current_agent:null, actor:Orchestrator}
(reject re-dispatches Worker@plan with <review_feedback>; plan→plan re-entry is idempotent)
impl → Worker@impl (implements + writes tests) → orchestrator PATCH {status:impl_review, current_agent:null, actor:Orchestrator}
impl_review → Reviewer@impl_review returns verdict → orchestrator records → [user confirm] →
orchestrator PATCH {status: test (approve) | impl (reject), current_agent:null, actor:Orchestrator}
test → Worker@test returns verdict → orchestrator records → [gate] →
orchestrator PATCH {status: done (pass) | impl (fail), current_agent:null, actor:Orchestrator} → side-effects after done
Circuit breaker: plan_review_count > 3 OR impl_review_count > 3 → stop, ask user
(counts are incremented when a verdict is recorded, not by the move PATCH)
Read the task's level field first to determine which steps to execute.
Model Routing (Provider-Aware)
Resolve real model names from ../squad/models.json using provider:
SQUAD_MODEL_PROVIDERenv var if set (claudeorcodex)- else
codexwhenCODEX_*env is present - else
claudewhenCLAUDE_*env is present - else
claudewhen.claude/exists - else
codexwhen.codex/exists - else
default_providerfrommodels.json
For Codex, the router should prefer the higher-capability entries in models.json for the full squad-run pipeline.
First resolve MODEL_PROVIDER and the read_model / read_effort helpers per ../squad/shared.md → Model Resolution, then look up each agent's model:
MODEL_WORKER=$(read_model worker)
MODEL_REVIEWER=$(read_model reviewer)
EFFORT_WORKER=$(read_effort worker)
EFFORT_REVIEWER=$(read_effort reviewer)
Implementation
# 1. Read current task state (status + level + card_type).
# Stateless: re-read (status, level) every loop; NEVER cache status across a gate.
TASK=$(api GET /task/$ID?fields=status,level,card_type)
STATUS=$(echo "$TASK" | jq -r '.status')
LEVEL=$(echo "$TASK" | jq -r '.level')
CARD_TYPE=$(echo "$TASK" | jq -r '.card_type // "task"')
# 1a. Pipeline-exclusion: an epic is a CONTAINER, not runnable. Refuse and list its children.
# (See ../squad/shared.md → Task Relationships & Epics.)
if [ "$CARD_TYPE" = "epic" ]; then
REL=$(api GET /task/$ID/relationships)
KIDS=$(echo "$REL" | jq -r '(.children // []) | if length == 0 then "(no children yet)" else (.[] | " #\(.id) \(.title) [\(.status)]") end')
PROG=$(echo "$REL" | jq -r '"\(.children_progress.done)/\(.children_progress.total)"')
echo "Epic #$ID is a container — run its children ($PROG done):"
echo "$KIDS"
exit 0 # abort before dispatch
fi
# 1a-bis. Cancelled is a TERMINAL status — not runnable. Refuse before dispatch; reopen to run.
# (See ../squad/shared.md → Move Protocol: cancelled is reachable from any status and is
# left only via POST /task/:id/reopen — cancelled → todo.)
if [ "$STATUS" = "cancelled" ]; then
echo "Task #$ID is cancelled (terminal) — reopen to run."
exit 0 # abort before dispatch
fi
# 1a-ter. Done is a TERMINAL status — not runnable. Refuse before dispatch; reopen to re-run.
# A `done` card is terminal regardless of HOW it got there — a gated pipeline finalize OR an
# administrative POST /task/:id/complete both land on the same `done`, so one branch covers both.
# (See ../squad/shared.md → Move Protocol: done has no forward transition; left only via reopen.)
if [ "$STATUS" = "done" ]; then
echo "Task #$ID is done (terminal) — reopen to re-run."
exit 0 # abort before dispatch
fi
# 1b. Sub-task readiness nudge (SOFT — NOT a block; the dep hard-block in ⓪ʙ takes precedence).
# A `task` with incomplete .children → warn "usually run those first".
# Default mode: AskUserQuestion confirm/cancel. --auto: proceed + log an Orchestrator activity note.
REL=$(api GET /task/$ID/relationships)
OPEN_KIDS=$(echo "$REL" | jq -r '[(.children // [])[] | select(.status != "done" and .status != "cancelled")] | length')
if [ "$OPEN_KIDS" -gt 0 ]; then
echo "Task #$ID has $OPEN_KIDS open sub-task(s) — usually run those first (nudge, not a block)."
# --auto: proceed and log:
# POST /api/task/$ID/activity {actor:"Orchestrator", model:"system",
# message:"--auto proceeded past $OPEN_KIDS open sub-task(s)"}
# default: AskUserQuestion confirm/cancel before dispatch.
fi
# The plan dispatch's level-aware exit status — the orchestrator moves plan → $PLAN_NEXT after
# Worker@plan finishes: L3 → plan_review, L2 → impl. (L1 never reaches the plan column.)
if [ "$LEVEL" = "3" ]; then PLAN_NEXT=plan_review; else PLAN_NEXT=impl; fi
# 2. Pipeline-entry / dispatch (see Agent Dispatch below)
# When STATUS == todo:
# L1 → ONE PATCH {"status":"impl","current_agent":"Worker","actor":"Orchestrator"}, then dispatch Worker@impl.
# L2/L3 → ONE PATCH {"status":"plan","current_agent":"Worker","actor":"Orchestrator"} (the todo→plan entry
# move), then dispatch Worker@plan. The card is in the PLAN column before the
# Worker begins — step ② below is this same single level-aware entry PATCH.
# When STATUS == plan (fresh entry OR Reviewer-reject re-entry via plan_review→plan):
# dispatch Worker@plan WITHOUT re-moving status. plan→plan is not a legal transition
# and MUST NOT be attempted (idempotent re-entry). Set current_agent:"Worker" only.
# All other statuses: dispatch the column's agent (see Agent Dispatch table).
# 3. After agent: append one activity event via POST /activity (see schema.md for format)
# 4. The agent records its verdict; orchestrator READS it → GATE (default) → COMMIT move
# (generic PATCH, current_agent:null) → SIDE-EFFECTS (git commit + note, only after a done commit).
# See "Per-Step Transition Contract" below for the full ordering + mapping.
# 5. Re-read state, loop until done or circuit breaker
Observation gate-seam (consent before any user_steering emit)
Before the orchestrator emits any user_steering observation event it MUST pass the consent gate — ../squad/scripts/observe.py (read-only; see ../squad/shared.md → Observation & Consent). Resolve it ONCE at run start and cache the exit code for the whole run (observe.py is stateless — one GET /consent per call):
# Resolve once per run; cache the decision. 0 = emit, non-zero = skip (no parsing).
python3 ../squad/scripts/observe.py gate >/dev/null 2>&1; OBSERVE_OK=$?
# … later, per correction, reuse the cached decision — do NOT re-resolve:
# [ "$OBSERVE_OK" = 0 ] && <emit user_steering> # else skip
Local kill-switches (DO_NOT_TRACK / SQUAD_OBSERVE_DISABLED / CI) hard-off the gate with no network; otherwise it reads server consent and fails closed on any error. A mid-run web opt-out takes effect on the next run; any straggler emit is 403'd server-side by the consent gate. The emission itself is the observation capture; this gate is the seam it calls.
Per-Step Transition Contract (orchestrator owns every move)
The orchestrator — never the agent — issues every status transition. Each agent step runs in strict order:
- Record — the agent does its work, writes its output fields, and records
its verdict (approved/reject | pass/fail + comment) by POSTing the matching verdict endpoint
(Reviewer@plan_review →
/plan-review, Reviewer@impl_review →/review, Worker@test →/test-result). The agent does NOT change status. - Read — the orchestrator reads the server-derived
last_plan_review_status/last_review_status/last_test_statusfield for the current stage. The next status is computed locally from the verdict (table below) — never from anewStatusin the POST response. - Gate (default mode) —
AskUserQuestionaccept/reject runs BEFORE the move.--autoskips the human prompt but still issues the move. - Commit — the orchestrator issues the single validated generic
PATCH /api/task/:idto the next status withcurrent_agent:nullandactor:"Orchestrator". - Side-effects — git commit + commit note, only AFTER a
donemove is committed.
Read the verdict — the server-derived status for the current review stage:
# The orchestrator reads the server-derived verdict for the current review stage:
# plan_review → last_plan_review_status · impl_review → last_review_status · test → last_test_status
case "$STATUS" in
plan_review) VFIELD=last_plan_review_status ;;
impl_review) VFIELD=last_review_status ;;
test) VFIELD=last_test_status ;;
esac
VERDICT=$(api GET /task/$ID?fields=$VFIELD \
| VFIELD="$VFIELD" python3 -c "import sys,json,os; print(json.load(sys.stdin).get(os.environ['VFIELD']) or '')")
Verdict → next status (computed locally; mirrors getTransitions). Every row is issued via the
single validated generic PATCH {status:<next>, current_agent:null, actor:"Orchestrator"}. The verdict is the literal value
read from the derived field — reviews are approved / changes_requested, the test stage is pass / fail:
| Agent @ status | Verdict | Generic PATCH move |
|---|---|---|
| Worker @ plan | (done) | L3 → plan_review · L2 → impl |
| Reviewer @ plan_review | approved / changes_requested | impl / plan |
| Worker @ impl | (done) | impl_review (L2/L3) |
| Reviewer @ impl_review | approved / changes_requested | (L2 → done · L3 → test) / impl |
| Worker @ test | pass / fail | done / impl |
| done finalize | — | done |
L3 Approval Snapshot (Reviewer → test)
When the Reviewer verdict is approved AND the computed move is the L3 impl_review → test
transition, then immediately AFTER issuing that generic move PATCH, capture APPROVAL_TREE — a
deterministic content-hash of the FULL working-tree state (tracked edits + untracked) that the
Reviewer approved. This is L3-only: L1/L2 have no test stage, and the L2 approved
branch moves straight to done and must NOT capture.
# Right after the impl_review→test move PATCH. L3-only: L2 approved→done never captures.
# Non-destructive content-hash of the WHOLE tree (tracked + untracked) via a throwaway
# temp index — the real index / working tree / stash list are never touched.
if [ "$LEVEL" = "3" ]; then
TMPIDX=$(mktemp) && rm -f "$TMPIDX" # mktemp pre-creates an empty file; git reads it
# as a corrupt index ("index file smaller than
# expected") — REMOVE it before use so git creates
# a fresh temp index at that path.
GIT_INDEX_FILE="$TMPIDX" git add -A # stage tracked edits + untracked into the temp index
APPROVAL_TREE=$(GIT_INDEX_FILE="$TMPIDX" git write-tree) # deterministic tree SHA of the whole tree
rm -f "$TMPIDX" # clean up the throwaway index
fi
APPROVAL_TREE now represents the exact diff the Reviewer approved — identical tree bytes ⇒
identical hash; any content change (including a NEW untracked file) ⇒ a different hash.
Reject loops (plan_review→plan, impl_review→impl, test→impl) re-dispatch the column's agent; a
plan→plan re-entry sets current_agent only (no illegal status move). Re-issuing a move that is
already applied is a safe no-op.
Human gate-override write-through (default mode). At the step-3 gate a human may
reject — including after the agent recorded approved (the derived $VERDICT still reads
approved, so the table above would compute a FORWARD move). The human's send-back is not a
terminal-scrollback note: it is recorded SERVER-SIDE as a durable, attributable override BEFORE the
move. On a human reject (default mode only — --auto never rejects), record the override, then
re-read the now-flipped $VERDICT and fall through to the SAME verdict→move table — which now
computes the backward move the pipeline makes legal (plan_review→plan / impl_review→impl / test→impl):
# Runs ONLY when the human REJECTS at the step-3 AskUserQuestion gate (incl. post-`approved`).
# $STATUS = the review stage (= the override `gate`); $VFIELD/$CID as set above.
if [ "$GATE_DECISION" = reject ]; then
# 1. Mandatory reason — a follow-up AskUserQuestion / free-text. Empty reason ⇒ server 400,
# so re-prompt until non-empty (mirrors the GitHub mandatory dismiss-reason).
# CAPTURE the free-text via a single-quoted heredoc — the quoted <<'…' delimiter disables ALL
# expansion, so a backtick/$(…) in the reason stays inert AT THE ASSIGNMENT (a plain
# REASON="<the reason>" would command-substitute right here, before the safe env handoff below).
# See the Shell Safety box (board content is data, never code) at the prompt-assembly seam.
REASON=$(cat <<'REASON_EOF'
<the human's reason — required, non-empty>
REASON_EOF
)
# 2. Current version (optimistic-concurrency guard against a concurrent override).
VER=$(api GET /task/$ID?fields=version | jq -r '.version')
# 3. Record the SUPERSEDING override over the run's user-scoped PAT. The server stamps
# executed_by=<PAT> + the body carries actor_kind=human (delegation,
# not impersonation). Record-only: it flips last_*_status, never moves status.
# Build the override body OUT-OF-BAND — the human's `reason` is free-text and must never be
# inlined into a --json "{…}" string (a backtick/$(…) in it would command-substitute in the
# shell; a quote/newline would break the JSON). json.dumps reads every field from os.environ,
# so the value crosses as inert data. See the Shell Safety box (board content is data, never
# code) at the prompt-assembly seam below.
ERR=$(mktemp)
export GATE="$STATUS" REASON="$REASON" VER="$VER" CID="$CID"
OVERRIDE_BODY=$(python3 -c "
import json, os
print(json.dumps({'gate': os.environ['GATE'], 'reason': os.environ['REASON'],
'expected_version': int(os.environ['VER']), 'correlation_id': os.environ['CID']}))")
api POST /task/$ID/override-review --json "$OVERRIDE_BODY" 2>"$ERR"
RC=$? # 4 = 4xx (403 missing task:override-review scope · 400 empty reason · 409 stale version)
if [ "$RC" -ne 0 ]; then
# SURFACE the failure to the user — a 403 means the run PAT lacks the elevated
# task:override-review scope. NEVER silently downgrade to a fix-in-place (the
# no-silent-downgrade rule); the server record is the single source of truth.
echo "ERROR: could not record human override on $ID (exit $RC): $(grep -v '^ERROR:' "$ERR" 2>/dev/null || cat "$ERR")"
rm -f "$ERR"
return 1 2>/dev/null || exit 1 # halt the gate; do not move, do not fix in place
fi
rm -f "$ERR"
# 4. Re-read the now-flipped derived verdict; fall through to the verdict→move table above
# (it now computes the backward reject move). The user_steering emit below also fires.
VERDICT=$(api GET /task/$ID?fields=$VFIELD \
| VFIELD="$VFIELD" python3 -c "import sys,json,os; print(json.load(sys.stdin).get(os.environ['VFIELD']) or '')")
fi
Emit user_steering on a correction. When a review verdict is a reject (Reviewer
changes_requested→plan at plan_review, Reviewer changes_requested→impl at impl_review, Worker
fail→impl at test) — or, in default mode, the human rejects at the AskUserQuestion gate (step 3)
— emit ONE abstracted user_steering event, gated by the cached OBSERVE_OK from the run-start
seam (above) and BEST-EFFORT (|| true). Enums come from the gate per ../squad/shared.md →
Abstraction Rubric (the per-gate mapping table); the --comment is an abstracted pattern
(leak-filtered → (redacted) on any hit). Use the step's correlation_id so the event threads
with the step. Routine approvals emit nothing; a reject-loop re-dispatch is a NEW occurrence
(fresh correlation_id), not a duplicate.
# After reading $VERDICT (and before/with the reject move). $CID = the step's correlation_id.
if [ "$OBSERVE_OK" = 0 ]; then
case "$STATUS:$VERDICT" in
plan_review:changes_requested)
python3 ../squad/scripts/observe.py emit "$ID" --modality evaluative --valence negative \
--target planning --severity moderate --attributability violated_constraint \
--comment "rejected the plan" --correlation-id "$CID" || true ;;
impl_review:changes_requested)
python3 ../squad/scripts/observe.py emit "$ID" --modality evaluative --valence negative \
--target verification --severity moderate --attributability violated_constraint \
--comment "requested implementation changes" --correlation-id "$CID" || true ;;
test:fail)
python3 ../squad/scripts/observe.py emit "$ID" --modality evaluative --valence negative \
--target verification --severity major --attributability violated_constraint \
--comment "tests failed" --correlation-id "$CID" || true ;;
esac
fi
Agent Nicknames & Identity
The pipeline runs two agents. Each has a fixed nickname used consistently across all records — the task card becomes a work log; every field and every log entry is signed.
| Nickname | Role | Model Key | Reasoning Effort (codex) | Status triggers |
|---|---|---|---|---|
Worker |
Worker Agent (end-to-end: plan, impl + tests, test run) | worker |
high |
plan, impl, test |
Reviewer |
Review Agent (validates output, provides feedback) | reviewer |
medium |
plan_review, impl_review |
Wire actor labels. The board's actor field (on the generic task PATCH and the activity
append) is server-validated against a fixed enum that predates the 2-agent pipeline:
Planner / Critic / Builder / Shield / Inspector / Ranger / Refiner / Orchestrator / Heartbeat.
An unknown value (e.g. Worker) is a 400. Enum-bound writes therefore send the column's wire
label; free-string fields (current_agent, verdict reviewer / tester, signature headers)
use the real nicknames Worker / Reviewer directly:
| Column | v2 agent | Wire actor (enum-bound writes) |
|---|---|---|
plan |
Worker | Planner |
plan_review |
Reviewer | Critic |
impl |
Worker | Builder |
impl_review |
Reviewer | Inspector |
test |
Worker | Ranger |
See
../squad/schema.mdfor JSON formats, the Wire Actor Labels contract, and the Signature Header Rule.
Agent Dispatch
Template files are at ../squad/templates/. One dispatch per column; the template's <FOCUS>
placeholder selects the matching ## Focus: section.
| Status | Template | FOCUS | Nickname | Model Key |
|---|---|---|---|---|
plan |
templates/worker.md |
plan |
Worker |
worker |
plan_review |
templates/reviewer.md |
plan_review |
Reviewer |
reviewer |
impl |
templates/worker.md |
impl |
Worker |
worker |
impl_review |
templates/reviewer.md |
impl_review |
Reviewer |
reviewer |
test |
templates/worker.md |
test |
Worker |
worker |
Minimum fields per dispatch (fetch only what each dispatch needs):
| Dispatch | Required Fields |
|---|---|
Worker@plan |
title,description,spec,plan_review_comments |
Reviewer@plan_review |
title,description,spec,plan,decision_log,done_when |
Worker@impl |
title,description,spec,plan,done_when,plan_review_comments,review_comments |
Reviewer@impl_review |
title,description,spec,plan,done_when,implementation_notes |
Worker@test |
title,implementation_notes |
Dispatch procedure — execute in this order for every dispatch:
⓪ Fetch project brief (once per pipeline run, cache for all dispatches)
PROJECT_DATA = api GET /projects/$PROJECT
PROJECT_BRIEF = extract .brief field (empty string if null or project not found)
This is injected into every agent template via <project_brief> placeholder.
⓪ʙ Resolve dependencies & review feedback (once per pipeline run, cache for all dispatches)
**Resolve dependencies via the relationships API** (see `../squad/shared.md` → **Task Relationships & Epics**).
The `blocks` dependency edges are read from `GET /api/task/:id/relationships` → `.blocked_by` — NOT
text-parsed from the description. (The `Depends on:` text convention and the old dependencies
endpoint are both retired — see `../squad/shared.md`.)
```bash
# Read structured blocks edges; .blocked_by = the deps this task is blocked by.
REL=$(api GET /task/$ID/relationships)
DEP_IDS=$(echo "$REL" | jq -r '.blocked_by[]?.id')
Readiness gate (HARD BLOCK):
A dep is resolved when its status is done or cancelled (the two terminal statuses).
If any .blocked_by[].status is not in {done, cancelled}, the task is not ready:
- Default mode:
AskUserQuestion— surface the incomplete dep(s) and confirm before proceeding. --automode: refuse with"blocked by incomplete dependency #N"and abort the pipeline. This is the hard block and takes precedence over the soft sub-task nudge (① below). An epic used as a blocker auto-completes — when all its children reach a terminal status its stored status rolls up todone/cancelled, satisfying the status-based readiness gate automatically (no manual/completeneeded). The derived epiccompleterollup stays display-only; the stored status (kept in sync by the rollup) is what satisfies the dep. The jq below is unchanged —doneis already in the resolved set{done, cancelled}.
BLOCKERS=$(echo "$REL" | jq -r '.blocked_by[]? | select(.status != "done" and .status != "cancelled") | "#\(.id) (\(.status))"')
if [ -n "$BLOCKERS" ]; then
# --auto: refuse + abort. default: AskUserQuestion confirm/cancel.
echo "blocked by incomplete dependency $BLOCKERS"
fi
No client-side circular-dependency check. The server enforces acyclicity at write time (in-transaction CTE) and returns 409 on a cycling
POST /relationships; that 409 is surfaced from the write path (in declaration skills), never pre-validated here.
Fetch per-dep context (for cached injection): for each id in DEP_IDS, fetch the context fields.
A 404 warns + skips that dep and continues.
for DEP_ID in $DEP_IDS; do
DEP_TASK=$(api GET /task/$DEP_ID?fields=title,status,decision_log,implementation_notes)
if [ -z "$(echo "$DEP_TASK" | jq -r '.id // empty')" ]; then
echo "WARNING: dependency #$DEP_ID not found (404), skipping"
continue
fi
# Cache: DEPS[$DEP_ID] = { title, status, decision_log, implementation_notes }
done
Build per-dispatch dependency context string: For each cached dependency, assemble context based on the current dispatch:
- Worker@plan:
decision_log(500 chars) +implementation_notes(500 chars) - Worker@impl:
implementation_notes(500 chars) - Reviewer@impl_review:
decision_log(300 chars)
Truncation: if field length > limit, take first N chars + ...[truncated].
If dep status is not in {done, cancelled}: prepend [IN PROGRESS] warning to that dep's block.
A done OR cancelled dep is resolved — no [IN PROGRESS] marker.
If no dependencies: DEPS_CONTEXT="" (empty string — placeholder removed cleanly).
Format per dependency:
### #<DEP_ID>: <title> [<status>]
[IN PROGRESS]
**Decision Log:**
<truncated decision_log>
**Implementation Notes:**
<truncated implementation_notes>
Extract review feedback for re-runs — one placeholder, <review_feedback>, populated
per dispatch: a Worker@plan re-run gets the last plan_review_comments entry; a Worker@impl
re-run gets the last review_comments entry; every other dispatch gets empty string.
# Review feedback for a Worker@plan re-run (source: plan_review_comments)
# — for a Worker@impl re-run, run the SAME extraction against review_comments instead.
REVIEW_FEEDBACK=""
FEEDBACK_JSON=$(echo "$TASK" | jq -r '.plan_review_comments // ""') # or .review_comments
if [ -n "$FEEDBACK_JSON" ] && [ "$FEEDBACK_JSON" != "null" ]; then
REVIEW_FEEDBACK=$(echo "$FEEDBACK_JSON" | python3 -c "
import sys, json
data = json.load(sys.stdin)
if isinstance(data, list) and len(data) > 0:
print(data[-1].get('comment', ''))
")
fi
① Read task fields (use per-dispatch fields to minimize token usage)
Worker@plan
TASK = api GET /task/$ID?fields=title,description,spec,plan_review_comments
Reviewer@plan_review
TASK = api GET /task/$ID?fields=title,description,spec,plan,decision_log,done_when
Worker@impl
TASK = api GET /task/$ID?fields=title,description,spec,plan,done_when,plan_review_comments,review_comments
Reviewer@impl_review
TASK = api GET /task/$ID?fields=title,description,spec,plan,done_when,implementation_notes
Worker@test
TASK = api GET /task/$ID?fields=title,implementation_notes Extract only the fields listed above for each dispatch
② Enter the column + mark the agent active — ONE level-aware PATCH Mint a FRESH per-step correlation id for THIS dispatch occurrence — never cache or reuse one across the loop. Every dispatch gets a new id, including every reject re-dispatch (plan_review→plan, impl_review→impl, test→impl): each occurrence is a distinct step and gets its own id.
CORRELATION_ID=$(python3 -c 'import uuid;print(uuid.uuid4())')
This SAME $CORRELATION_ID is threaded into BOTH the agent template (step ④,
--set correlation_id=) AND the orchestrator's activity POST for this step (step ⑥),
so the board groups the step's record-results write + the agent_log into one timeline
entry.
The entry status move and current_agent assignment are a SINGLE PATCH (never a
separate/third call). Pick the body by the dispatch being made (current_agent is a
free-string field — it carries the real nickname, not a wire label):
• Worker@plan from todo (L2/L3): { "status": "plan", "current_agent": "Worker", "actor": "Orchestrator" }
• Worker@impl from todo (L1): { "status": "impl", "current_agent": "Worker", "actor": "Orchestrator" }
• Worker@plan already at plan (fresh entry handled above, OR Reviewer-reject
re-entry via plan_review→plan): { "current_agent": "Worker" } (NO status move —
plan→plan is illegal; idempotent re-dispatch)
• Any dispatch already in its own column (Reviewer@plan_review, Worker@impl,
Reviewer@impl_review, Worker@test): { "current_agent": "" } (no status change)
api PATCH /task/$ID --json
③ Read template file Read tool: ../squad/templates/worker.md or ../squad/templates/reviewer.md (Agent Dispatch table)
④ Fill placeholders in template
Replace every occurrence of:
→ actual task ID
→ actual project name
→ this dispatch's focus (plan | impl | test | plan_review | impl_review)
→ project brief from step ⓪ (empty string if not set)
→ task title
→ task description (the human's original request — may predate the spec)
→ rendered Refiner spec: "## Refined Spec\n\n…" or "" if no spec (see SPEC_MD below); when present the spec is authoritative over the description (../squad/shared.md → Spec Precedence)
→ plan field value
→ decision_log field value
→ done_when field value
→ implementation_notes field value
→ per-dispatch dep context from step ⓪ʙ (empty string if none)
→ latest review feedback for this re-run (empty if first run; see ⓪ʙ)
→ $CORRELATION_ID (the fresh per-step id minted in step ②)
→ current UTC time (ISO 8601)
→ $MODEL_WORKER
→ $MODEL_REVIEWER
→ $EFFORT_WORKER
→ $EFFORT_REVIEWER
Spec render — render the fetched spec JSON → markdown for <spec>. Empty string when the
task has no spec (legacy/un-refined), so <spec> collapses cleanly and ## Original Request
(<description>) carries the requirements. (Inline python, mirrors the REVIEW_FEEDBACK
pattern — no new script.)
When the rendered spec is non-empty, present it as authoritative and label ## Original Request
(<description>) as the human's original request that may predate the spec — the standing
precedence rule (../squad/shared.md → Spec Precedence) governs every dispatch that consumes
both fields; do not inject a per-prompt precedence guard.
SPEC_MD=$(echo "$TASK" | python3 -c "
import sys, json
d = json.load(sys.stdin); spec = d.get('spec')
if not spec: print('', end=''); sys.exit(0)
out = ['## Refined Spec', '']
if spec.get('goal'): out += ['**Goal:** ' + spec['goal'], '']
reqs = spec.get('requirements') or []
if reqs: out += ['**Requirements:**'] + ['- ' + r for r in reqs] + ['']
qa = spec.get('qa') or []
if qa:
out += ['**Clarifications (Q&A):**']
for it in qa:
out += ['- Q: ' + (it.get('question') or '')]
out += [' A: ' + (it['answer'] if it.get('answer') is not None else '(unanswered)')]
print('\n'.join(out).rstrip())
")
# Every Worker@plan / Reviewer / Worker@impl dispatch consumes the spec. Worker@test does
# NOT (mechanical lint/build/test only) → pass SPEC_MD="" for the test dispatch.
🛡️ Shell Safety — board content is data, never code (injection defense)
Rule. NEVER assemble an interpreter-bound string — a double-quoted shell command, a
python3 -c "…"program, or a--json "{…}"body — by inlining rendered or board content (the rendered prompt,plan,spec,description,decision_log,done_when, a commit title, a human's overridereason, any markdown/code text). Pass it out-of-band.Capture is a sink too. Getting that content into a variable is the same hazard: a plain double-quoted
VAR="<rendered content>"command-substitutes any backtick/$(…)at the assignment line — before any downstream env/json.dumpshandling can protect it. For orchestrator-emitted literal free-text, capture it with a single-quoted heredoc (VAR=$(cat <<'EOF' … EOF)) — the quoted delimiter disables ALL expansion, so the content is a shell literal (the OWASP argv/stdin/file-literal equivalent). Capturing from a safe source — a board read piped throughjq -r, ajson.load, or any command's output (VAR=$(… | jq -r …)) — is also inert, and a later"$VAR"expansion never re-substitutes the value. Only a literal double-quoted capture of free-text is the sink.Why. Bash performs command-substitution on backticks and
$(…)in the literal command text before the program runs — so any such sequence in the content executes in the orchestrator's shell and its output is spliced into the string in place of the original. The content is both corrupted and executed.It fails SILENTLY. No error surfaces: the prompt/artifact is quietly corrupted (observed: mangled agent prompts, malformed plan snapshots) and the substituted command has already run. (
echoalso mangles escaped newlines differently under zsh vs bash — another reason to build bodies with a real serializer, not string concatenation.)Security consequence (RCE). A card's
plan/spec/description/markdown is untrusted input: it can carry adversarial or indirect-prompt-injected$(…)/backticks, and inlining it into a shell /python3 -cstring executes that payload. This is the OWASP OS-command-injection defense — parameterize: separate data from code — not an escaping/quoting tip. Every such interpolation is a defect (a finding), not an ergonomics nit.Three out-of-band safe patterns:
- Temp file +
--json @file— serialize withpython3 json.dumps(orjq -n --arg) to a file and pass it by path; safe for large/multiline board bodies.- Env var →
os.environ— export the value, read it inside the program withos.environ[...]; never splice it into the-ctext (the step-⑥ activityBODYbelow).- stdin / render pipe —
echo "$VAR" | python3 -c …(theSPEC_MD/REVIEW_FEEDBACKbuilders) or therender_agent_prompt.pypipe-to-variable. A"$VAR"expansion is inert — bash does not re-substitute a variable's value; only content pasted literally into the command text is dangerous.# ❌ WRONG — content inlined into the command text; bash substitutes any $(…)/backtick FIRST: # api POST /task/$ID/activity --json "{\"message\": \"<rendered-content>\"}" # python3 -c "print('<rendered-content>')" # ❌ WRONG — even the CAPTURE substitutes: a plain double-quoted assignment of free-text: # REASON="<rendered-content>" # ✅ RIGHT (capture) — single-quoted heredoc: the quoted <<'…' delimiter disables all expansion, # so a backtick/$(…) in the free-text stays a literal: REASON=$(cat <<'REASON_EOF' <the human's reason> REASON_EOF ) # ✅ RIGHT (write) — the value crosses via the environment; the program reads it as inert data: BODY=$(MSG="$REASON" python3 -c "import json, os; print(json.dumps({'message': os.environ['MSG']}))") api POST /task/$ID/activity --json "$BODY"
Recommended helper script:
PROMPT=$(python3 ../squad/scripts/render_agent_prompt.py \
--template ../squad/templates/<worker|reviewer>.md \
--models ../squad/models.json \
--provider "$MODEL_PROVIDER" \
--set ID="$ID" \
--set PROJECT="$PROJECT" \
--set FOCUS="$STATUS" \
--set project_brief="$PROJECT_BRIEF" \
--set title="$TITLE" \
--set description="$DESCRIPTION" \
--set spec="$SPEC_MD" \
--set plan="$PLAN" \
--set decision_log="$DECISION_LOG" \
--set done_when="$DONE_WHEN" \
--set implementation_notes="$IMPLEMENTATION_NOTES" \
--set dependencies_context="$DEPS_CONTEXT" \
--set review_feedback="$REVIEW_FEEDBACK" \
--set correlation_id="$CORRELATION_ID" \
--set TIMESTAMP="$TIMESTAMP")
If a field is missing, pass empty string (--set key="").
Use --strict only when every unresolved <...> token should be treated as an error.
⑤ Launch Task tool with filled prompt
If MODEL_PROVIDER is codex:
Task(
subagent_type = "general-purpose",
model = "",
model_reasoning_effort= "",
prompt =
)
Otherwise (claude):
Task(
subagent_type = "general-purpose",
model = "",
prompt =
)
⑥ After Task completes — append ONE agent-attributed activity event for the step.
The actor is the WIRE LABEL for the column the agent just ran (see Wire Actor Labels —
NOT "Orchestrator"), tagged with the agent's model and — OPTIONAL, best-effort — the
runtime's OWN reported per-subagent token usage at Task completion when the runtime
exposes one (omitted otherwise, never null/0; never orchestrator-estimated). Concrete
copy-pasteable snippet + the agent-event-vs-orchestrator-machine-event distinction: see
"⑥ Agent-attributed step event" immediately below this block.
correlation_id is the SAME $CORRELATION_ID minted in step ② and passed to the
agent template in step ④ — the agent's record-results write and this activity event
carry one id (correlation_id:$CORRELATION_ID), so the board groups them into a single
timeline entry for the step.
#### ⑥ Agent-attributed step event
The per-step activity event is attributed to the **agent that just ran** (`actor:<wire label for
its column>`, `model:<that agent's res
…(truncated)