do-sdlc · diff

git:20260822.f376ae3 to git:20260824.ebc62c9

197 added, 448 removed. Audit A to A.

---
name: do-sdlc
description: "Supervise a full SDLC pipeline run to merge in a local Claude Code session. Triggered by 'do-sdlc', 'run the full pipeline', 'ship this issue end to end', 'supervise the sdlc'. Also the home of the single-stage router contract that /sdlc (in this repo) executes."
context: fork
---
# do-sdlc — SDLC Pipeline Supervisor and Single-Stage Router
This skill is the **one substantive SDLC skill**. It has two entry modes over one shared step
spine:
| Mode | Entry | Executes | Contract |
|------|-------|----------|----------|
- | **Router mode** | `/sdlc` (in this repo) | Steps 1–4 once | dispatch ONE sub-skill, then return; the PM session re-invokes |
+ | **Router mode** | a single-stage router invocation | Steps 1–4 once | dispatch ONE sub-skill, then return; the supervising session re-invokes |
| **Supervisor mode** | `/do-sdlc` | Steps 1–7 | loop until merge/blocked |
- The router mode is the bridge PM's production contract — a single-stage router that assesses
- state and dispatches exactly one `/do-*` skill, expecting the PM session (bridge or this skill's
- supervisor loop) to re-invoke it after each stage completes. The supervisor mode is the local
- stand-in for the bridge PM session: it re-invokes the router, dispatching each stage to a
- subagent on the stage-appropriate model (opus/sonnet), until merge, a blocking guard, or the
- iteration cap.
-
- You are the supervisor or router, never the worker. You assess, dispatch, and track. The stage
+ Router mode assesses state and dispatches exactly one `/do-*` skill, expecting the supervising
+ session to re-invoke it after each stage completes. Supervisor mode is the local stand-in for that
+ session: it re-invokes the router itself, dispatching each stage to a subagent on the
+ stage-appropriate model (opus/sonnet), until merge, a blocking guard, or the iteration cap. You
+ are the supervisor or router, never the worker: you assess, dispatch, and track, and the stage
subagents do all the work.
- **Redundant-context check (issue #2026, WS-F):** if a bridge PM/dev context already owns this
- issue — a live eng session (e.g. a bridge PM session) — then supervisor mode is redundant: that
- context IS the supervision loop. Do not run it; drive via `/sdlc` (in this repo, the single-stage
- router) instead. A live *supervised-run signal* for the issue number is a different case — see
- the refusal decision table in Step 2: if the signal's `owner_run_id` is one this run already
- holds, it is this run's own hand-off, not another owner, and must be inherited rather than
- treated as redundant-context grounds to stand down.
+ **Redundant-context check.** If a live supervising context already owns this issue, supervisor
+ mode is redundant — that context IS the supervision loop. Do not run it; drive the pipeline one
+ stage at a time through router mode instead. A live *supervised-run signal* for the issue number
+ is a different case: if its `owner_run_id` is one this run already holds, it is this run's own
+ hand-off and must be inherited, not treated as grounds to stand down (see Step 2).
## Repo Context Probe
- If `docs/sdlc/do-sdlc.md` exists, read it and honor its declarations; otherwise use the generic
- defaults described below. The defaults drive the pipeline through `sdlc-tool` (synced to every
- machine via `~/.local/bin`), `gh`, and `git`. Repo-coupled specifics — worktree ownership,
- `SDLC_TARGET_REPO`/`GH_REPO` semantics, the G1–G9 guard table's repo path references, and
- `sdlc-tool` command discipline — live in that context file, not in this global body.
+ If `docs/sdlc/do-sdlc.md` exists, read it and honor its declarations; otherwise use the generic defaults described below.
+ The defaults drive the pipeline through `sdlc-tool` (a stage-state CLI on `PATH`), `gh`, and
+ `git`. Repo-coupled specifics — worktree ownership, target-repo and `GH_REPO` semantics, the
+ router's internal guard implementation and its source references, the router-shim relationship,
+ and `sdlc-tool` command discipline — live in that context file, not in this global body.
+
## Hard Rules
- 1. **NEVER write code, run tests, or create plans directly** — every stage executes inside a
- stage subagent that invokes the stage's `/do-*` skill (supervisor mode), or is dispatched as
- exactly one sub-skill and then the router returns (router mode).
+ 1. **NEVER write code, run tests, or create plans directly** — every stage executes inside a stage
+ subagent that invokes the stage's `/do-*` skill (supervisor mode), or is dispatched as exactly
+ one sub-skill after which the router returns (router mode).
2. **NEVER decide dispatch yourself** — `sdlc-tool next-skill` is the only source of dispatch
- decisions. It encodes all guards (G1–G9) and dispatch rows. Do not second-guess it, reorder
+ decisions; it encodes all guards (G1–G9) and dispatch rows. Do not second-guess it, reorder
stages, or skip it "because the next stage is obvious".
3. **NEVER continue past a `blocked` decision** — surface the reason to the human and stop.
Guards block for a reason.
4. **ALWAYS pass `model:` per the Stage→Model table** when spawning a stage subagent. Never rely
on the inherited default.
5. **ALWAYS record the dispatch before spawning the subagent** — this preserves the G4
oscillation signal even if the subagent crashes.
6. **ALWAYS dispatch with `run_in_background: false`, never end the turn waiting on a background child.**
This skill runs in a forked context (`context: fork`) that gets exactly one turn. The
Agent tool defaults to background execution — it returns immediately and notifies later. A
fork has no later turn to be notified on, so a background dispatch is unrecoverable: the fork
- reports "running in the background, I'll continue when it completes" and then never does
- (issue #1915). Every stage subagent must be spawned with `run_in_background: false` so its
- result is in hand before the loop advances. In router mode the same rule applies to the one
- stage you dispatch: it must complete before you return.
- 7. **NEVER spawn agent teammates for stage work.** Where Claude Code agent teams are enabled,
- ignore those affordances: a teammate's idle notification is not a completion signal (teammates
- go idle mid-task with deliverables unfinished), and an in-process teammate cannot be reliably
- resumed. Every dispatch is a foreground subagent per Rule 6.
- 8. **NEVER loop in router mode** — invoke one sub-skill, then return. The PM session handles
- progression.
+ reports "running in the background, I'll continue when it completes" and then never does. Every
+ stage subagent must be spawned with `run_in_background: false` so its result is in hand before
+ the loop advances — in router mode too, for the one stage you dispatch.
+ 7. **NEVER spawn agent teammates for stage work.** Where agent teams are enabled, ignore those
+ affordances: a teammate's idle notification is not a completion signal (teammates go idle
+ mid-task with deliverables unfinished), and an in-process teammate cannot be reliably resumed.
+ Every dispatch is a foreground subagent per Rule 6.
+ 8. **NEVER loop in router mode** — invoke one sub-skill, then return. The supervising session
+ handles progression.
## Worktree & branch ownership
**Slug identity always wins.** Each issue's build fork exclusively owns `.worktrees/{slug}` and
`session/{slug}`, where `{slug}` is the lane's identity, recorded once at lane start and read
- (never re-derived) via the lane-identity resolver — the single source of truth (worktree manager
- + branch resolution; see the repo context probe for this repo's exact paths). Do NOT pre-allocate
- per-supervisor `.worktrees/sdlc-{N}` lanes: nothing reads a lane override, so lane instructions
- are silently dropped and every issue's builders land in `.worktrees/{slug}` regardless.
- Converging fork + supervisor onto one branch per plan is deliberate — it structurally collapses
- duplicate PRs, since GitHub permits only one open PR per head branch. Concurrent builders inside
- the one slug worktree must write disjoint file sets (do-build's `Parallel: true` convention: no
- shared-file writes).
+ (never re-derived) via the lane-identity resolver. Do NOT pre-allocate per-supervisor lanes:
+ nothing reads a lane override, so lane instructions are silently dropped and every issue's
+ builders land in `.worktrees/{slug}` regardless. Converging fork + supervisor onto one branch per
+ plan is deliberate — it structurally collapses duplicate PRs, since GitHub permits only one open
+ PR per head branch. Concurrent builders inside the one slug worktree must write disjoint file sets
+ (do-build's `Parallel: true` convention: no shared-file writes).
## Stage→Model Dispatch Table
- Mirrors the engineer persona's table (`config/personas/engineer.md`) — the local equivalent of
- `valor-session create --model`.
-
| Stage | Skill | `model:` | Rationale |
|-------|-------|----------|-----------|
| ISSUE | /do-issue | sonnet | Structured writing |
| PLAN | /do-plan | opus | Adversarial reasoning, architectural design |
| CRITIQUE | /do-plan-critique | opus | Adversarial review (its internal critics self-pin to sonnet) |
| BUILD | /do-build | sonnet | Tool-heavy plan execution |
| TEST | /do-test | sonnet | Deterministic test runs |
| PATCH | /do-patch | sonnet | Targeted fix |
| REVIEW | /do-pr-review | opus | Nuanced code review judgment |
| DOCS | /do-docs | sonnet | Structured writing |
| MERGE | /do-merge | sonnet | Programmatic gate |
## Step 1: Resolve the Issue or PR
Determine whether the input is an issue reference, a PR reference, or a bare feature description.
- Scope `gh` reads with `--repo` when the repository is known or derivable — under a foreign
- `GH_REPO` (or from a wrong cwd) a bare `gh` command answers about a *different* repository and
- exits 0. When the target repo is not this one, resolve the slug from `GH_REPO` (already an
- org/repo slug) or `gh repo view --json nameWithOwner -q .nameWithOwner` from the target repo
- root, and pass it as `gh ... --repo <slug>`.
+ Scope `gh` reads with `--repo` whenever the repository is known or derivable — under a foreign
+ `GH_REPO`, or from a wrong cwd, a bare `gh` command answers about a *different* repository and
+ exits 0. Resolve the slug from `GH_REPO` or from `gh repo view --json nameWithOwner -q
+ .nameWithOwner` run at the target repo root.
- **Issue reference** (e.g., `issue 123`, `issue #123`): `gh issue view {number} --repo <resolved>` when
a slug resolved.
- **PR reference** (e.g., `PR 363`, `pr #363`): `gh pr view {number} --repo <resolved> --json
number,title,state,headRefName,reviewDecision,statusCheckRollup,body` to get the branch name,
review state, and check status. Then extract the linked issue number from the PR body (look
for `Closes #N` or `Fixes #N`).
- **Bare feature description** (no number): in supervisor mode, dispatch a `/do-issue` stage
- subagent first (sonnet), read the created issue number from its report, then proceed. In
- router mode, do not proceed without an issue number — surface that to the PM.
+ subagent first (sonnet), read the created issue number from its report, then proceed. In router
+ mode, do not proceed without an issue number — surface that to the supervising session.
- **PR state informs Step 3**: when a PR is provided, its current state (checks passing/failing,
- review approved/changes-requested, etc.) tells you which pipeline stage to resume from. Skip
- stages that are already complete — do not restart from scratch.
+ **PR state informs Step 3**: a provided PR's current state (checks passing/failing, review
+ approved/changes-requested) tells you which stage to resume from. Skip stages already complete —
+ do not restart from scratch.
## Step 2: Ensure the Tracking Session
```bash
# SDLC_REPO: GitHub slug (org/repo) — used to build issue/PR URLs.
SDLC_REPO=$(gh repo view --json nameWithOwner --jq .nameWithOwner 2>/dev/null || git remote get-url origin | sed 's/.*github.com[:/]//;s/.git$//')
- # SDLC_TARGET_REPO: filesystem path to the target repo (distinct from SDLC_REPO which is the
- # GitHub slug). sdlc-tool forces cwd to ~/src/ai; this env var tells it where the target
- # repo's plans live. Set once and exported for the lifetime of the supervision loop.
- SDLC_TARGET_REPO=$(git rev-parse --show-toplevel 2>/dev/null || echo "")
- export SDLC_TARGET_REPO
- # Run identity (issue #2003): `session-ensure` is the EXCLUSIVE minting site for the
- # run_id — one uuid-hex identity for this whole supervision run, minted by winning the
- # issue lock and emitted in the JSON output. No env vars, no run files: every
- # state-mutating `sdlc-tool` call in Step 4/5 passes it back explicitly via
- # `--run-id {run_id}`. The standalone worker threads its own run_id in-process, so the
- # real worker-vs-local guard is preserved.
+ # Run identity: `session-ensure` is the EXCLUSIVE minting site for the run_id — one hex
+ # identity for this whole run, minted by winning the issue lock and emitted in the JSON.
+ # No env vars, no run files: every state-mutating call passes it back via `--run-id`.
sdlc-tool session-ensure --issue-number {issue_number} --issue-url "https://github.com/$SDLC_REPO/issues/{issue_number}"
```
+ When the target repo is not the one you are standing in, `sdlc-tool` needs its filesystem path.
+ The repo context probe declares the env var that carries it; export that var once, for the
+ lifetime of the loop, and use it wherever this body writes `{target_repo_path}`.
+
Let stderr through and read the JSON payload. `session-ensure` reports a refusal *in the payload*
(`{"blocked": true, "reason": ...}`), not through the exit code — it exits 0 either way — so a
- discarded diagnostic here is a run that proceeds under an identity it does not hold.
-
- Read the JSON from the tool result and **record the `run_id`** (`{"session_id": ..., "created": ..., "run_id": "<hex>"}`) — carry it through every subsequent step. Reuses the existing
- `sdlc-local-{N}` session on re-runs. The ownership contract:
-
- - **Every state-mutating `sdlc-tool` call** (`dispatch record`, `stage-marker`, `verdict record`, `meta-set`) **MUST pass `--run-id {run_id}` explicitly.** A missing flag is a named non-zero error (`RUN_ID_REQUIRED`) — the call never mints or adopts an identity.
- - **Pass `--issue-number` to every `sdlc-tool` invocation.** It is the authoritative session selector.
- - **`stage-query`, `verdict get`, and `dispatch get` accept no `--run-id` argument** — they have no lock to compare against. `next-skill` sits in neither bucket: it *accepts* `--run-id` as a read-only identity assertion for its issue-lock peek — always pass it there (issue #2766).
- - **Do NOT export `AGENT_SESSION_ID`** — env vars do not persist across Claude Code bash blocks.
-
- **Self-heal on resume (issue #2144).** State-mutating writes (`stage-marker`, `verdict record`,
- `meta-set`, `dispatch record`) now **self-heal** their run identity: if a resumed turn has lost
- the `run_id` from context (worker restart mid-pipeline), the write re-establishes the *same*
- run's identity from the environment (live supervised signal → `.sdlc-run` / `active_run_id` →
- verified re-acquire of the free lock) and retries once, instead of silently refusing
- `RUN_ID_REQUIRED`/`LEASE_ABSENT` and freezing the ledger. This is best-effort and non-blocking;
- a foreign live lease still hard-refuses. Still pass `--run-id` on the happy path; the heal is a
- safety net for the resume edge, not a license to omit it.
-
- ### Three-way refusal decision table
-
- `session-ensure` (and any subsequent re-ensure — see Step 5's between-stage continuity check) can
- refuse with exactly three payload shapes. Discriminate them; do not collapse all three to "stop":
-
- | Refusal | Shape | Action |
- |---|---|---|
- | **Hand-off** | `{"blocked": true, "reason": "SUPERVISED_RUN_ACTIVE", "run_id", "owner_run_id", "owner_session_id"}` | This is a **designed hand-off**, not a block. It mints nothing — the supervisor already owns the run. **Pass the self-identity check below first.** If it confirms your own signal: **inherit** `owner_run_id` (carry it forward as `run_id`, pass it back via `--run-id`/`--reuse-run-id`), and **continue** — never stop for a confirmed-own signal. |
- | **Orphaned lock** | `{"blocked": true, "reason": "ISSUE_LOCKED", "owner_run_id", "owner_session_id", "orphaned_lock": true}` | The prior owner died before renewing; the lock frees within its TTL (duration and renewal semantics are #2446's — cross-reference it, do not reimplement). Wait, re-ensure, then **rebind `run_id` to whatever the re-ensure returns** before continuing — a post-TTL fresh contest mints a NEW run_id, and every downstream `--run-id` call must use the rebound value or it silently orphans. |
- | **Foreign holder** | `{"blocked": true, "reason": "ISSUE_LOCKED", "owner_run_id", "owner_session_id", "orphaned_lock": false}` | Apply the same self-identity check as the hand-off row: `owner_run_id ∈ {run_ids this run has held}` means this is your own lock, not a foreign one — **inherit and continue**, never stop. Only when `owner_run_id` is genuinely foreign is this the **unconditional stop condition.** A genuine live foreign run owns the issue. Stop and report. (issue #2766 — always pass `--run-id {run_id}` on Step 4's `next-skill` call so this row is only ever reached for a genuine rival, never your own lock.) |
+ discarded diagnostic here is a run that proceeds under an identity it does not hold. **Record the
+ `run_id`** from that payload and carry it through every subsequent step; re-runs reuse the
+ existing tracking session.
- **Self-identity check before standing down** (applies to the hand-off row above): `SUPERVISED_RUN_ACTIVE`
- fires only on a LIVE signal — a stale/expired one falls through to the orphaned-lock/foreign-holder
- rows instead. The **decisive** term is `owner_run_id ∈ {run_ids this run has held}`: a live signal
- carrying a run_id this run never held is a genuine concurrent rival — **stop and report**, even though
- the payload shape looks like a hand-off. `owner_session_id == sdlc-local-{issue_number}` is
- *necessary-but-not-sufficient* — ledger anchors are keyed by issue number, not by run, so a second
- concurrent `/do-sdlc` on the same issue emits a byte-identical `owner_session_id`. Compare `owner_run_id`
- explicitly; do not substitute `run_id` (the sibling field the tool also returns) for it. A match on
- `owner_run_id` is this run's own ghost (inherit-and-continue); a mismatch is a rival even when
- `owner_session_id` matches (stop-and-report). (see #2446, #2451)
- - **Recovery after run_id loss** (context compaction, restarted supervisor): re-run the `session-ensure` above. While the old lock is live it returns `ISSUE_LOCKED`; the lock frees within its TTL and a fresh contest then mints a new run_id (duration and renewal semantics are #2446's — do not restate a number here). **If you still have the run_id**, add `--reuse-run-id {run_id}` to recover immediately under the same identity — the tool verifies the claim against the live lock, or — on a free lock — against the session record or the issue's durable run-identity anchor, and never adopts an unverified one.
- - **Ledger-anchor rule:** `sdlc-local-{N}` is a non-executable ledger anchor (`is_ledger`, #2042), not
- a live executable session. It permanently shows `status=running` and carries the run's `_meta` stage
- state — it must **not** be killed, and a running-looking anchor is not evidence of a rogue pipeline.
+ The full ownership contract — which calls require `--run-id`, the three-way refusal decision
+ table, the self-identity check that keeps a run from standing down for its own lock, and recovery
+ after run_id loss — is in [Run Identity & Lock Ownership](RUN_IDENTITY.md). Read it now if
+ `session-ensure` returned a `blocked` payload, and re-read it at any later refusal.
## Step 3: Assess Current State
- Check what already exists for this issue. Use `$SDLC_TARGET_REPO` for local operations (defaults
- to `.` for same-repo work). Run ALL of these checks — do not skip any.
-
- **Command discipline (applies to every check in Steps 3-4):** run each check as a separate
- single-line command and read the output from the tool result — no pipes, no command substitution,
- no `||` fallbacks, no environment-variable capture. You interpret the output and decide the next
- step.
+ Check what already exists for this issue. Local operations run against the target repo path from
+ Step 2 (`.` for same-repo work). Run ALL of these checks — do not skip any.
- ### Step 3.0: Query stage_states from PipelineStateMachine (primary signal)
+ **Command discipline (Steps 3-4):** run each check as a separate single-line command and read the
+ output from the tool result — no pipes, no command substitution, no `||` fallbacks, no
+ environment-variable capture. You interpret the output and decide the next step.
- Query the PM session's `stage_states` for authoritative stage completion data. This is the
- **exclusive signal** for routing decisions. Stage completion is determined ONLY by stored state —
- never by artifact inference.
+ ### Step 3.0: Query stage state (primary signal)
- The tool resolves the active session from `VALOR_SESSION_ID`, `AGENT_SESSION_ID`, or
- `--issue-number` internally.
+ Stage completion is the **exclusive signal** for routing decisions, determined ONLY by stored
+ state — never by artifact inference.
```bash
sdlc-tool stage-query --issue-number {issue_number}
```
Interpret the JSON output from the tool result:
- - Non-empty object with stage keys (e.g. `{"ISSUE": "completed", "PLAN": "completed", "BUILD": "in_progress"}`): use it as the **exclusive signal** for the dispatch table. A stage is behind us ONLY if its value is `"completed"` — or `"skipped"`, which only PLAN and CRITIQUE can ever hold and which means the pipeline never dispatched that stage because this issue has no plan document (#2577, see [`docs/features/off-pipeline-merge-path.md`](../../../docs/features/off-pipeline-merge-path.md)). Never re-dispatch a skipped stage. Skip steps 3a-3e.
- - Empty `{}` or an `unavailable` marker: fall through to the dispatch-history fallback in steps 3a-3e. Do NOT infer stage completion from artifacts.
- ### Steps 3a-3e: Dispatch History Fallback
-
- These checks run ONLY when stage_states is unavailable (empty JSON from step 3.0). When
- stage_states IS available, skip directly to the dispatch table using stage_states as the source
- of truth.
-
- **IMPORTANT: Never infer stage completion from artifacts (plan files, PR existence, docs/ files,
- etc.). Stage completion is exclusively determined by stored state.**
+ - Non-empty object with stage keys (e.g. `{"ISSUE": "completed", "PLAN": "completed", "BUILD":
+ "in_progress"}`): use it as the exclusive signal for the dispatch table. A stage is behind us
+ ONLY if its value is `"completed"` — or `"skipped"`, which only PLAN and CRITIQUE can ever hold
+ and which means the pipeline never dispatched that stage because this issue has no plan
+ document. Never re-dispatch a skipped stage. Skip steps 3a-3e.
+ - Empty `{}` or an `unavailable` marker: fall through to the dispatch-history fallback below.
- When stage_states is unavailable, use conversation context to identify which skills were already
- dispatched in this session. Artifacts are used only to check preconditions (e.g., "does a PR
- exist?") — not to declare stages complete.
+ ### Steps 3a-3e: Dispatch History Fallback
- `$SDLC_TARGET_REPO` is exported by the harness so `git -C` picks it up without further shell
- composition; `gh` uses `$GH_REPO` automatically for the cross-repo case.
+ These run ONLY when stage state is unavailable. Use conversation context to identify which skills
+ were already dispatched in this session; artifacts check preconditions ("does a PR exist?") and
+ never declare a stage complete. `gh` uses `$GH_REPO` automatically for the cross-repo case.
```bash
# 3a. Check if a plan doc references this issue
grep -r "#{issue_number}" docs/plans/
```
```bash
# 3b. List all branches (filter for session/ prefix in the tool result)
- git -C "$SDLC_TARGET_REPO" branch -a
+ git -C "{target_repo_path}" branch -a
```
```bash
# 3c. Check if a PR already exists
gh pr list --repo <resolved> --search "#{issue_number}" --state open
# Cross-check with live refs — the --search index lags GitHub; --head queries live refs:
# gh pr list --repo <resolved> --head session/{slug} --state open (reuse if present; keyed by head branch, not issue #)
```
- If a PR exists, fetch its full state for assessment:
```bash
- # 3d. Get PR state: checks, review, branch
+ # 3d. If a PR exists, get its state: checks, review, branch
gh pr view {pr_number} --repo <resolved> --json number,headRefName,reviewDecision,statusCheckRollup,body
-
- # 3e. Check review status — look for APPROVED, CHANGES_REQUESTED, or no review
- # reviewDecision: "APPROVED" means formal GitHub review approved (non-self-authored PRs)
- # reviewDecision: "CHANGES_REQUESTED" means formal GitHub review requested changes
- # reviewDecision: "" (empty) — AMBIGUOUS for self-authored PRs:
- # - For non-self-authored PRs: no review posted yet
- # - For self-authored PRs: expected even after review — check _verdicts["REVIEW"] from sdlc_stage_query
- # Always cross-check _meta.latest_review_verdict before concluding no review exists.
```
+ 3e. Read `reviewDecision` from that output. `APPROVED` / `CHANGES_REQUESTED` are formal GitHub
+ outcomes. An empty value is **ambiguous**: on a non-self-authored PR no review is posted yet, but
+ on a self-authored PR it is expected even after a full review. Always cross-check the recorded
+ REVIEW verdict from Step 3.0 before concluding no review exists.
+
### Step 3.4: Check Documentation Status
- This step is REQUIRED when a PR exists and review is clean (APPROVED). Skip it only if the
- pipeline hasn't reached the REVIEW stage yet.
+ REQUIRED when a PR exists and review is clean (APPROVED); skip only if the pipeline hasn't
+ reached REVIEW yet.
```bash
# 3.4a. List files changed in the PR (count docs/ entries from the tool result)
gh pr diff {pr_number} --repo <resolved> --name-only
- ```
- ```bash
# 3.4b. Find the plan path for this issue (first match from the tool result)
grep -rl "#{issue_number}" docs/plans/
- ```
- ```bash
- # 3.4c. Read the plan's Documentation section (inspect for unchecked tasks in the tool result)
+ # 3.4c. Read the plan's Documentation section (inspect for unchecked tasks)
cat docs/plans/{plan-filename}.md
```
- For the DOCS stage completion check, re-read the `sdlc-tool stage-query` output from Step 3.0. Do
- not pipe JSON through a shell here.
+ For the DOCS stage completion check, re-read the Step 3.0 `stage-query` output. Do not pipe JSON
+ through a shell here.
**Decision logic for docs**:
- If the plan has a `## Documentation` section with unchecked tasks → docs NOT done
- If PR has zero `docs/` file changes AND plan requires doc tasks → docs NOT done
- If docs tasks are all checked AND `docs/` changes exist in PR → docs done
- When in doubt, dispatch `/do-docs` — it is idempotent and will no-op if nothing needs updating
## Step 3.5: Legal Dispatch Guards (reference)
`sdlc-tool next-skill` evaluates the G1–G9 guards itself — do NOT re-evaluate them by hand. The
table below exists so you can interpret a `blocked` decision or a forced dispatch when the tool
- returns one. Canonical implementation: `agent.sdlc_router.decide_next_dispatch()`; the parity
- test `tests/unit/test_sdlc_skill_md_parity.py` keeps this table in sync with the Python rules.
- Repo-specific guard nuances are declared in the repo context probe (`docs/sdlc/do-sdlc.md`).
+ returns one. The router's implementation, its ordering rationale, and the guard-by-guard nuances
+ are declared in the repo context probe.
Guards are evaluated in the **pinned `GUARDS` list order** `[G1, G2, G3, G4, G9, G8, G7, G5, G6]` — the first to return a non-`None` decision wins. Guard IDs are historical (assigned in introduction order), not evaluation order; the table below is listed in evaluation order:
| Guard | Condition | Forced Dispatch |
|-------|-----------|-----------------|
| G1: Critique loop | Latest critique verdict contains `NEEDS REVISION` or `MAJOR REWORK` AND `last_dispatched_skill == /do-plan-critique` | `/do-plan` |
| G2: Critique cycle cap | `critique_cycle_count >= MAX_CRITIQUE_CYCLES` (2) AND CRITIQUE is not completed | Escalate: `blocked` with reason `critique cycle cap reached` |
| G3: PR lock | `pr_number` is set AND (`last_dispatched_skill` OR proposed dispatch) is `/do-plan` or `/do-plan-critique` | `/do-merge` (if REVIEW and DOCS complete), `/do-patch` (if review requested changes), else `/do-pr-review` |
| G4: Oscillation (universal) | `same_stage_dispatch_count >= 3` | Escalate: `blocked` with reason `stage oscillation — {skill} dispatched {N} times without state change` |
| G9: Blocked-on-conflict | Recorded REVIEW verdict contains `BLOCKED_ON_CONFLICT` AND `pr_merge_state` not in the non-conflicting set (`CLEAN`, `HAS_HOOKS`, `UNSTABLE`, `BLOCKED`, `BEHIND`) AND the verdict is not stale (no `/do-patch` landed after it, #2796) | Escalate: `blocked` with reason naming the PR, the merge state, and the rebase — no SDLC skill resolves merge conflicts |
| G8: Stage-advance verification | `context["stage_artifacts_verified"] is False` (a claimed stage artifact — PR, branch, plan commit — failed live verification) | Re-dispatch the skill owning `context["unverified_stage"]` |
| G7: Plan-revising lock | `pr_number` is None AND `plan_revising == True` AND no `/do-plan` revision has landed since the latest CRITIQUE verdict (event-scoped, #2787 — NOT the sticky `revision_applied` boolean) | `/do-plan` (if `last_dispatched_skill == /do-plan-critique`); Escalate `blocked` (if no `/do-plan` in last `MAX_PLAN_REVISING_DISPATCHES + 1` turns) |
| G5: Unchanged critique artifact | `_verdicts["CRITIQUE"]` has `artifact_hash` AND current plan file hash matches | Use cached verdict: `/do-plan` (NEEDS REVISION) or `/do-build` (READY TO BUILD, no concerns). Never re-dispatch `/do-plan-critique` on an unchanged plan. **Steps aside unconditionally on `READY TO BUILD (with concerns)`** so rows 2b/4b/4c own that state (#2787); the bound there is `MAX_CONCERN_RECRITIQUE_ROUNDS`, not G5. |
| G6: Terminal merge ready | `pr_number` set AND `pr_merge_state == "CLEAN"` AND `ci_all_passing == True` AND `DOCS == "completed"` AND `_verdicts["REVIEW"]` contains `APPROVED` | `/do-merge {pr_number}` |
- **G4 is universal** — it applies to EVERY stage, including DOCS and MERGE. Repeated dispatches of
- `/do-docs` or `/do-merge` without state change WILL trip the guard.
-
- **G4 precedes G8 by design (issue #1267).** G8 re-dispatches the same stage's skill on a false
- artifact claim, with nothing upstream to stop it on a persistently false claim. Because G4 runs
- first, it fires and blocks once `same_stage_dispatch_count >= MAX_SAME_STAGE_DISPATCHES` before
- G8 gets another chance to re-dispatch — silent re-dispatch first, escalate via the existing G4
- cap second, not an immediate block on the first mismatch.
-
- **G9 (issue #2796).** A `BLOCKED_ON_CONFLICT` REVIEW verdict previously routed by accident — row 8
- sent it to `/do-patch` (which cannot rebase), row 8b sent it back to `/do-pr-review` (whose
- preflight re-recorded the same verdict) — ping-ponging until G4 escalated with a reason naming
- neither the conflict nor the rebase. G9 escalates immediately with a reason that names both. Its
- non-conflicting step-aside set (`CLEAN`, `HAS_HOOKS`, `UNSTABLE`, `BLOCKED`, `BEHIND`) mirrors
- `/do-pr-review`'s own preflight decision table exactly, so a PR that is merely `BEHIND` or
- `UNSTABLE` is never wrongly told it has merge conflicts. `tools/sdlc_stage_query.py::_fetch_pr_merge_state`
- retries once on a transient `UNKNOWN` read before G9 (or G6) ever sees the value.
-
- **G8 makes no live calls.** Live verification of claimed stage artifacts happens in the
- next-skill context-assembly path (`tools/sdlc_next_skill.py`), which sets
- `context["stage_artifacts_verified"]` / `context["unverified_stage"]`; G8
- (`agent.sdlc_router.guard_g8_artifact_verification`) only reads those flags. This keeps
- `agent/sdlc_router.py` import-free of `tools/` (see `tests/unit/test_architectural_constraints.py`).
- Absent/unset/`True` is a no-op, and a stage whose claimed artifact has no resolvable identifier
- (e.g. no recorded `pr_number`) is skipped rather than reported as a mismatch (#2757). The
- verifier's `git` checks run with `cwd=_target_repo_cwd()` (`SDLC_TARGET_REPO`, a filesystem path)
- so they inspect the SDLC target repo, not the process cwd — see the cwd-threading contract
- (#2078) in `docs/features/sdlc-router-oscillation-guard.md`.
-
- **G5 applies to CRITIQUE only**, not REVIEW. Review verdicts legitimately change on unchanged
- diffs (CI flips, new comments, linked issues). G4 handles REVIEW non-determinism instead.
-
- **G1 open-PR step-aside (#1932):** once `pr_number` is set, G1 no longer fires — it steps aside
- and defers to G3, the canonical open-PR plan-stage redirect. Without this, a NEEDS
- REVISION/MAJOR REWORK critique verdict recorded before the PR was opened could route a shipped PR
- back to `/do-plan`.
-
- **G5 open-PR step-aside (#1932):** on its NEEDS_REVISION/MAJOR_REWORK branch (cached critique
- verdict, unchanged plan hash), G5 also steps aside once `pr_number` is set and defers to G3
- instead of re-dispatching `/do-plan`. The READY_TO_BUILD branch already deferred on `pr_number`
- or `BUILD == completed`; this closes the same gap on the revision branch.
-
- **G7 blocks build while plan revision is in flight.** The lock is set by `/do-plan-critique`
- (Step 5.6) when the verdict requires a revision pass, cleared by `/do-plan` (Phase 4, Step 2b)
- after pushing the revision, and self-heals when `revision_applied: true` is in the plan
- frontmatter. Gated on `pr_number is None` so an already-shipped PR is never blocked.
+ **G4 is universal** — every stage, including DOCS and MERGE. Repeated dispatches without state
+ change WILL trip it. G4 also precedes G8, so a persistently false artifact claim is re-dispatched
+ silently at first and escalates via the G4 cap rather than blocking on the first mismatch.
- **G7 precedes G5 and G6 in list order (issue #1871).** G5's cached READY-TO-BUILD fast path does
- not itself read `plan_revising`, so G7 must run first to intercept a stale-hash cache hit while a
- revision is pending. The "an already-mergeable PR is never blocked by a stale `plan_revising`
- flag" guarantee does **not** come from list position relative to G6 — it comes from G7's own Gate
- 1 (`pr_number` set → return `None`). G6 only ever fires when `pr_number` is set, so in every
- state where G6 could dispatch `/do-merge`, G7 has already deferred at Gate 1; G6 always wins
- regardless of list position.
+ **G5 applies to CRITIQUE only**, not REVIEW — review verdicts legitimately change on unchanged
+ diffs (CI flips, new comments). G4 handles REVIEW non-determinism instead.
- **Convergence latch — `revision_applied_at` (issue #1760).** `revision_applied` is sticky:
- `/do-plan` sets it `true` on every revision pass and it never resets, so it can't tell "this is
- the settle-and-build revision the critique verdict judged" apart from "a later, unrelated
- `/do-plan` dispatch". `/do-plan` Phase 4 Step 2a now also writes an event-scoped
- `revision_applied_at: <ISO-8601 UTC timestamp>` in the SAME step as `revision_applied: true`
- (never a follow-up edit). `agent.sdlc_router._critique_verdict_is_stale()` uses it as a latch: a
- `/do-plan` dispatch at or before `revision_applied_at` is treated as converged (not stale); one
- that postdates it re-stales normally, so a later unrelated revision never gets a free pass to
- BUILD. Absent/unparseable `revision_applied_at` leaves the latch inert (fail-safe to pre-#1760
- timestamp-only staleness). **Verdict-kind gate (#2049):** the latch engages only for verdicts that
- do not require a revision (the settle-and-build READY TO BUILD path); for NEEDS REVISION / MAJOR
- REWORK the settled revision *invalidates* the verdict — it stays stale and row 2b routes to
- `/do-plan-critique`, never back to `/do-plan`. **With-concerns scope (#2787):** on the `READY TO
- BUILD (with concerns)` path a bounded branch runs ahead of the latch — below
- `MAX_CONCERN_RECRITIQUE_ROUNDS` the concern-closing revision re-stales the verdict so row 2b
- re-critiques it; at the bound the latch engages and row 4c builds with the residual concerns
- recorded as accepted. See [With-Concerns Re-Critique Gate](../../../docs/features/with-concerns-recritique-gate.md)
- and [SDLC Pipeline — Convergence Latch](../../../docs/features/sdlc-pipeline.md#convergence-latch-revision_applied_at-issue-1760)
- for the full mechanism.
+ **Open-PR step-asides.** Once `pr_number` is set, G1 and G5's revision branch defer to G3, the
+ canonical open-PR plan-stage redirect — a stale pre-PR critique verdict must never route a shipped
+ PR back to `/do-plan`. G7 is likewise gated on `pr_number is None`.
- **ISSUE_LOCKED (not a G-guard, issues #1954/#2003):** `sdlc-tool next-skill` checks the
- issue-level ownership lock *before* evaluating G1-G9, and short-circuits to
- `{"blocked": true, "reason": "ISSUE_LOCKED", "owner_run_id": ..., "owner_session_id": ...,
- "orphaned_lock": ...}` if a foreign run holds the lock for this issue. Ownership is keyed by
- `run_id` (minted only by `session-ensure`, carried via `--run-id`), never by session_id or
- process identity. `ensure_session` surfaces the same `{"blocked": true, ...}` shape at its own
- call site. `dispatch record`'s CLI wrapper surfaces the lock differently: on a failed write it
- peeks the lock and, if contention caused the failure, merges `reason`/`owner_run_id`/
- `owner_session_id` into its existing `{"ok": false, "history_length": N}` result (never
- `blocked`) — see `_cli_record()` in `tools/sdlc_dispatch.py`. `orphaned_lock: true` means the
- owning run died before its next renewal — the lock frees itself within the lease TTL
- (`ISSUE_LOCK_TTL_SECONDS`, default 30 min; the happy path releases it immediately at run end).
- **The signal is renewal freshness, not process liveness (issue #2620):** the lease payload's
- `pid` belongs to the ephemeral `session-ensure` CLI and is dead within seconds of acquire, so
- `_lock_owner_is_live` keys on the `renewed_at` stamp that every renewal tick refreshes. A
- recently-renewed lease is a LIVE owner (`orphaned_lock: false` → stop), and only a lease nobody
- has renewed for `ISSUE_LOCK_RENEWAL_FRESHNESS_SECONDS` (default 20 min) reads orphaned.
- **Self-owned continue path:** if `owner_run_id` is a `run_id` this run has already held (minted
- at the start of the run, or inherited from the supervisor per Step 2), the lock is YOURS — this
- is not a block. Continue the stage under that run_id (a bare `session-ensure` under the live
- supervised-run signal returns `SUPERVISED_RUN_ACTIVE` carrying the same run_id — inherit it, per
- Step 2). Only a FOREIGN `owner_run_id` is a hard block: surface the `reason` and `owner_run_id`
- to the human and stop; do not loop, do not attempt to route around it.
+ **G7 blocks build while plan revision is in flight.** The lock is set by `/do-plan-critique` when
+ the verdict requires a revision pass, cleared by `/do-plan` after pushing the revision, and
+ self-heals from the plan frontmatter. `/do-plan` also records an event-scoped timestamp alongside
+ its revision-applied flag, so the router can tell the settle-and-build revision a critique verdict
+ judged apart from a later unrelated one — that convergence latch engages only for verdicts that do
+ not require a revision; NEEDS REVISION / MAJOR REWORK stay stale and route back to
+ `/do-plan-critique`.
- **Known gap — stale REVIEW verdict after PATCH (issue #1932 / PR #1941):** G3 and G6 above key
- off `_verdicts["REVIEW"]` containing `APPROVED`, not off whether that verdict was recorded
- *after* the most recent PATCH commit. Before PR #1941's router fix (and for any similar gap not
- yet caught), `next-skill` can propose `/do-merge` on a stale pre-patch `APPROVED`/`CHANGES
- REQUESTED` verdict because nothing forces a fresh `/do-pr-review` after `/do-patch` resolves
- REVIEW findings. Before trusting a router-proposed `/do-merge`, verify with `sdlc-tool verdict
- get --stage REVIEW --issue-number {N}` that the recorded verdict is `APPROVED` and postdates the
- patch commit; if not, manually dispatch `/do-pr-review` first.
+ **ISSUE_LOCKED is not a G-guard.** `next-skill` checks the issue-level ownership lock *before*
+ evaluating G1–G9 and short-circuits on it. See [Run Identity & Lock Ownership](RUN_IDENTITY.md).
- **Row 10 merge gate:** `/do-merge` fires only when REVIEW and DOCS are complete, the PR merge
- state is CLEAN, CI is all-passing, and the recorded REVIEW verdict is APPROVED at the current
- head.
+ **Known gap — stale REVIEW verdict after PATCH.** G3 and G6 key off the recorded REVIEW verdict
+ containing `APPROVED`, not off whether it postdates the most recent PATCH commit. Before trusting
+ a router-proposed `/do-merge`, confirm with `sdlc-tool verdict get --stage REVIEW --issue-number
+ {N}`; if the verdict predates the patch, dispatch `/do-pr-review` first.
+ **Merge gate:** `/do-merge` fires only when REVIEW and DOCS are complete, the PR merge state is
+ CLEAN, CI is all-passing, and the recorded REVIEW verdict is APPROVED at the current head.
## Step 4: Dispatch ONE Sub-Skill
- **Do not pattern-match against a hand-edited table.** Instead, call the routing tool and dispatch
- whatever skill it returns. The tool evaluates all guards (G1–G9) and dispatch rules (19 rows)
- against live state. In router mode this is the whole job: call `next-skill`, record the dispatch,
- invoke the ONE returned skill, then **return**. In supervisor mode the same call feeds Step 5's
- loop.
+ **Do not pattern-match against a hand-edited table.** Call the routing tool and dispatch whatever
+ skill it returns; it evaluates every guard and dispatch row against live state. In router mode
+ this is the whole job: call `next-skill`, record the dispatch, invoke the ONE returned skill, then
+ **return**. In supervisor mode the same call feeds Step 5's loop.
```bash
# Get the next dispatch decision
sdlc-tool next-skill --issue-number {issue_number} --run-id {run_id}
```
- The tool dispatches at most ONE skill per call. It outputs JSON in one of these shapes:
-
- Single dispatch:
- ```json
- {"skill": "/do-build", "reason": "...", "row_id": "4a", "dispatched": true}
- ```
+ The tool decides at most ONE skill per call, outputting JSON in one of these shapes. Every shape
+ carries `decision`:
- Blocked:
```json
- {"blocked": true, "reason": "G4: stage oscillation ...", "guard_id": "G4"}
+ {"skill": "/do-build", "reason": "...", "row_id": "4a", "decision": "dispatch", "recorded": false, "recorded_reason": "NOT_PERSISTED_CALL_DISPATCH_RECORD"}
+ {"blocked": true, "decision": "blocked", "reason": "G4: stage oscillation ...", "guard_id": "G4"}
+ {"blocked": true, "decision": "blocked", "reason": "ISSUE_LOCKED", "owner_run_id": "...", "owner_session_id": "...", "orphaned_lock": false}
```
- Blocked (issue-level ownership lock -- not a G-guard, see Step 3.6):
- ```json
- {"blocked": true, "reason": "ISSUE_LOCKED", "owner_run_id": "...", "owner_session_id": "...", "orphaned_lock": false}
- ```
+ `next-skill` **writes nothing** — with or without `--run-id`. `recorded: false` is on every
+ dispatch decision to say so out loud: the ledger has NOT advanced until you make the separate
+ `sdlc-tool dispatch record` call below. Skipping it leaves the router re-deriving from a history
+ that never grew, so it re-decides the same row until G4 blocks the lane for oscillating (#2897).
**How to use the output:**
- 1. If `dispatched` is `true`: record the dispatch via `sdlc-tool dispatch record` (see below),
+ 1. If `decision` is `"dispatch"`: record the dispatch via `sdlc-tool dispatch record` (see below),
then invoke the returned `skill`.
- 2. If `blocked` is `true`: surface the `reason` to the human and wait. Do NOT loop or guess an
- alternative skill. This applies identically whether the block came from a G1-G9 guard or from
- `reason: "ISSUE_LOCKED"` (another live session already owns this issue) -- report
- `owner_session_id` to the human, do not loop, do not attempt to route around it.
- 3. If neither key is present (error): log the `error` field and escalate to the human.
+ 2. If `decision` is `"blocked"`: surface the `reason` to the human and wait. Do NOT loop or guess an
+ alternative skill — this holds identically for a G-guard block and for `ISSUE_LOCKED`, where
+ you also report `owner_session_id`.
+ 3. If `decision` is `"error"`: log the `error` field and escalate to the human.
**Before recording and dispatching**, also supply `--proposed-skill` when you already know what
skill you intend to invoke (enables G3 PR-lock detection):
```bash
sdlc-tool next-skill --issue-number {issue_number} --run-id {run_id} --proposed-skill /do-build
```
Record every dispatch decision via `sdlc-tool dispatch record` BEFORE invoking the sub-skill —
this preserves the G4 oscillation signal even if the sub-skill crashes mid-execution.
```bash
- # Record a dispatch event (call BEFORE invoking the sub-skill)
sdlc-tool dispatch record --skill /do-build --issue-number {issue_number} --run-id {run_id}
-
- # Record with PR context (for review/patch/merge stages)
- sdlc-tool dispatch record --skill /do-pr-review --issue-number {issue_number} --pr-number {pr_number} --run-id {run_id}
-
- # Inspect the dispatch history (debug G4 state; read-only, no --run-id)
+ # add --pr-number {pr_number} for review/patch/merge stages
+ # inspect history (debug G4 state; read-only, no --run-id):
sdlc-tool dispatch get --issue-number {issue_number}
```
- The CLI wraps `agent.sdlc_router.record_dispatch()` and `tools.stage_states_helpers.update_stage_states()` — it is the correct runtime entry point. Never call `record_dispatch()` directly from a shell or skill script; always use `sdlc-tool dispatch record`.
+ `sdlc-tool dispatch record` is the only correct runtime entry point — never reach past it into
+ the router's internals from a shell or skill script.
Do NOT restart from scratch if prior stages are already complete.
## Step 5: Supervision Loop (supervisor mode only)
- Repeat the following cycle. **Iteration cap: 15 dispatches** (a happy path is 8 stages; the cap
- is a backstop above realistic patch/re-review cycles — G4 catches genuine oscillation long before
- it). Router mode never enters this step — it returns after Step 4.
+ Repeat the following cycle. **Iteration cap: 15 dispatches** (a happy path is 8 stages; the cap is
+ a backstop above realistic patch/re-review cycles — G4 catches genuine oscillation long before
+ it). Router mode never enters this step; it returns after Step 4.
### 5a. Ask the router
```bash
sdlc-tool next-skill --issue-number {issue_number} --run-id {run_id}
```
- (Read-only — `next-skill` never mints, adopts, or renews the lock. `--run-id` is a read-only
- identity assertion: it peeks under the caller's own stated identity so this run is never told to
- stand down for a lock it holds itself, issue #2766. Always pass it here.)
+ (Read-only — `next-skill` never mints, adopts, or renews the lock. `--run-id` is an identity
+ assertion for its lock peek, so this run is never told to stand down for a lock it holds itself.
+ Always pass it here.)
Interpret the JSON from the tool result (same shapes as Step 4):
- - `{"blocked": true, "reason": "ISSUE_LOCKED", ...}` → the payload additionally carries
- `peek_identity` (`"caller"` | `"session_mirror"` | `"unresolved"`) and, only when `--run-id` was
- supplied and did not match the live lock, `session_mirror_run_id`. Both are **diagnostics only**
- — they never override the block and there is no automatic re-ensure/retry keyed on them.
- `peek_identity: "unresolved"` means the block is *inconclusive* (the fallback session lookup
- missed) — report it to the human as such and stop, exactly like any other block; do not retry.
- Apply the self-identity check below to the row's `owner_run_id` before deciding whether this is
- actually your own lock.
+ - `{"blocked": true, "reason": "ISSUE_LOCKED", ...}` → route it through
+ [Run Identity & Lock Ownership](RUN_IDENTITY.md) before deciding whether this is actually your
+ own lock. Only a genuinely foreign owner stops the loop.
- `{"blocked": true, ...}` (other reasons) → **STOP the loop.** Report the `reason` and `guard_id` to the human, plus a summary of stages completed so far. Do not retry, do not guess an alternative skill.
- - `{"skill": "...", "dispatched": true, ...}` → continue to 5b. The router dispatches at most ONE skill per call; there is no parallel-pair shape.
+ - `{"skill": "...", "decision": "dispatch", "recorded": false, ...}` → continue to 5b, which is what actually writes the dispatch. The router decides at most ONE skill per call; there is no parallel-pair shape.
- Anything else (error key, empty) → STOP and surface the error.
### 5b. Record the dispatch
```bash
sdlc-tool dispatch record --skill {skill} --issue-number {issue_number} --run-id {run_id}
# include --pr-number {pr} once a PR exists (review/patch/docs/merge stages)
```
### 5c. Spawn the stage subagent
- **One writer per artifact — enumerate live children before you dispatch.** Every stage you
- dispatch is a writer on a shared artifact, and the plan doc in particular is a single file on the
- shared main checkout that no worktree isolates. Before spawning, account for the children you
- already have out: if a child is still holding the plan doc, do not dispatch a second one onto it,
- and do not edit it yourself while it does. Wait for the outstanding child to report, then
- dispatch.
-
- This is not hypothetical. On 2026-08-07 one plan doc took two concurrent revision children twice
- over (#2650, shape 3) — a writer watched its line count grow from 62 to 111 between its own
- reads and had to stop and ask who owned the file — and a supervisor patched a plan task while its
- own dispatched child held the doc, then had to warn the child mid-flight to re-read before
- committing (shape 4). Both were recovered only because an agent happened to notice the file
- moving underneath it.
-
- Since Hard Rule 6 already requires `run_in_background: false`, the ordinary loop has exactly one
- child out at a time and satisfies this for free. The rule bites when you are tempted to fan out,
- or to "just fix one line" in a doc a child is revising. Neither is safe; the second is the one
- that feels harmless.
+ **One writer per artifact — enumerate live children before you dispatch.** Every stage you dispatch
+ is a writer on a shared artifact, and the plan doc in particular is a single file no worktree
+ isolates. If a child is still holding it, do not dispatch a second one onto it and do not edit it
+ yourself; wait for the outstanding child to report. Hard Rule 6 already keeps exactly one child out
+ at a time, so the ordinary loop satisfies this for free — the rule bites when you are tempted to
+ fan out, or to "just fix one line" in a doc a child is revising.
Use the Agent tool (general-purpose), with `model:` from the Stage→Model table and
**`run_in_background: false`** (Hard Rule 6 — this fork cannot be resumed by a background
notification). Prompt template:
```
You are executing ONE SDLC stage for issue #{issue_number} in {repo_path}.
Invoke the Skill tool now: skill "{skill-name-without-slash}", args "{issue_number / pr_number / slug as the skill expects}".
The skill is the procedure — follow it exactly. Do not improvise the stage yourself.
Context:
- Issue: #{issue_number} — {title}
- PR: {#pr or "none yet"}
- Plan: {docs/plans/{slug}.md or "none yet"}
- Prior stage outcome: {one-line summary, or "None — first stage"}
- Run identity: {run_id} — pass --run-id {run_id} on every state-mutating sdlc-tool call (stage-marker, verdict record, meta-set, dispatch record) and on next-skill (a read-only identity assertion for its lock peek, issue #2766); stage-query/verdict get/dispatch get take none.
- Commit early: commit to session/{slug} as work lands (small logical checkpoints), not only at the end — a preempt or lease lapse mid-stage must never lose work.
When done, report back (this is data for the supervisor, not prose for a human):
- outcome: success | failure
- verdict: any verdict string the skill emitted (READY TO BUILD / NEEDS REVISION / APPROVED / CHANGES REQUESTED / ...)
- artifacts: plan path, PR number, branch name — whatever was created or changed
- failures: test failures, blockers, or errors verbatim if any
```
Carry forward context between iterations: the `run_id` goes into every stage prompt, and once
BUILD reports a PR number, include it in every subsequent prompt and in `dispatch record
--pr-number`.
### 5d. Backfill stage markers (TEST and PATCH only)
- `/do-test` and `/do-patch` do not write their own stage markers — on the bridge, the worker's
- dev-completion handler does it. Locally, the supervisor must:
+ `/do-test` and `/do-patch` do not write their own stage markers, so the supervisor must:
```bash
sdlc-tool stage-marker --stage TEST --status completed --issue-number {issue_number} --run-id {run_id}
# or --status failed, per the subagent's report
```
All other stage skills self-mark; do NOT double-write markers for them.
- A non-zero exit means the marker did **not** land — the ledger does not say what
- you think it says. Read the stderr diagnostic and route it:
+ A non-zero exit means the marker did **not** land — the ledger does not say what you think it
+ says. Read the stderr diagnostic and route it:
- - `ISSUE_LOCKED` naming an `owner_run_id` that is **not** yours → a foreign run
- owns this issue. **Stop the loop** and report; continuing writes nothing and
- loses the run.
- - `LEASE_ABSENT`, or `ISSUE_LOCKED` naming an id you recognize as your own →
- your lease lapsed. Run the Step 5e.6 re-ensure, **adopt the `run_id` it
- returns**, and retry the marker once under the adopted id.
- - Anything else (a Redis/broker error, a timeout) → transient. Retry once, and
- only report-and-continue if it persists. A transient error is never an
- unconditional pipeline abort.
+ - `ISSUE_LOCKED` naming a foreign `owner_run_id` → **stop the loop** and report; continuing writes
+ nothing and loses the run.
+ - `LEASE_ABSENT`, or `ISSUE_LOCKED` naming an id you recognize as your own → your lease lapsed.
+ Run the Step 5d.6 re-ensure, **adopt the `run_id` it returns**, and retry the marker once.
+ - Anything else (a broker error, a timeout) → transient. Retry once; report-and-continue only if
+ it persists. A transient error is never an unconditional pipeline abort.
- ### 5d.4. REVIEW self-check gate (issue #2193)
+ ### 5d.4. REVIEW self-check gate
- If the stage just dispatched in 5c was `/do-pr-review`, do not treat its
- return as sufficient to advance. `/do-pr-review` should have already called
- `sdlc-tool verdict finalize` internally (an atomic verdict+trailer+marker
- write) before returning — but the supervisor is the loud, committed backstop
- for that contract, not a formality. Call the read-only self-check yourself:
+ If the stage just dispatched in 5c was `/do-pr-review`, do not treat its return as sufficient to
+ advance. It should have already finalized its verdict internally (an atomic verdict+trailer+marker
+ write), but the supervisor is the loud backstop for that contract. Call the read-only self-check
+ yourself:
```bash
sdlc-tool verdict selfcheck --pr {pr_number} --issue-number {issue_number}
```
- Interpret the typed JSON result (`{ok, verdict_present, trailer_matches_head,
- marker_completed, reason}`):
-
- - `{"ok": true, ...}` → the verdict, its freshness trailer, and the REVIEW
- completion marker are all confirmed present. Proceed to 5e / loop back to
- 5a as normal.
- - `{"ok": false, ...}` → **HALT the loop immediately.** Print the machine-readable
- `reason` field loudly to the operator (e.g. "REVIEW SELF-CHECK FAILED:
- reason={reason} — pr={pr_number} issue={issue_number}"), along with which of
- `verdict_present` / `trailer_matches_head` / `marker_completed` is false.
- Do NOT re-dispatch REVIEW yourself, do NOT advance to DOCS/MERGE, and do NOT
- silently loop back to the router — this is a single loud refusal, replacing
- the old failure mode where the router would silently re-dispatch REVIEW
- forever on the same missing state. Report this as a stop condition in Step 6
- (Final Report) just like a `blocked` router decision.
+ Interpret the typed JSON (`{ok, verdict_present, trailer_matches_head, marker_completed, reason}`):
- This gate is prose/logic in this skill body only — it does not modify
- `agent/sdlc_router.py`'s dispatch rows (those already fail-closed and
- re-dispatch on missing state; see the router's rows 8/8b/9). It exists
- specifically to make the supervised `/do-sdlc` path *loud* on the exact
- failure the router would otherwise handle silently over multiple iterations.
+ - `{"ok": true, ...}` → verdict, freshness trailer, and REVIEW completion marker are all present.
+ Proceed as normal.
+ - `{"ok": false, ...}` → **HALT the loop immediately.** Print `reason` loudly to the operator along
+ with which of `verdict_present` / `trailer_matches_head` / `marker_completed` is false. Do NOT
+ re-dispatch REVIEW, advance to DOCS/MERGE, or silently loop back to the router — this is a single
+ loud refusal. Report it as a stop condition in Step 6, like a `blocked` router decision.
### 5d.5. Tool-availability mismatch guard (issue #2022)
- Inspect every stage subagent's final report before acting on it. If the final message is (or
- begins with) a **bare shell command** — it starts with `git `, `gh `, `cd `, `pytest`, `python
- `, or otherwise reads as a command line rather than the outcome/verdict/artifacts report the
- prompt template asks for — AND the child made **zero tool calls**, the child was spawned on an
- agent type without the tools its first step needed: it emitted the command it could not run as
- plain text. Treat this as a **tool-availability mismatch, never a normal completion**:
+ Inspect every stage subagent's final report before acting on it. If the final message reads as a
+ **bare shell command** (`git `, `gh `, `cd `, `pytest`) rather than the outcome/verdict/artifacts
+ report the prompt template asks for — AND the child made **zero tool calls** — it was spawned on
+ an agent type lacking the tools its first step needed. This is a **tool-availability mismatch,
+ never a normal completion**:
1. Log it: "TOOL-AVAILABILITY MISMATCH: stage={skill}, final message is a bare shell command with zero tool calls"
2. Re-dispatch the same stage once on a Bash-capable agent type (`general-purpose`)
3. If the re-dispatch shows the same signature, stop and surface the mismatch to the human — do not loop
- ### 5d.6. Between-stage continuity re-ensure (issue #2452)
+ ### 5d.6. Between-stage continuity re-ensure
- After the stage subagent returns (5c/5d/5d.4/5d.5) and before asking the router again (5a), re-ensure
- this run's identity:
+ After the stage subagent returns and before asking the router again (5a), re-ensure this run's
+ identity:
```bash
sdlc-tool session-ensure --issue-number {issue_number} --reuse-run-id {run_id}
```
- Do **not** append a stderr redirect to `/dev/null`, a trailing `|| true`, or any other form that
- discards the diagnostic.
- The whole point of this step is the payload; a form that destroys it makes every instruction below
- unfollowable and lets the run continue under an identity it has silently lost.
-
- This is a **continuity proof** — the tool verifies the held `run_id` against the live lock/session
- record and against the durable issue-keyed run-identity anchor on the ledger — not a lease
- keepalive; it does not renew or extend the TTL (that is the heartbeat's job, issue #2714).
-
- **Adopt the returned `run_id`.** When the payload carries no `blocked` flag, read `run_id` out of it
- and use **that** value as `{run_id}` for every subsequent stage, prompt and `--run-id` flag in this
- run. It may differ from the one you carried in: a lapsed lease is rebound, and a fresh contest mints
- a new identity. Keeping your own stale copy is precisely how a run ends up writing markers nobody
- accepts.
-
- **Branch on the payload, not the exit code.** `session-ensure` exits 0 on every outcome it can
- report — success and refusal alike — and signals refusal as `{"blocked": true, "reason": ...}` on
- stdout, with the human-readable diagnostic on stderr. (Its sibling `stage-marker` *does* exit
- non-zero — do not generalize one tool's disposition to the other.) A non-zero exit here is a
- wrapper or usage error, emits **no payload at all**, and is not recoverable: stop and report rather
- than retrying. On a `blocked` payload, route it through the **same** three-way table and
- `owner_run_id` self-identity check from Step 2, so the own-ghost abandonment bug does not simply
- relocate from the top of the loop to this stage seam:
-
- - **Foreign owner** — `ISSUE_LOCKED` whose `owner_run_id` is neither yours nor a self-identity you
- recognize: this run has genuinely lost the issue. **Stop the loop** and report. This is the one
- stop condition.
- - **Self / hand-off** — `SUPERVISED_RUN_ACTIVE`, or an `owner_run_id` the self-identity check
- confirms is your own: **inherit** `owner_run_id` as `{run_id}` and continue.
- - **Orphaned lock** — `orphaned_lock: true`: wait out the TTL, re-ensure, adopt what comes back.
- - **Transient** — a Redis/broker error, a timeout, or any payload that is none of the above:
- **surface it and retry**, then continue. Never convert a transient error into a pipeline abort —
- halting a healthy run on a broker blip is worse than the bug this step exists to catch. A payload
- that parses but carries neither `run_id` nor `blocked` belongs here too. **Empty stdout is not
- transient** — that is the wrapper/usage error above, and retrying it forever is how a broken
- install turns into an identity-less run.
+ Never discard its stderr or stdout — the payload is the whole point of the step. **Adopt the
+ `run_id` it returns**, which may differ from the one you carried in, and branch on the payload
+ rather than the exit code. [Run Identity & Lock Ownership](RUN_IDENTITY.md) has the full
+ disposition, including the transient-error class that must never become a pipeline abort.
### 5e. Check exit conditions
- Dispatched skill was `/do-merge` AND the subagent reports a merge → verify with `gh pr view {pr} --repo <resolved> --json state,mergedAt` from the tool result. If `MERGED`: **exit the loop, success.**
- Router returned `blocked` → already stopped in 5a.
- Iteration cap reached → stop and report how far the pipeline got.
- Otherwise → loop back to 5a. Brief one-line progress note per iteration (e.g. "CRITIQUE done (READY TO BUILD) → dispatching BUILD on sonnet").
## Step 6: Final Report
On exit (any path), report:
1. **Outcome**: merged / blocked (with guard + reason) / cap reached
2. **Stage trail**: each dispatch in order with its outcome and verdict
3. **Artifacts**: issue, plan path, PR number, merge commit
4. **Anything needing human attention**: unresolved blockers, skipped acknowledgments, follow-ups
## Step 7: Release the run lease
- You hold this issue's run lease for as long as this supervision loop lives, and
- nothing reclaims it when you simply stop — there is no terminal transition on a
- HALT. Left held, it makes the *next* run on this issue refuse with a foreign-owner
- block until the lease's ceiling lapses hours later.
-
- So after the Final Report, hand the lease back with the pipeline tool's
- **`session-release`** subcommand, passing this run's issue number and `run_id`
- (see the repo context probe for the exact invocation). It is ownership-checked
- and best-effort: a wrong or already-released `run_id` is a safe no-op, so run it
- whenever in doubt and never let its output change your reported outcome.
-
- Do this on the exits nothing else observes:
-
- - the **5d.4 REVIEW self-check HALT**
- - a **`blocked`** router decision (5a / 5e)
- - the **iteration cap** being reached
-
- The **merged** exit needs no action from you — completing the MERGE stage
- releases the lease in the tool layer, on the marker write itself. Running the
- step there anyway is harmless (it reports no lease held), but it is not what
- makes the merged path correct.
-
- ## Relationship to /sdlc (in this repo)
-
- | | `/sdlc` (in this repo) | `/do-sdlc` |
- |---|---|---|
- | Contract | dispatch ONE stage, return | loop until merge/blocked |
- | Progression | PM session re-invokes | this skill re-invokes the router |
- | Model assignment | PM passes `--model` when spawning dev sessions | supervisor passes `model:` on the Agent tool |
- | Where it runs | bridge PM sessions + local | local Claude Code sessions |
+ You hold this issue's run lease for as long as this supervision loop lives, and nothing reclaims
+ it when you simply stop — there is no terminal transition on a HALT. Left held, it makes the
+ *next* run on this issue refuse with a foreign-owner block until the lease's ceiling lapses.
+ After the Final Report, hand the lease back with the pipeline tool's **`session-release`**
+ subcommand, passing this run's issue number and current `run_id` (the repo context probe declares
+ the exact invocation). It is ownership-checked and best-effort: a wrong or already-released
+ `run_id` is a safe no-op, so run it whenever in doubt, and never let its output change your
+ reported outcome.
- `/sdlc` is a thin `context: fork` shim over this skill's router mode: it reads this body's Step
- 1–4 (resolve → session-ensure → assess → dispatch ONE), executes them once, and returns. Both
- entry points consume the same router (`sdlc-tool next-skill` → `agent.sdlc_router.decide_next_dispatch()`)
- and the same stored stage state — there is exactly one source of dispatch truth.
+ Do this on the exits nothing else observes: the **5d.4 REVIEW self-check HALT**, a **`blocked`**
+ router decision (5a / 5e), and the **iteration cap** being reached. The **merged** exit needs no
+ action from you — completing the MERGE stage releases the lease in the tool layer, on the marker
+ write itself.