do-sdlc · diff

git:20260819.e4f8e6e to git:20260822.f376ae3

466 added, 58 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'."
+ 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 — Local Pipeline Supervisor
+ # do-sdlc — SDLC Pipeline Supervisor and Single-Stage Router
- This skill is the **local stand-in for the bridge PM session**. `/sdlc` (in this repo) is a single-stage router by contract: it dispatches ONE sub-skill and returns, expecting a PM session to re-invoke it. In a local Claude Code session there is no PM loop — this skill IS that loop: 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.
+ This skill is the **one substantive SDLC skill**. It has two entry modes over one shared step
+ spine:
- You are the supervisor, not the worker. You assess, dispatch, and track. The stage subagents do all the work.
+ | 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 |
+ | **Supervisor mode** | `/do-sdlc` | Steps 1–7 | loop until merge/blocked |
- **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 `/do-sdlc` 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.
+ 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
+ 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.
+
## 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`.
+ 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.
## 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.
- 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 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.
- 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.
+ 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).
+ 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
+ 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.
## 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 `tools/lane_identity.py::resolve_lane_slug` — this is the single source of truth (`worktree_manager.py` + `resolve_branch_for_stage`; see [`docs/features/sdlc-lane-identity.md`](../../../docs/features/sdlc-lane-identity.md) in repos that have it). 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).
+ **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).
## Stage→Model Dispatch Table
- Mirrors the engineer persona's table (`config/personas/engineer.md`) — the local equivalent of `valor-session create --model`.
+ 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
+ ## Step 1: Resolve the Issue or PR
- Same resolution as `/sdlc` (in this repo) Step 1:
+ 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>`.
- - **Issue reference** (`208`, `issue #208`): `gh issue view {number}`
- - **PR reference** (`PR 363`): `gh pr view {number} --json number,title,state,headRefName,reviewDecision,statusCheckRollup,body` and extract the linked issue number from the body (`Closes #N` / `Fixes #N`)
- - **Bare feature description** (no number): dispatch a `/do-issue` stage subagent first (sonnet), read the created issue number from its report, then proceed.
+ - **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.
- Do not proceed without an issue number.
+ **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.
## 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 3 passes it back explicitly via
+ # 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.
sdlc-tool session-ensure --issue-number {issue_number} --issue-url "https://github.com/$SDLC_REPO/issues/{issue_number}"
```
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 iteration of the Step 3 loop. Reuses the existing `sdlc-local-{N}` session on re-runs. The ownership contract:
+ 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 3's between-stage continuity check) can
+ `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 3a's `next-skill` call so this row is only ever reached for a genuine rival, never your own lock.) |
+ | **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.) |
**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.
- ## Step 3: Supervision Loop
+ ## Step 3: Assess Current State
- 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).
+ 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.
- ### 3a. Ask the router
+ **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.
+ ### Step 3.0: Query stage_states from PipelineStateMachine (primary signal)
+
+ 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.
+
+ The tool resolves the active session from `VALOR_SESSION_ID`, `AGENT_SESSION_ID`, or
+ `--issue-number` internally.
+
```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.**
+
+ 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.
+
+ `$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.
+
+ ```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
+ ```
+
+ ```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
+ 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.
+ ```
+
+ ### 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.
+
+ ```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)
+ 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.
+
+ **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`).
+
+ 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.
+
+ **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.
+
+ **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.
+
+ **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.
+
+ **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.
+
+ **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.
+
+
+ ## 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.
+
+ ```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}
+ ```
+
+ Blocked:
+ ```json
+ {"blocked": true, "reason": "G4: stage oscillation ...", "guard_id": "G4"}
+ ```
+
+ 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}
+ ```
+
+ **How to use the output:**
+ 1. If `dispatched` is `true`: 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.
+
+ **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)
+ 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`.
+
+ 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.
+
+ ### 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.)
- Interpret the JSON from the tool result:
+ 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, ...}` (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 3b. The router dispatches at most ONE skill per call; there is no parallel-pair shape.
+ - `{"skill": "...", "dispatched": true, ...}` → continue to 5b. The router dispatches at most ONE skill per call; there is no parallel-pair shape.
- Anything else (error key, empty) → STOP and surface the error.
- ### 3b. Record the dispatch
+ ### 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)
```
- ### 3c. Spawn the stage subagent
+ ### 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.
+ **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.
+ 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.
+ 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.
- 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:
+ 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`.
+ 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`.
- ### 3d. Backfill stage markers (TEST and PATCH only)
+ ### 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 — on the bridge, the worker's
+ dev-completion handler does it. Locally, 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:
- `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 3d.6 re-ensure, **adopt the `run_id` it
+ 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.
- ### 3d.4. REVIEW self-check gate (issue #2193)
+ ### 5d.4. REVIEW self-check gate (issue #2193)
- If the stage just dispatched in 3c was `/do-pr-review`, do not treat its
+ 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:
```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 3e / loop back to
- 3a as normal.
+ 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 4
+ forever on the same missing state. Report this as a stop condition in Step 6
(Final Report) just like a `blocked` router decision.
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.
- ### 3d.5. Tool-availability mismatch guard (issue #2022)
+ ### 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 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**:
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
- ### 3d.6. Between-stage continuity re-ensure (issue #2452)
+ ### 5d.6. Between-stage continuity re-ensure (issue #2452)
- After the stage subagent returns (3c/3d/3d.4/3d.5) and before asking the router again (3a), 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:
```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.
- ### 3e. Check exit conditions
+ ### 5e. Check exit conditions
- - Dispatched skill was `/do-merge` AND the subagent reports a merge → verify with `gh pr view {pr} --json state,mergedAt` from the tool result. If `MERGED`: **exit the loop, success.**
- - Router returned `blocked` → already stopped in 3a.
+ - 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 3a. Brief one-line progress note per iteration (e.g. "CRITIQUE done (READY TO BUILD) → dispatching BUILD on sonnet").
+ - Otherwise → loop back to 5a. Brief one-line progress note per iteration (e.g. "CRITIQUE done (READY TO BUILD) → dispatching BUILD on sonnet").
- ## Step 4: Final Report
+ ## 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 5: Release the run lease
+ ## 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 **3d.4 REVIEW self-check HALT**
- - a **`blocked`** router decision (3a / 3e)
+ - 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 |
- Both 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.
+ `/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.