关于 squad-run
squad-run 是编排 squad 任务实施的技能,涵盖规划、实现、审查和测试阶段。它为多 Agent 工作流定义任务级路由、审批门和状态转换。
主要功能
- 01按任务 ID 自动触发
- 02按级别执行流水线
- 03路由 Worker 与 Reviewer Agent
- 04人工审批与驳回门
- 05依据判定转换状态
- 06经同意门控的观察事件
适用场景
- 01让 squad 任务经过完整流水线
- 02继续处理中任务的下一步
- 03暂停以审查计划或实现
- 04将驳回的工作退回修改
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ steloit/ squad-skills --skill squad-run安装后在 Claude Code 中输入/squad-run确认已加载。
详细介绍
自动触发规则
在以下情况下,务必调用此技能(无需等待 /squad-run):
-
用户提到 Squad 任务 ID 并要求实现:
- “实现任务 #NNN”/“构建任务 NNN”/“做 NNN”/“运行 NNN”
- 任何同时提到任务编号和实现/构建/处理/执行的消息
-
Claude 提议实现某个具体的 Squad 任务,且用户确认:
- 模式:Claude 说“要我实现任务 #NNN [标题] 吗?”→ 用户回复“是”“好”“开始”“去做”
- 此确认必须自动触发
/squad-run <ID>,不得手动实现
-
有任务正在进行时,用户说“下一个任务”/“继续”:
- 先获取看板上下文,确定下一个待办任务,然后运行它
自动触发时:提取任务 ID 并调用 /squad-run <ID> — 切勿手动实现代码,然后再更新 Squad 状态。
共享上下文:阅读
../squad/shared.md,了解流水线级别、状态转换、API 端点、错误处理和 Agent 上下文流。 安全原则:阅读../squad/principles.md— 必须遵守,不可省略。 架构:阅读../squad/schema.md,了解完整的数据库架构、列说明和 JSON 字段格式。
命令
在 Codex 环境中,可以直接以斜杠命令文本调用此技能,例如 $squad-run <ID> 或 $squad-run <ID> --auto。
/squad-run step <ID> — 单步执行
仅执行下一个流水线步骤,然后退出。逻辑与 /squad-run 相同,但不循环。
/squad-run <ID> [--auto] — 运行完整流水线
默认:在计划审查和实现审查的审批环节暂停,等待用户确认。
--auto:全自动运行(仍会触发断路器)。
编排循环(按级别)
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)
先读取任务的 level 字段,以确定要执行哪些步骤。
模型路由(按提供商)
根据提供商从 ../squad/models.json 中解析真实模型名称:
- 如果设置了
SQUAD_MODEL_PROVIDER环境变量,则使用其值(claude或codex) - 否则,如果存在
CODEX_*环境变量,则使用codex - 否则,如果存在
CLAUDE_*环境变量,则使用claude - 否则,如果存在
.claude/,则使用claude - 否则,如果存在
.codex/,则使用codex - 否则,使用
models.json中的default_provider
对于 Codex,路由器应优先选择 models.json 中能力较强的条目,用于完整的 squad-run 流水线。
首先按照 ../squad/shared.md → 模型解析,解析 MODEL_PROVIDER 以及 read_model/read_effort 辅助函数,然后查询每个 Agent 的模型:
MODEL_WORKER=$(read_model worker)
MODEL_REVIEWER=$(read_model reviewer)
EFFORT_WORKER=$(read_effort worker)
EFFORT_REVIEWER=$(read_effort reviewer)
实现
# 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
观察门控接缝(发出任何 user_steering 事件前须征得同意)
在编排器发出任何 user_steering 观察事件之前,必须通过同意门控 — ../squad/scripts/observe.py(只读;参见 ../squad/shared.md → 观察与同意)。在运行开始时仅解析一次,并缓存整个运行期间的退出代码(observe.py 无状态 — 每次调用都会执行一次 GET /consent):
# 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
本地紧急开关(DO_NOT_TRACK/SQUAD_OBSERVE_DISABLED/CI)会直接关闭门控,不发起网络请求;否则会读取服务器端的同意状态,并在发生任何错误时默认拒绝。运行过程中通过网页取消选择的操作会在下一次运行时生效;任何滞后的事件都会被服务器端的同意门控以 403 拒绝。事件发出本身就是观察捕获;此门控是其调用的接缝。
每步状态转换约定(每次状态变更均由编排器负责)
编排器(而非 Agent)负责发起每次状态转换。每个 Agent 步骤都严格按以下顺序执行:
- 记录 — Agent 执行工作、填写输出字段,并通过 POST 对应的判定端点记录判定结果(approved/reject | pass/fail + comment)(Reviewer@plan_review →
/plan-review,Reviewer@impl_review →/review,Worker@test →/test-result)。Agent 不会更改状态。 - 读取 — 编排器读取当前阶段由服务器生成的
last_plan_review_status/last_review_status/last_test_status字段。下一状态根据判定结果在本地计算(见下表),绝不使用 POST 响应中的newStatus。 - 门控(默认模式)— 在状态变更之前运行
AskUserQuestion,供用户接受或拒绝。--auto会跳过人工提示,但仍会发起状态变更。 - 提交 — 编排器发起唯一一个经过验证的通用
PATCH /api/task/:id,将状态设为下一状态,并设置current_agent:null和actor:"Orchestrator"。 - 副作用 — 只有在提交
done状态变更之后,才执行 git commit 并添加提交备注。
读取判定结果 — 当前审查阶段由服务器生成的状态:
# 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 '')")
判定结果 → 下一状态(在本地计算;与 getTransitions 一致)。每一行都通过唯一一个经过验证的通用 PATCH {status:<next>, current_agent:null, actor:"Orchestrator"} 发起。判定结果是从派生字段读取的字面值——审查阶段为 approved / changes_requested,测试阶段为 pass / fail:
| Agent @ 状态 | 判定结果 | 通用 PATCH 状态变更 |
|---|---|---|
| Worker @ plan | (完成) | L3 → plan_review · L2 → impl |
| Reviewer @ plan_review | approved / changes_requested | impl / plan |
| Worker @ impl | (完成) | 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 审批快照(Reviewer → test)
当 Reviewer 的判定为 approved,且计算出的移动是 L3 impl_review → test
转换时,紧接着发出通用移动 PATCH 后,立即捕获 APPROVAL_TREE——Reviewer 批准的完整工作树状态(已跟踪修改 + 未跟踪文件)的确定性内容哈希。这仅适用于 L3:L1/L2 没有 test 阶段,而 L2 的 approved
分支会直接移动到 done,因此绝不能捕获。
# 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 现在表示 Reviewer 批准的确切差异——树的字节完全相同 ⇒ 哈希相同;任何内容更改(包括新增未跟踪文件)⇒ 哈希不同。
拒绝循环(plan_review→plan、impl_review→impl、test→impl)会重新调度该列的 Agent;plan→plan 重新进入时只设置 current_agent(不执行非法的状态移动)。重新发出已应用的移动是安全的空操作。
人工关卡覆盖写入(默认模式)。 在第 3 步关卡中,人工可以拒绝——即使 Agent 已记录 approved 也可以(派生的 $VERDICT 仍为 approved,因此上表会计算出前进移动)。人工要求退回的操作不是终端回滚备注:它会在移动之前作为持久、可追溯的覆盖记录在服务端。人工拒绝时(仅默认模式——--auto 绝不会拒绝),记录覆盖,然后重新读取现已翻转的 $VERDICT,并继续使用同一个判定→移动表——此时会计算出流水线允许的后退移动(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> + on_behalf_of=<owner>; 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
更正时发出 user_steering。 当审查判定为拒绝时(在 plan_review 阶段 Reviewer 判定 changes_requested→plan,在 impl_review 阶段 Reviewer 判定 changes_requested→impl,在 test 阶段 Worker 判定 fail→impl)——或者在默认模式下,人工在 AskUserQuestion 关卡(第 3 步)拒绝时——发出一个抽象化的 user_steering 事件;该事件受运行开始边界处(上文)缓存的 OBSERVE_OK 控制,并采用尽力而为方式(|| true)。枚举值根据 ../squad/shared.md 中的关卡 抽象规则(各关卡映射表)确定;--comment 使用抽象化模式(经过泄漏过滤;命中任何内容时替换为 (redacted))。使用该步骤的 correlation_id,使事件与步骤关联。常规批准不发出任何事件;拒绝循环中的重新调度是一次新的发生(使用新的 correlation_id),而非重复事件。
# 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 昵称与身份
流水线运行两个 Agent。每个 Agent 都有一个固定昵称,在所有记录中保持一致——任务卡会变成工作日志;每个字段和每条日志记录都有签名。
| 昵称 | 角色 | 模型键 | 推理强度(codex) | 状态触发条件 |
|---|---|---|---|---|
Worker | Worker Agent(端到端:计划、实现 + 测试、测试运行) | worker | high | plan、impl、test |
Reviewer | Review Agent(验证输出并提供反馈) | reviewer | medium | plan_review、impl_review |
线上的 actor 标签。 看板的 actor 字段(位于通用任务 PATCH 和活动追加中)由服务端根据固定枚举进行验证,该枚举早于双 Agent 流水线:
Planner / Critic / Builder / Shield / Inspector / Ranger / Refiner / Orchestrator / Heartbeat。
未知值(例如 Worker)会导致 400 错误。因此,枚举约束字段的写入会发送列的线上标签;自由字符串字段(current_agent、判定中的 reviewer / tester、签名标头)则直接使用真实昵称 Worker / Reviewer:
| 列 | v2 Agent | 线上 actor(枚举约束写入) |
|---|---|---|
plan | Worker | Planner |
plan_review | Reviewer | Critic |
impl | Worker | Builder |
impl_review | Reviewer | Inspector |
test | Worker | Ranger |
JSON 格式、线上 Actor 标签契约和签名标头规则,请参见
../squad/schema.md。
Agent 调度
模板文件位于 ../squad/templates/。每列对应一次派发;模板中的 <FOCUS>
占位符用于选择匹配的 ## Focus: 部分。
| 状态 | 模板 | FOCUS | 昵称 | 模型键 |
|---|---|---|---|---|
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 |
每次派发的最少字段(只获取该次派发所需的字段):
| 派发 | 必需字段 |
|---|---|
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 |
派发流程 — 每次派发都按以下顺序执行:
⓪ 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')
就绪检查(硬性阻断):
当依赖的状态为 done 或 cancelled(两种终态)时,该依赖即为已解决。
如果任何 .blocked_by[].status 不属于 {done, cancelled},则任务尚未就绪:
- 默认模式:使用
AskUserQuestion— 展示未完成的依赖,并在继续前确认。 --auto模式:以"blocked by incomplete dependency #N"拒绝并中止流水线。 这是硬性阻断,优先于软性子任务提示(下方 ①)。 用作阻塞项的史诗会自动完成 — 当其所有子项都达到终态时,其存储状态会汇总为done/cancelled,从而自动满足基于状态的就绪检查(无需手动执行/complete)。派生的史诗complete汇总状态仅用于显示;满足依赖条件的是与汇总结果保持同步的存储状态。 下方的 jq 保持不变 —done已在已解决状态集合{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
客户端不检查循环依赖。服务端会在写入时(事务内 CTE)强制确保无环;若
POST /relationships形成循环,则返回 409。该 409 会在写入路径中(声明技能里)暴露,而不会在此处预先验证。
获取每项依赖的上下文(用于注入缓存):对 DEP_IDS 中的每个 id,获取上下文字段。
若返回 404,则发出警告并跳过该依赖,继续处理。
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
构建每次派发的依赖上下文字符串: 对每项缓存的依赖,根据当前派发组装上下文:
- Worker@plan:
decision_log(500 个字符)+implementation_notes(500 个字符) - Worker@impl:
implementation_notes(500 个字符) - Reviewer@impl_review:
decision_log(300 个字符)
截断规则:若字段长度超过限制,则取前 N 个字符 + ...[truncated]。
若依赖状态不属于 {done, cancelled}:在该依赖的内容块前添加 [IN PROGRESS] 警告。
状态为 done 或 cancelled 的依赖均已解决 — 不添加 [IN PROGRESS] 标记。
若没有依赖:DEPS_CONTEXT=""(空字符串 — 清除占位符)。
每项依赖的格式:
### #<DEP_ID>: <title> [<status>]
[IN PROGRESS]
**Decision Log:**
<truncated decision_log>
**Implementation Notes:**
<truncated implementation_notes>
提取重跑时的审查反馈 — 使用一个占位符 <review_feedback>,并按派发类型填充:Worker@plan 重跑时获取最后一条 plan_review_comments;Worker@impl 重跑时获取最后一条 review_comments;其他所有派发均使用空字符串。
# 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
① 读取任务字段(按每次分派读取字段,以尽量减少令牌用量)
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 对每次分派,只提取上述列出的字段
② 移入对应列 + 将 Agent 标记为活跃 — 一次按层级执行的 PATCH 为本次分派的这一步生成一个全新的关联 ID — 切勿缓存或 在循环中重复使用。每次分派都必须使用新 ID,包括每次驳回后的 再次分派(plan_review→plan、impl_review→impl、test→impl):每次分派都是一个 独立步骤,必须有自己的 ID。
CORRELATION_ID=$(python3 -c 'import uuid;print(uuid.uuid4())')
同一个 $CORRELATION_ID 会同时传入 Agent 模板(步骤 ④ 中的 --set correlation_id=)
和编排器针对本步骤发出的 activity POST(步骤 ⑥),
这样看板就会将步骤的 record-results 写入和 agent_log 合并为同一条时间线
记录。
列状态变更和 current_agent 赋值必须通过一次 PATCH 完成(绝不能分开调用或额外调用)。根据正在进行的分派,选择相应的请求体(current_agent 是自由字符串字段 — 填写实际昵称,而不是 wire 标签):
• Worker@plan,来自 todo(L2/L3): { "status": "plan", "current_agent": "Worker", "actor": "Orchestrator" }
• Worker@impl,来自 todo(L1): { "status": "impl", "current_agent": "Worker", "actor": "Orchestrator" }
• Worker@plan,当前已在 plan(已在上方处理的新进入,或 Reviewer 驳回后
经 plan_review→plan 重新进入): { "current_agent": "Worker" } (不变更状态 —
plan→plan 不合法;幂等重新分派)
• 已在自身列中的任何分派(Reviewer@plan_review、Worker@impl、
Reviewer@impl_review、Worker@test): { "current_agent": "" } (不变更状态)
api PATCH /task/$ID --json <上述请求体>
③ 读取模板文件 Read 工具:../squad/templates/worker.md 或 ../squad/templates/reviewer.md(Agent Dispatch 表)
④ 填写模板占位符
替换以下每个占位符的所有出现位置:
→ 实际任务 ID
→ 实际项目名称
→ 本次分派的重点(plan | impl | test | plan_review | impl_review)
<project_brief> → 步骤 ⓪ 中的项目简介(若未设置则为空字符串)
→ 任务标题
<description> → 任务描述(用户最初的请求 — 可能早于 spec)
<spec> → Refiner spec 的呈现内容:"## Refined Spec\n\n…";若没有 spec,则为 ""(参见下方 SPEC_MD);如果存在 spec,则其优先级高于 description(../squad/shared.md → Spec Precedence)
→ plan 字段值
<decision_log> → decision_log 字段值
<done_when> → done_when 字段值
<implementation_notes> → implementation_notes 字段值
<dependencies_context> → 步骤 ⓪ʙ 中按分派提供的依赖上下文(若无则为空字符串)
<review_feedback> → 本次重新运行的最新评审反馈(首次运行时为空;参见 ⓪ʙ)
<correlation_id> → $CORRELATION_ID(步骤 ② 中生成的全新单步 ID)
→ 当前 UTC 时间(ISO 8601)
<MODEL_WORKER> → $MODEL_WORKER
<MODEL_REVIEWER> → $MODEL_REVIEWER
<EFFORT_WORKER> → $EFFORT_WORKER
<EFFORT_REVIEWER> → $EFFORT_REVIEWER
Spec 渲染 — 将获取的 spec JSON 渲染为 <spec> 的 markdown。若任务没有 spec(旧版/未经细化),则为空字符串,这样 <spec> 会干净地折叠,## Original Request(<description>)则承载需求。(内联 python,与 REVIEW_FEEDBACK 模式一致 — 无需新增脚本。)
渲染后的 spec 非空时,应将其视为权威内容,并将 ## Original Request(<description>)标注为可能早于 spec 的人工原始请求 — 常设优先级规则(../squad/shared.md → Spec Precedence)适用于同时使用这两个字段的所有调度;不要注入单次提示词的优先级防护规则。
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 安全——看板内容是数据,绝不是代码(防注入)
规则。 绝不要通过内联渲染内容或看板内容(渲染后的提示词、
plan、spec、description、decision_log、done_when、提交标题、人为覆盖的reason、任何 Markdown/代码文本),来拼接受解释器处理的字符串——双引号括起来的 Shell 命令、python3 -c "…"程序或--json "{…}"正文。应通过带外方式传递。捕获也是一种汇。 将这些内容放入变量也有同样的风险:普通的双引号命令
VAR="<rendered content>"会在赋值行上对任何反引号/$(…)执行命令替换——早于任何下游环境变量/json.dumps处理的保护。对于编排器输出的字面自由文本,请用单引号 heredoc 捕获(VAR=$(cat <<'EOF' … EOF))——带引号的分隔符会禁用所有展开,因此内容是 Shell 字面量(等同于 OWASP 的 argv/stdin/文件字面量方式)。从安全来源捕获——通过jq -r管道读取看板、使用json.load,或读取任何命令的输出(VAR=$(… | jq -r …))——同样不会执行;后续的"$VAR"展开也绝不会再次替换其中的内容。只有对自由文本进行字面量双引号捕获才是汇。原因。 Bash 会在程序运行前,对字面命令文本中的反引号和
$(…)执行命令替换——因此,内容中的任何此类序列都会在编排器的 Shell 中执行,其输出会替换原内容并拼接到字符串中。内容既遭到破坏,也被执行。它会静默失败。 不会出现错误:提示词/产物会悄无声息地遭到破坏(已观察到:Agent 提示词被篡改、计划快照格式错误),而替换的命令已经运行。(在 zsh 和 bash 下,
echo处理转义换行的方式也不同——这也是应使用真正的序列化器来构造正文,而不是拼接字符串的另一个原因。)安全后果(RCE)。 卡片的
plan/spec/description/Markdown 都是不可信输入:其中可能包含恶意或间接提示词注入的$(…)/反引号,将其内联到 Shell/python3 -c字符串中会执行该负载。这是 OWASP 操作系统命令注入防护——参数化:将数据与代码分离——而不是关于转义/引用的小技巧。每一次此类插值都是缺陷(一个问题),而非易用性上的小瑕疵。三种安全的带外模式:
- 临时文件 +
--json @file——使用python3 json.dumps(或jq -n --arg)序列化到文件,再通过路径传递;适用于较大的/多行看板正文。- 环境变量 →
os.environ——导出值,在程序内部通过os.environ[...]读取;绝不要将其拼接进-c文本(下方第⑥步活动中的BODY)。- stdin/渲染管道——
echo "$VAR" | python3 -c …(SPEC_MD/REVIEW_FEEDBACK构造器)或render_agent_prompt.py的管道到变量方式。"$VAR"展开不会执行——bash 不会再次替换变量值;只有被字面粘贴到命令文本中的内容才有危险。# ❌ 错误——内容被内联到命令文本中;bash 会先替换任何 $(…)/反引号: # api POST /task/$ID/activity --json "{\"message\": \"<rendered-content>\"}" # python3 -c "print('<rendered-content>')" # ❌ 错误——即使是捕获也会执行替换:用普通双引号赋值来捕获自由文本: # REASON="<rendered-content>" # ✅ 正确(捕获)——单引号 heredoc:带引号的 <<'…' 分隔符会禁用所有展开, # 因此自由文本中的反引号/$(…) 会保留为字面内容: REASON=$(cat <<'REASON_EOF' <the human's reason> REASON_EOF ) # ✅ 正确(写入)——通过环境传递值;程序将其作为惰性数据读取: BODY=$(MSG="$REASON" python3 -c "import json, os; print(json.dumps({'message': os.environ['MSG']}))") api POST /task/$ID/activity --json "$BODY"
推荐的辅助脚本:
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")
如果字段缺失,传入空字符串(--set key="")。
仅当所有未解析的 <...> 令牌都应视为错误时,才使用 --strict。
⑤ 使用已填充的提示词启动 Task 工具
如果 MODEL_PROVIDER 是 codex:
Task(
subagent_type = "general-purpose",
model = "",
model_reasoning_effort= "",
prompt =
)
否则(claude):
Task(
subagent_type = "general-purpose",
model = "",
prompt =
)
⑥ Task 完成后——为此步骤追加一条归属于 Agent 的活动事件。
actor 是 Agent 刚刚执行的列对应的 WIRE LABEL(参见 Wire Actor Labels —
不是 "Orchestrator"),并标注 Agent 的模型,以及——可选,尽力而为——运行时在 Task
完成时自行报告的每个子 Agent 的 Token 用量(仅当运行时提供时记录,否则省略,绝不为 null/0;绝不由 Orchestrator 估算)。
可直接复制粘贴的具体示例,以及 Agent 事件与 Orchestrator 机器事件的区别:参见本段下方的
"⑥ Agent-attributed step event"。
correlation_id 与步骤 ② 中生成并在步骤 ④ 传给 Agent 模板的 $CORRELATION_ID 相同——
Agent 的 record-results 写入和这条活动事件使用同一个 id(correlation_id:$CORRELATION_ID),
以便看板将它们归入此步骤的同一条时间线记录。
#### ⑥ 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 resolved model>`) — **not** `actor:"Orchestrator"`. The `actor`
field is enum-bound on the wire, so it carries the column's wire label (Wire Actor Labels table
above); `actor` and `model` are the reliable per-step attribution and are always present. `tokens`
is **OPTIONAL and best-effort**: include it ONLY when the runtime itself reports per-subagent
usage for the just-completed Task, read from that reported usage — never orchestrator-estimated (a
caller sees only the final result, not the subagent's internal LLM/tool calls, so any local
estimate structurally undercounts). Omit it otherwise (never null, never 0); per-agent token stats
are therefore not guaranteed or complete. Single atomic POST, no read-modify-write:
~~~bash
# ⑥ Agent-attributed step event — the AGENT that just ran is the actor (NOT "Orchestrator"),
# recorded under its column's WIRE LABEL, tagged with its model + (OPTIONAL, best-effort)
# the runtime's OWN reported per-subagent token usage at Task completion.
# WIRE_ACTOR = this step's wire label (plan→Planner · plan_review→Critic · impl→Builder ·
# impl_review→Inspector · test→Ranger)
# AGENT_MODEL = the dispatched agent's resolved model ($MODEL_WORKER / $MODEL_REVIEWER)
# STEP_MSG = one-line summary of what the step did
# STEP_TOKENS = best-effort: your runtime's OWN reported per-subagent usage for the
# just-completed Task — read from what the runtime reports, never
# orchestrator-estimated. Leave UNSET if the runtime reports nothing — the key
# is then omitted (never null/0); per-agent token stats are not guaranteed.
# Shell Safety (see the box at the prompt-assembly seam): every field crosses via the environment
# and is read with os.environ INSIDE the program — board content is data, never inlined into the
# python3 -c "…" text. This is the canonical env→os.environ out-of-band pattern.
BODY=$(WIRE_ACTOR="$WIRE_ACTOR" AGENT_MODEL="$AGENT_MODEL" STEP_MSG="$STEP_MSG" \
CID="$CORRELATION_ID" STEP_TOKENS="${STEP_TOKENS:-}" python3 -c "
import json, os
b = {'actor': os.environ['WIRE_ACTOR'], 'model': os.environ['AGENT_MODEL'],
'message': os.environ['STEP_MSG'], 'correlation_id': os.environ['CID']}
tok = os.environ.get('STEP_TOKENS')
if tok: b['tokens'] = int(tok) # runtime usage; omitted entirely when the runtime reports nothing
print(json.dumps(b))")
api POST /task/$ID/activity --json "$BODY"
~~~
**Agent-step event vs orchestrator machine event.** The step-⑥ event above is *agent-attributed*.
The orchestrator's OWN machine events stay `actor:"Orchestrator", model:"system"`: the status-move
PATCHes (the transition contract) and the **commit note** below. Do not sweep those into agent
attribution.
The Worker RETURNS its output (no self-move). Once the impl dispatch completes — its own template
already ran the project formatter and tests via Command Resolution — the orchestrator issues the
impl→impl_review **commit** move directly:
```bash
api PATCH /task/$ID --json '{"status": "impl_review", "current_agent": null, "actor": "Orchestrator"}'
默认模式:在 plan_review、impl_review 和 test(L3 测试门禁)阶段,契约顺序明确——Agent
记录其裁定 → Orchestrator 读取该裁定 → 门禁(通过 AskUserQuestion 接受/拒绝)→
Orchestrator 使用通用 PATCH(current_agent:null)提交状态流转。门禁
先于状态流转 PATCH;状态流转始终使用通用 PATCH,绝不使用裁定端点。
如果人工在门禁处拒绝——包括 Agent 已记录 approved 之后——必须先记录一条
持久化、可追溯的覆盖记录(POST /task/$ID/override-review,必须提供 reason),以更改派生的裁定,
然后正常读取→状态流转流程会计算向前一阶段退回的操作(即上文所述的人工门禁覆盖写入流程);如果返回
403(PAT 不具备 task:override-review 权限),则将错误告知用户,绝不静默修正。
自动模式(--auto):顺序相同,但在门禁处自动接受(Orchestrator 仍会发出状态流转 PATCH)。
→ 完成状态流转(所有级别)
过时原则(impl_review → test → done)。 Reviewer 的批准仅适用于获批时的差异;在完成提交之前, 对工作树的任何后续更改都会使该批准失效,并需要通过同一个 impl_review 门禁重新审核。
过期批准复查(L3,APPROVAL_TREE 非空时)。 在下方完成提交状态流转 PATCH 之前,
重新捕获整个树的内容哈希,并将其与 Reviewer 批准的快照进行比较——按内容是否相同判断,不要按测试文件与源文件的分类判断:
# Re-capture PRECOMMIT_TREE with the SAME non-destructive primitive (whole tree, tracked + untracked).
if [ "$LEVEL" = "3" ] && [ -n "$APPROVAL_TREE" ]; then
TMPIDX=$(mktemp) && rm -f "$TMPIDX"
GIT_INDEX_FILE="$TMPIDX" git add -A
PRECOMMIT_TREE=$(GIT_INDEX_FILE="$TMPIDX" git write-tree)
rm -f "$TMPIDX"
if [ "$PRECOMMIT_TREE" = "$APPROVAL_TREE" ]; then
: # identical content hash → the approved diff is intact → proceed to the done-commit unchanged.
else
# Differs → the approval is STALE (e.g. the test dispatch edited the tree after approval).
# Re-dispatch the Reviewer through the EXISTING impl_review gate — NOT a new column/status.
# Move the card back via the standard impl_review re-entry, mint a FRESH per-step
# correlation_id, follow the existing record→read→gate→commit contract, and respect the
# EXISTING circuit breaker: impl_review_count > 3 → stop, ask user (no infinite loop). The
# re-review uses the same reviewer template (FOCUS=impl_review), the same /review verdict
# endpoint, and the same verdict→move table:
# • re-review approved → capture a FRESH APPROVAL_TREE (re-snapshot with the primitive
# above), the test stage runs again, and the next test→done attempt
# re-enters this compare.
# • re-review changes_requested → the EXISTING impl_review → impl reject loop fires (route back to
# the Worker), unchanged.
echo "stale approval: working tree changed after impl_review approval — re-reviewing"
# … re-enter impl_review here (existing gate, fresh correlation_id, impl_review_count breaker) …
fi
fi
# 1. Move to done (single validated generic PATCH) — clears current_agent.
# Re-issuing when already done is a safe no-op.
api PATCH /task/$ID --json '{"status": "done", "current_agent": null, "actor": "Orchestrator"}'
# 2. Side-effects AFTER the state commit — commit pending changes.
if [ -n "$(git status --porcelain 2>/dev/null)" ]; then
git add -A
# The task title is board content — never inline it into the commit-message string (a backtick
# in a title would command-substitute; see the Shell Safety box). Build the message in a
# variable (expansion is inert) or use `git commit -F <file>`.
MSG="feat: $TITLE [squad #$ID]"
git commit -m "$MSG"
fi
COMMIT_HASH=$(git rev-parse --short HEAD 2>/dev/null || echo "no-git")
# 3. Record commit hash as an activity event (machine event, actor=Orchestrator/system)
SUBJECT=$(git log -1 --format=%s 2>/dev/null || echo "no-git")
BODY=$(jq -n --arg msg "Committed $COMMIT_HASH: $SUBJECT [squad #$ID]" \
'{actor: "Orchestrator", model: "system", message: $msg}')
api POST /task/$ID/activity --json "$BODY"
如果尚无提交,则跳过该事件,或记录 message:"Committed (none) [squad #$ID]"。
→ Coach(本次运行的摩擦审查)
卡片变为 done 并完成提交后,按照 ../squad/shared.md → Coach Dispatch 派发 Coach(步骤 ⑤ 中已解析 MODEL_PROVIDER 和辅助脚本)。传入:
skill_name=squad-runsource_task=$IDrun_summary="squad-run pipeline completed task $ID to done."trajectory= 任务的活动事件(按顺序排列的所有 Agent 步骤)+ implementation_notes + 审查裁定friction_signals= 本轮记录的拒绝循环 / 断路器触发 / Agent 重试;无异常时为none
/squad-run review <ID> — 代码审查
对状态为 impl_review 的任务触发 Reviewer(与 impl_review 步骤相同)。