okstra 0.107.0 → 0.107.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "okstra",
3
- "version": "0.107.0",
3
+ "version": "0.107.2",
4
4
  "description": "Multi-agent cross-verification orchestrator runtime + Claude Code skills.",
5
5
  "license": "MIT",
6
6
  "author": "devonshin",
@@ -1,5 +1,5 @@
1
1
  {
2
- "package": "0.107.0",
3
- "builtAt": "2026-07-01T03:36:52.358Z",
2
+ "package": "0.107.2",
3
+ "builtAt": "2026-07-01T09:21:00.281Z",
4
4
  "repoRoot": "/home/runner/work/okstra/okstra"
5
5
  }
@@ -12,8 +12,16 @@
12
12
  # are left for graceful shutdown). It no-ops when tmux is unavailable or nothing
13
13
  # is stale.
14
14
  #
15
- # Usage: okstra-team-reconcile.sh [--list] <team-name>
16
- # --list report what WOULD be deactivated; do not write (alias --dry-run).
15
+ # Usage:
16
+ # okstra-team-reconcile.sh [--list] <team-name|team-dir|config.json>
17
+ # okstra-team-reconcile.sh [--list] --project-root <dir> [--fallback-team <label>]
18
+ # okstra-team-reconcile.sh [--list] --session-end # reads SessionEnd JSON on stdin
19
+ #
20
+ # --project-root resolve the CURRENT live session's roster (robust to the
21
+ # phase-3 lead.sessionId snapshot drifting across resume), then
22
+ # reconcile and print `dismissible-member: <name>` lines.
23
+ # --session-end SessionEnd-hook net: reconcile the ending session's roster.
24
+ # --list report what WOULD be deactivated; do not write (alias --dry-run).
17
25
  #
18
26
  # Failures are non-fatal to the run — teardown must never block on this.
19
27
  set -u
@@ -648,7 +648,7 @@ From the report-writer's draft of `## 5.4 Implementation Plan Deliverables`, lea
648
648
 
649
649
  Each plan item inherits the `[TICKETID: ...]` tag of its source section (per the standard ticket-tagging contract).
650
650
 
651
- When extracting each item, lead also captures a **`subject`** — a plain one-line label (≤12 words) describing *what that item is* in the reader's terms, e.g. `P-Opt-1` → "Option A: upload v2 를 신규 모듈로 분리", `P-Step-1.1` → "Stage 1 Step 2: `npm run test:v2` 로 회귀 검증". This is a label-capture, not new analysis. The `subject` is what §5.5.9 renders as the per-item heading so the reader knows *what* each AGREE/DISAGREE is about without cross-referencing §4.5; a bare `P-*` ID with no subject is a contract violation.
651
+ When extracting each item, lead also captures a **`subject`** — a plain one-line label (≤12 words) describing *what that item is* in the reader's terms, e.g. `P-Opt-1` → "Option A: upload v2 를 신규 모듈로 분리", `P-Step-1.1` → "Stage 1 Step 2: `npm run test:v2` 로 회귀 검증". This is a label-capture, not new analysis. The `subject` is what §5.5.9 renders as the per-item heading so the reader knows *what* each AGREE/DISAGREE is about without cross-referencing §4.5; a bare `P-*` ID with no subject is a contract violation. **Enforced:** `validators/validate-run.py` `_validate_plan_item_subject_substance` fails a subject that is a placeholder — under 3 chars, equal to the item id, or shaped like a bare `P-*` id.
652
652
 
653
653
  ### Plan-body verdict semantics
654
654
 
@@ -679,9 +679,9 @@ When `config.adversarial == true` (the default for `implementation-planning`; se
679
679
  - The burden of proof sits on the plan: an item earns `AGREE` only if the verifier actively tried to break it and could not.
680
680
  - The verifier MUST open the file paths / symbols / commands the item cites and confirm they exist and are executable as written. This is the one allowed widening of the lightweight "judge from internal consistency and stated commands / paths" rule — confirming the existence of cited paths is not "re-analyzing the original requirements".
681
681
  - If a cited path / command / validation signal cannot be confirmed, the verifier responds `DISAGREE(<kind>)` with the applicable breakage kind (a–e); uncertainty resolves toward DISAGREE, not AGREE.
682
- - For `P-Req-*` items, a single `DISAGREE(f)` is approval-blocking even in a two-worker roster. Requirement coverage is a hard gate: one verified contradiction between a requirement row and its cited plan item creates a `majority-disagree` classification for gate purposes and MUST become a `Blocks=approval` clarification row.
682
+ - **Single-vote-blocking kinds.** A single `DISAGREE` is approval-blocking on its own no majority needed — when the breakage kind is `a` (cited path/symbol mismatch) or `d` (rollback violates commit/dependency order) on *any* plan item, or `f` (requirement-coverage mismatch) on a `P-Req-*` item. These defects are concrete, safety-critical, and adversarially verifiable (the verifier confirmed the cited path / order / requirement), so one correct dissent must not be outvoted. Each creates a `majority-disagree` classification and MUST become a `Blocks=approval` clarification row. Kinds `b` / `c` / `e` still need a majority — `b` especially is prone to planning-vs-implementation environment false positives. **Enforced:** `validators/validate-run.py` `_classify_plan_item_gate` (`_SINGLE_VOTE_BLOCKING_KINDS = {a, d}` + the P-Req `f` rule).
683
683
 
684
- Plan-body verification stays **lightweight** even under this posture — the `verificationMode = "full-reanalysis"` forcing in §"Adversarial Verification Mode" applies to finding convergence only (see §"Mode constraint"); the adversarial posture here only changes verifier behaviour, not the mode. This raises verification *quality* (active refutation, plan-side burden) without changing the gate *threshold* a single dissent still does not block approval; a majority is required (deliberate design decision).
684
+ Plan-body verification stays **lightweight** even under this posture — the `verificationMode = "full-reanalysis"` forcing in §"Adversarial Verification Mode" applies to finding convergence only (see §"Mode constraint"); the adversarial posture here only changes verifier behaviour, not the mode. This raises verification *quality* (active refutation, plan-side burden). The gate *threshold* stays majority-based for the majority-gated kinds (`b`/`c`/`e`), with the single-vote-blocking exception above for the concrete, safety-critical kinds (`a`/`d`, and `f` on P-Req). A majority requires at least two participating (non-error) votes, so a lone surviving `DISAGREE` whose peer returned a non-result does NOT block on a majority-gated kind a worker failure must not make the gate stricter than a healthy roster would.
685
685
 
686
686
  ### Round protocol (single round at default `maxRounds=1`)
687
687
 
@@ -692,20 +692,20 @@ Plan-body verification stays **lightweight** even under this posture — the `ve
692
692
  - `full-consensus` — all participating analysers `AGREE` (SUPPLEMENT counts as agree on the item itself).
693
693
  - `partial-consensus` — majority `AGREE`, dissenting `DISAGREE` recorded.
694
694
  - `dissent-isolated` — only one worker `DISAGREE`s, others `AGREE` — treat as `partial-consensus` for gate purposes; record dissent. (Distinct from finding-convergence `worker-unique`, which means the *opposite*: only one worker AGREEs. Plan-body classifications use this dedicated label to avoid the collision.)
695
- - `majority-disagree` — majority of analysers `DISAGREE` on this item, OR any analyser emits `DISAGREE(f)` for a `P-Req-*` item. This classification **blocks the Approval marker**.
695
+ - `majority-disagree` — a *majority* of analysers `DISAGREE` (majority needs ≥2 participating non-error votes), OR any single-vote-blocking kind fires: one `DISAGREE(a)` / `DISAGREE(d)` on any item, or one `DISAGREE(f)` on a `P-Req-*` item (see §"Single-vote-blocking kinds"). This classification **blocks the Approval marker**.
696
696
  - `contested` only meaningful when `maxRounds > 1`; at default `maxRounds=1`, fold any unresolved item into `partial-consensus`.
697
697
  5. Gate result resolution:
698
698
  - any `majority-disagree` item present AND `gating=true` → `blocked-by-disagreement`
699
699
  - all dispatches non-result → `aborted-non-result`
700
700
  - any `partial-consensus` / `dissent-isolated` present, no `majority-disagree` → `passed-with-dissent`
701
701
  - all items `full-consensus` → `passed`
702
- 6. Lead writes `runs/<task-type>/state/plan-body-verification-<task-type>-<seq>.json` (schema below) and populates `### 5.5.9 Plan Body Verification` in the final report's data.json (`implementationPlanning.planBodyVerification`, schema `schemas/final-report-v1.0.schema.json`; template at `templates/reports/final-report.template.md`). The §5.5.9 body is **grouped by plan item**: `planItems[]`, each carrying its `id`, its plain-language `subject` (rendered as the item heading), an optional `sourceSection`, and a `verdicts[]` list (`worker / verdict / breakageKind / note`) — one verdict row per worker under that item. The renderer prints three fixed legends (gate values, verdict tokens, breakage kinds a–f) so the reader can decode every cell without opening this spec. The older flat `#### Verdict details` table (`Plan item / Worker / …`, one row per plan-item × worker pair) is superseded by the grouped layout — it hid *what* each vote was about behind a bare `P-*` ID; the subject heading is the fix. The validator's `Plan Body Verification` + `Gate result:` substring checks still gate this section.
702
+ 6. Lead writes `runs/<task-type>/state/plan-body-verification-<task-type>-<seq>.json` (schema below) and populates `### 5.5.9 Plan Body Verification` in the final report's data.json (`implementationPlanning.planBodyVerification`, schema `schemas/final-report-v1.0.schema.json`; template at `templates/reports/final-report.template.md`). The §5.5.9 body is **grouped by plan item**: `planItems[]`, each carrying its `id`, its plain-language `subject` (rendered as the item heading), an optional `sourceSection`, an optional `clarificationId` (the `C-<N>` this item blocks on when `majority-disagree`), and a `verdicts[]` list (`worker / verdict / breakageKind / note`) — one verdict row per worker under that item. The renderer prints three fixed legends (gate values, verdict tokens, breakage kinds a–f) so the reader can decode every cell without opening this spec. The older flat `#### Verdict details` table (`Plan item / Worker / …`, one row per plan-item × worker pair) is superseded by the grouped layout — it hid *what* each vote was about behind a bare `P-*` ID; the subject heading is the fix. The validator's `Plan Body Verification` + `Gate result:` substring checks still gate this section.
703
703
  7. For every `majority-disagree` item, lead adds a row to `## 1. Clarification Items` with:
704
704
  - new `C-<N>` ID (numbering continues from any existing rows)
705
705
  - `Statement` summarising the disagreement and the worker breakage `<kind>`
706
706
  - `Kind` chosen per the standard policy (usually `decision` for option-level conflicts, `data-point` for path/symbol mismatches)
707
707
  - `Blocks=approval`
708
- - the §5.5.9 verdict table's `Classification` column for that row reads `majority-disagree → C-<N>` (1:1 ID match orphan on either side is a contract violation per `prompts/profiles/implementation-planning.md` self-review step 6).
708
+ - the item's `planItems[].clarificationId` set to that `C-<N>` (1:1 link). `validators/validate-run.py` `_validate_plan_body_clarification_matching` recomputes each item's class and fails when a majority-disagree item's `clarificationId` is missing, dangling, or points at a non-`approval` row.
709
709
  8. The top-of-report `- [ ] Approved` marker line is rendered if and only if the Gate result is `passed` or `passed-with-dissent`. `validators/validate-run.py` `validate_phase_boundary` enforces this correspondence; manually adding the marker line when the gate did not pass is a contract violation.
710
710
 
711
711
  ### `plan-body-verification-<task-type>-<seq>.json` schema
@@ -218,7 +218,7 @@ These phases are governed by [team-contract](./team-contract.md). It is the cano
218
218
  Claude Code **v2.1.178 removed the `TeamCreate` / `TeamDelete` tools and the `Agent(...)` `team_name` parameter**. With `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1` (seeded by `okstra install` into the user's `settings.json`), every session already owns **one implicit team** from the moment it starts — there is no creation step to perform and no `team_name` to pass on dispatch. Workers spawned with `Agent(name: ...)` auto-join that implicit team. Do NOT search for, probe, or call `TeamCreate`: its absence is the expected state on every current Claude Code, NOT an "Agent Teams unavailable" signal, and is never a reason to degrade the run. There is no `select:TeamCreate` probe and no doubly-empty-select branch anymore — those keyed off a tool that no longer exists.
219
219
 
220
220
  1. Record team-state before any dispatch:
221
- - `teamName` — the verbatim name from the launch prompt's gate block (`okstra-<task-key>`; implementation stage runs append `-s<N>`, single-stage final-verification appends `-fv-s<N>`). Audit/display label only — the implicit team's on-disk directory is `session-<leadSessionId-prefix>`, which run-end teardown and reconcile resolve from `lead.sessionId`, NEVER from `teamName`.
221
+ - `teamName` — the verbatim name from the launch prompt's gate block (`okstra-<task-key>`; implementation stage runs append `-s<N>`, single-stage final-verification appends `-fv-s<N>`). Audit/display label only — the implicit team's on-disk directory is `session-<leadSessionId-prefix>`, but run-end teardown resolves it from the CURRENT live session (see `_common-contract.md` "Run-end teammate teardown"), NOT from the phase-3 `lead.sessionId` snapshot recorded here, which drifts across resume / compaction; it is never resolved from `teamName`.
222
222
  - `teamCreate: { attempted: false, status: "implicit" }` — the audit marker that this run rides the session's implicit team (no explicit creation tool exists). A concurrent-run launch prompt instead pre-records `status: "skipped", reason: "concurrent-run"`; honor whichever the gate specifies.
223
223
  2. Verify `team-state.lead.sessionId` is populated. The `okstra.sh` exec path fills it automatically (`generate_claude_session_id` → `claude --session-id ...`). The render-only / in-session takeover path (`okstra-run` skill) auto-detects via `resolve_inproc_lead_session_id`, which is best-effort and may return empty if `~/.claude/projects/<encoded-cwd>/` is unreadable or has no jsonl yet. If `lead.sessionId` is empty at this point, write the running session's id into team-state before proceeding — Phase 7 token-usage collection depends on it and will fail with `lead jsonl not found (sessionId=)` otherwise.
224
224
  3. Decide split-pane capability, record it, then emit the phase-3 checkpoint and proceed to Phase 4. Split-pane teammates require a tmux session — this is a host capability, NOT an okstra success/failure gate, and is the only thing the removed "TeamCreate" probe should ever have been deciding:
@@ -34,7 +34,7 @@ Agent(
34
34
  )
35
35
  ```
36
36
 
37
- The `model:` parameter is **derived from the Report writer worker's `modelExecutionValue`** in `task-manifest.json`, mapped to an Agent family token (`opus` / `sonnet` / `haiku`) per [team-contract](./team-contract.md) "Model Assignment Rules" #3–#4. Do NOT hardcode it — the report-writer-worker definition is `model: inherit`, so without this explicit parameter the worker silently runs on the lead's model instead of its assignment. The same `modelExecutionValue` feeds the prompt header in item 6 below, so the spawn model and the recorded `**Model:**` header always agree.
37
+ The `model:` parameter is **derived from the Report writer worker's `modelExecutionValue`** in `task-manifest.json`, mapped to an Agent family token (`opus` / `sonnet` / `haiku`) per [team-contract](./team-contract.md) "Model Assignment Rules" #3–#4. Do NOT hardcode it — the report-writer-worker definition is `model: inherit`, so without this explicit parameter the worker silently runs on the lead's model instead of its assignment. The same `modelExecutionValue` feeds the prompt header in item 7 below, so the spawn model and the recorded `**Model:**` header always agree.
38
38
 
39
39
  The prompt MUST include, in this order at the top:
40
40
 
@@ -43,18 +43,22 @@ The prompt MUST include, in this order at the top:
43
43
  3. `**Result Path:** runs/<task-type>/reports/final-report-<task-type>-<seq>.data.json` — canonical JSON SSOT. The renderer produces the sibling `.md` automatically.
44
44
  4. `**Worker Result Path:** runs/<task-type>/worker-results/report-writer-worker-<task-type>-<seq>.md` — mandatory validator-checked worker-results audit file
45
45
  5. `Assigned worker prompt history path: <absolute-path>`
46
- 6. `**Model:** Report writer worker, <modelExecutionValue>` (resolved per Phase 5.5 anchor-header rules)
47
- 7. The full `[Required reading]` clause (see [team-contract](./team-contract.md)) for Phase 6 it adds two **per-task-type, instruction-set-local** read-only files, both scoped to this run's task-type by `okstra-ctl` at prep time:
46
+ 6. The three BLOCKING dispatch anchor headers the lead MUST inject (per [team-contract](./team-contract.md) headers #5 / #7 / #8 — the worker cannot synthesize the two error paths and must not dead-end when they are absent):
47
+ - `**Worker Preamble Path:** <absolute-path>` the canonical Required Reading + Error Reporting SSOT at `~/.okstra/templates/worker-prompt-preamble.md`; the worker Reads it end-to-end before producing output.
48
+ - `**Errors log path:** <absolute-path>` — run-level errors JSONL (`logs/errors-<task-type>-<seq>.jsonl`).
49
+ - `**Errors sidecar path:** <absolute-path>` — this worker's per-run sidecar JSON (`worker-results/report-writer-worker-errors-<task-type>-<seq>.json`).
50
+ 7. `**Model:** Report writer worker, <modelExecutionValue>` (resolved per Phase 5.5 anchor-header rules)
51
+ 8. The full `[Required reading]` clause (see [team-contract](./team-contract.md)) — for Phase 6 it adds two **per-task-type, instruction-set-local** read-only files, both scoped to this run's task-type by `okstra-ctl` at prep time:
48
52
  - `<instruction-set>/final-report-schema.json` — a task-type excerpt of the data.json schema (the other task-types' deliverable blocks and their unreachable `$defs` are stripped; ~38% of the full schema is `$defs` alone). This is your authoring contract for the data.json shape. Do **NOT** pull the full `schemas/final-report-v1.0.schema.json` — it carries all task-types and its `schemas/...` path is not part of the task bundle. (Validation still runs against the full schema post-hoc via the renderer, so the excerpt never relaxes the contract.)
49
53
  - `<instruction-set>/final-report-template.md` — the **phase-stripped** template (every other task-type's §5.x deliverable block removed by `render.py`'s `_strip_phase_blocks`, leaving only your run's §5.x). Do **NOT** also pull the full `templates/reports/final-report.template.md` source (it re-adds ~330 lines of other phases' deliverables and is not in the task bundle).
50
- 8. A one-line MCP pointer instead of the verbatim block — `**MCP servers:** follow the task brief's "## Available MCP Servers" section (already in your Required reading).` The brief is already in the report-writer's Required reading (item 7), so the verbatim block is redundant.
51
- 9. The convergence classifications (Full/Partial/Contested/Worker-Unique), the round history data (`roundHistory[]`), the `round2SkippedReason` value, and pointers to all worker result files under `worker-results/`. The report-writer worker populates `crossVerification.roundHistory` in the data.json so Section 6 can show which rounds executed, queue sizes, and why Round 2 was (or was not) skipped. The renderer prints the full per-round table only when more than one round ran; single-round or zero-round histories are auto-collapsed to a one-line summary.
52
- 10. `**Report Language:** <en|ko>` — must be either `en` or `ko`; `auto`
54
+ 9. A one-line MCP pointer instead of the verbatim block — `**MCP servers:** follow the task brief's "## Available MCP Servers" section (already in your Required reading).` The brief is already in the report-writer's Required reading (item 8), so the verbatim block is redundant.
55
+ 10. The convergence classifications (Full/Partial/Contested/Worker-Unique), the round history data (`roundHistory[]`), the `round2SkippedReason` value, and pointers to all worker result files under `worker-results/`. The report-writer worker populates `crossVerification.roundHistory` in the data.json so Section 6 can show which rounds executed, queue sizes, and why Round 2 was (or was not) skipped. The renderer prints the full per-round table only when more than one round ran; single-round or zero-round histories are auto-collapsed to a one-line summary.
56
+ 11. `**Report Language:** <en|ko>` — must be either `en` or `ko`; `auto`
53
57
  has been resolved by the lead from project.json / global config
54
58
  before the dispatch is constructed. The worker copies this verbatim
55
59
  into `data.json.meta.reportLanguage`.
56
- 11. For implementation-planning runs: a literal block listing the 8 required English section headings the validator scans for (`Option Candidates`, `Trade-off`, `Recommended Option`, `Stepwise Execution Order`, `Dependency`, `Validation Checklist`, `Rollback`, `User Approval Request`). The writer must use these exact substrings as section headings (Korean translation in parentheses is allowed).
57
- 12. An explicit instruction: `You are the author of TWO files: (a) the final-report data.json at <Result Path>, (b) the worker-results audit file at <Worker Result Path>. After writing the data.json, invoke "okstra render-final-report <Result Path>" via Bash so the markdown sibling is rendered before you return. Do not return the report inline. The validator fails the run when (a)'s schema validation fails, when the rendered markdown is absent, or when (b) is missing.`
60
+ 12. For implementation-planning runs: a literal block listing the 8 required English section headings the validator scans for (`Option Candidates`, `Trade-off`, `Recommended Option`, `Stepwise Execution Order`, `Dependency`, `Validation Checklist`, `Rollback`, `User Approval Request`). The writer must use these exact substrings as section headings (Korean translation in parentheses is allowed).
61
+ 13. An explicit instruction: `You are the author of TWO files: (a) the final-report data.json at <Result Path>, (b) the worker-results audit file at <Worker Result Path>. After writing the data.json, invoke "okstra render-final-report <Result Path>" via Bash so the markdown sibling is rendered before you return. Do not return the report inline. The validator fails the run when (a)'s schema validation fails, when the rendered markdown is absent, or when (b) is missing.`
58
62
 
59
63
  **Completion detection after dispatch (BLOCKING).** The `Agent(name: ..., run_in_background: true)` call returns `Spawned successfully` immediately; that ack is NOT completion. After dispatching the report-writer (async), Lead MUST detect its completion via the self-scheduled polling protocol in [team-contract](./team-contract.md) "Worker-completion detection (self-scheduled polling)", polling for the appearance of the data.json (Result Path) and the worker-results file (Worker Result Path) — do NOT restate the algorithm here. Report-writer is a single worker, so the pending set has one entry; the SSOT protocol handles that naturally. Lead MUST NOT treat the `Spawned successfully` ack as completion and MUST NOT end its turn with a prose "waiting for the report" statement; that path stalls the run until the user manually nudges it.
60
64
 
@@ -273,7 +277,9 @@ Section numbering follows `templates/reports/final-report.template.md` exactly
273
277
  - `approach` — 그래서 어떤 작업이 필요한가 (택한 방향).
274
278
  - `justification` — 왜 이게 합리적 선택인가 (대안 대비, 근거).
275
279
 
276
- Every field MUST anchor its claim with at least one evidence reference — a `path:line`, a report ID (`C-001` / `F-013` / `§5.4`), or another cited source already present in this report. When the evidence is genuinely insufficient, say so explicitly (`근거 불충분 — <이유>`) instead of inventing one. `validators/validate-run.py` → `_validate_rationale_evidence` fails any field carrying neither a citation nor an insufficiency marker, so an ungrounded or fabricated rationale is rejected at validation time. Do NOT restate the Verdict Card or the §5.4 trade-off matrix verbatim — this section is the *why*, in connected prose, that those tables compress.
280
+ Every field MUST anchor its claim with at least one evidence reference — a `path:line`, an **in-report** ID (`C-001` / `E-006` / `§5.4`), or another cited source already present in this report. When the evidence is genuinely insufficient, say so explicitly (`근거 불충분 — <이유>`) instead of inventing one. `validators/validate-run.py` → `_validate_rationale_evidence` fails any field carrying neither a citation nor an insufficiency marker, so an ungrounded or fabricated rationale is rejected at validation time.
281
+
282
+ **Reader-facing prose MUST NOT cite a bare brief/worker-internal ID that this report never surfaces** — `RC-*` (reporter confirmations, defined in the brief), `RF-*` / `F-*` (findings, defined in worker-results) have no anchor in the final report, so a reader hits an opaque token with nothing to click. Either expand it inline (`the confirmed version target 1.27.47→1.27.48`) or, for an audit trail, namespace it (`claude:F-005`). **Enforced:** `_validate_no_opaque_id_references` fails a bare `RC-*` / `RF-*` / non-namespaced `F-*` appearing in `verdictCard` / `finalVerdict` / `rationale` / `clarificationItems[].statement`. (The renderer anchors + links in-report IDs of any digit width, so a surfaced `RC-4`-style id does resolve.) Do NOT restate the Verdict Card or the §5.4 trade-off matrix verbatim — this section is the *why*, in connected prose, that those tables compress.
277
283
 
278
284
  0. **Clarification Response Carried In** — render this `## 0.` heading ONLY when `{{CLARIFICATION_RESPONSE_RELATIVE_PATH}}` is non-empty. Walk every `C-*` row of the prior report's `## 1. Clarification Items` table, reconcile against new evidence, and record the outcome (`resolved` / `obsolete`) with citation before drafting the verdict. When no carry-in path was provided, OMIT the `## 0.` heading entirely — the validator fails an empty Section 0 stub.
279
285
  1. **Cross Verification Results** — 4 categories (Full / Partial / Contested / Worker-Unique) when convergence is enabled, per `convergence`. Prepend the Round History sub-table (columns: `Round | inputQueueSize | resolvedCount | carriedForwardCount | dispatches | skippedWorkers`) plus a `round2SkippedReason: <value>` note, pulled verbatim from `convergence-<task-type>-<seq>.json`. Empty contested list renders as `- 합의 미달 항목 없음.`. Convergence-disabled runs use the legacy Consensus/Differences format and omit the round table.
@@ -55,15 +55,16 @@ profile document.
55
55
  - This step is mandatory for every phase (`requirements-discovery`, `error-analysis`, `implementation-planning`, `implementation`, `final-verification`, `release-handoff`). Whether there is anything to ask about is decided by the `--list` output, NOT by the lead's reading of `lead-pane.id`: an empty list → skip the question (above); the lead MUST NOT fabricate a synthetic pane list.
56
56
  - Run-end teammate teardown (shared — MUST run AFTER the pane disposition question and BEFORE `PROGRESS: complete`):
57
57
  - CC v2.1.178 removed `TeamCreate` / `TeamDelete`: this run rode the session's **implicit team** (Phase 3 recorded `teamCreate.status: "implicit"` plus the `teamName` audit label). There is no tool to delete a team — the implicit team ends when the session ends. What teardown CAN do is gracefully dismiss the worker teammates the lead spawned, so they stop showing as live members in the FleetView roster.
58
- - **Whether to run this step is decided by on-disk fact.** The harness names the team directory `session-<leadSessionId-prefix>` (the `teamName` label never becomes a directory name). Resolve existence from disk: find the `~/.claude/teams/session-*/config.json` whose `leadSessionId` equals team-state `lead.sessionId`.
59
- - **Matching on-disk config found, with members beyond the lead** → teammates exist; run the dismissal sequence below.
60
- - **No matching on-disk config, or split-pane was off** → nothing to dismiss → silent-skip.
58
+ - **Whether to run this step is decided by on-disk fact, resolved at teardown time by the CURRENT live session — NOT from `team-state.lead.sessionId`.** The harness names the team directory `session-<leadSessionId-prefix>` (the `teamName` label never becomes a directory name), but `team-state.lead.sessionId` is only a phase-3 snapshot: Claude Code re-issues the session id across resume / compaction mid-run, so the live roster usually sits under a DIFFERENT `session-*` dir than the snapshot names. Resolving from the snapshot is what made split-pane teardown silently miss its own live teammates. The resolver in step 1 below keys off the live session (falling back to the snapshot dir only when the live one is absent) and prints the members to dismiss.
59
+ - **Resolver printed one or more `dismissible-member: <name>` lines** → teammates exist; run the dismissal sequence below.
60
+ - **Resolver printed `no live roster for this session`, or split-pane was off** → nothing to dismiss → silent-skip.
61
61
  - The pane disposition prompt above is the only user prompt for this cleanup decision. Do NOT ask a second teammate-only question.
62
62
  - On `아니오` / `n` / `keep` → dismiss nothing. Tell the user the teammates remain visible in FleetView/Teams until the session ends, and can be removed earlier from the Teams UI.
63
63
  - On `예` / `y` / `close` → emit `PROGRESS: phase-7-teardown dismissing teammates`, then run the sequence below. Token-usage collection MUST already be complete — it reads `~/.claude/projects/` jsonls, which dismissal never touches, but the read MUST precede teardown.
64
- 1. Run `$HOME/.okstra/bin/okstra-team-reconcile.sh "<resolved-config-path>"` the on-disk `~/.claude/teams/session-<…>/config.json` resolved in the existence check above exactly once. It flips dead-pane stale-active members to inactive, and no-ops when tmux is unavailable or nothing is stale. Do NOT loop it.
65
- 2. Send each confirmed-complete member a structured `SendMessage(to: <name>, message: { type: "shutdown_request" })` — the `message` MUST be the object literal shown, NEVER a JSON string stuffed into a text field. NEVER target the lead's own session, and never use `TaskStop` (teammates are not background tasks — `TaskStop` 404s on a member address).
64
+ 1. Run `$HOME/.okstra/bin/okstra-team-reconcile.sh --project-root "<PROJECT_ROOT>" --fallback-team "session-<lead.sessionId-prefix>"` exactly once. It resolves the live session's `~/.claude/teams/session-<live>/config.json` (falling back to the snapshot `session-<lead.sessionId-prefix>` dir only if the live one is absent), flips dead-pane stale-active members to inactive (no-op when tmux is unavailable or nothing is stale), and prints one `dismissible-member: <name>` line per non-lead member. Do NOT loop it, and do NOT hand-resolve a config path from `team-state.lead.sessionId`.
65
+ 2. Send each printed `dismissible-member: <name>` **whose completion is confirmed** (its Result Path exists) a structured `SendMessage(to: <name>, message: { type: "shutdown_request" })` — the `message` MUST be the object literal shown, NEVER a JSON string stuffed into a text field. NEVER target the lead's own session, and never use `TaskStop` (teammates are not background tasks — `TaskStop` 404s on a member address).
66
66
  - Claude Code exposes no tool to delete the team or surgically remove a roster entry, so a dismissed teammate may still show as an idle pill until the session ends. Do not pretend the team was deleted. If the user wants the roster gone entirely, surface the manual action: `Teams/FleetView 에서 team <teamName> 삭제`, and if tmux panes were kept earlier also show `$HOME/.okstra/bin/okstra-trace-cleanup.sh --run-dir "<RUN_DIR>"`.
67
+ - **Enforcement / safety net.** This teardown fires after the Phase 7 validator's window, so `validate_session_conformance.py` cannot check it (it is not a required checkpoint). The net is the `SessionEnd` hook `$HOME/.okstra/bin/okstra-team-reconcile.sh --session-end` (seeded into `settings.template.json`): when the Claude session ends it reconciles that session's roster regardless of whether this step ran, so a dead-pane member never lingers as a live pill past session end.
67
68
  - Report the outcome in one short line (e.g. `worker teammate 정리 완료`, or `stale 멤버 1명 reconcile 후 정리`) and proceed to the final `PROGRESS: complete ...` line.
68
69
  - Brief handoff contract (shared — applies whenever the run consumes a task brief produced by `okstra-brief`):
69
70
  - the brief is a **pre-discovery artifact**: it converts a domain-reporter's words (non-expert *or* developer) into expert-consumable form so this and later phases can run with zero fill-in questions to the operator. The brief is **not** authoritative on solution decisions; it is authoritative on the reporter's intent.
@@ -41,7 +41,7 @@
41
41
  - The YAML frontmatter `approved: true|false` field is the only authorised approval gate. report-writer always emits `approved: false`. The user clears it either by (a) editing the frontmatter line to `approved: true` directly, or (b) invoking the next phase with `--approve` so the CLI flips the frontmatter on the user's behalf. `okstra_ctl.run._validate_approved_plan` reads this field and refuses entry until it is `true`.
42
42
  - Cross-verification mode:
43
43
  - Phase 5.5 finding convergence runs in **adversarial mode** for this phase (`convergence.adversarial=true`). Verifiers actively try to refute each worker finding (requirement gap / risk / option) by re-inspecting its cited evidence; the burden of proof sits on the claim. See `prompts/lead/convergence.md` §"Adversarial Verification Mode".
44
- - §5.5.9 plan-body verification runs with an **adversarial posture** (`prompts/lead/convergence.md` §"Adversarial plan-body posture"): verifiers open and confirm every cited path / command and put the burden of proof on the plan. The gate threshold is unchanged a *majority* `DISAGREE` (`majority-disagree`) is still required to block approval; a single dissent does not.
44
+ - §5.5.9 plan-body verification runs with an **adversarial posture** (`prompts/lead/convergence.md` §"Adversarial plan-body posture"): verifiers open and confirm every cited path / command and put the burden of proof on the plan. The gate threshold is majority-based for kinds `b`/`c`/`e`, but a single `DISAGREE` blocks on its own for the concrete, safety-critical kinds `a` (path/symbol mismatch) / `d` (rollback order) and `f` on `P-Req-*` items. A majority also needs ≥2 participating votes, so a lone dissent whose peer returned a non-result does not block on a majority-gated kind (see `convergence.md` §"Adversarial plan-body posture").
45
45
  {{INCLUDE:_coverage-critic.md}}
46
46
  - Non-goals:
47
47
  - code-level micro-optimization unless it changes the implementation approach
@@ -78,11 +78,11 @@
78
78
  Validator S10d rejects a missing/empty line in any of the three categories (skipped only when a `TDD exemption:` line is present). The `RED:` step below must encode these cases, not a single assertion.
79
79
  - **Per-stage subsections** (`## 5.5.<i> Stage <i>: <title>` for each `i`), each containing the four required subsections:
80
80
  - `### Carry-In` — for `depends-on (none)`: task-brief only. Otherwise: each depended-on stage's static exit contract + runtime sidecar path `runs/<impl-key>/carry/stage-<i>.json` placeholder.
81
- - `### Stepwise Execution Order` — bite-sized table with `step | action | files | command | expected`. **Effective row count ≤ 8** (excluding header / divider / blank). Each step is one cohesive, self-contained change (시간 하한 없음; 함께 바뀌는 여러 파일을 포함할 수 있다); for code steps include actual code or diff sketch. **TDD ordering is MUST, not a preference:** the **first** effective step's `action` cell MUST start with the literal `RED:` and describe the failing test(s) that capture this stage's `Acceptance` **and the three declared `Test case (success|boundary|failure)` lines** (`expected` = FAIL) — the RED step encodes the case set, not a single happy-path assertion; at least one later `action` cell MUST start with the literal `GREEN:` and describe the minimal implementation that makes it pass (`expected` = PASS); an optional refactor step starts with `REFACTOR:`. **Exemption:** doc-only / config-only / pure-rename stages with no observable runtime behaviour may omit RED/GREEN by declaring one line `TDD exemption: <reason>` in the stage section (mirrors the executor's per-step exemption in `_implementation-executor.md`). Validator S10c enforces RED-first + GREEN, or the exemption line.
81
+ - `### Stepwise Execution Order` — bite-sized table with `step | action | files | command | expected`. **Effective row count ≤ 8** (excluding header / divider / blank). Each step is one cohesive, self-contained change (시간 하한 없음; 함께 바뀌는 여러 파일을 포함할 수 있다); for code steps include actual code or diff sketch. **TDD ordering is MUST, not a preference:** the **first** effective step's `action` cell MUST start with the literal `RED:` and describe the failing test(s) that capture this stage's `Acceptance` **and the three declared `Test case (success|boundary|failure)` lines** (`expected` = FAIL) — the RED step encodes the case set, not a single happy-path assertion; at least one later `action` cell MUST start with the literal `GREEN:` and describe the minimal implementation that makes it pass (`expected` = PASS); an optional refactor step starts with `REFACTOR:`. **Exemption:** doc-only / config-only / pure-rename stages with no observable runtime behaviour may omit RED/GREEN by declaring one line `TDD exemption: <reason>` in the stage section (mirrors the executor's per-step exemption in `_implementation-executor.md`). Validator S10c enforces RED-first + GREEN **and** that the `RED:` step's `expected` reads FAIL / the `GREEN:` step's reads PASS; S10e rejects a `TDD exemption:` whose reason is not one of doc-only / config-only / pure-rename (both in `validators/validate-implementation-plan-stages.py`).
82
82
  - **Per-stage conformance declaration (mandatory one line, in the stage section — same placement freedom as `TDD exemption:`):** the stage MUST carry exactly one of:
83
83
  - `Conformance tests: stage-<N> — <task_root>/qa/scripts/stage-<N>.<ext> (requires=[db|io|http|external,...])` — a Tier3 verification script that proves this stage's upstream requirements (brief / requirements-discovery / error-analysis / improvement-discovery → this stage's `Acceptance`) hold against **real** DB rows, real endpoints, or the real external API — NOT mocks. When you emit this line you MUST also (a) write the script to `<task_root>/qa/scripts/stage-<N>.<ext>` and (b) add a matching entry to `<task_root>/qa/conformance-manifest.json` with fields `stageKey` (= `<task-id>-stage-<N>`), `script`, `runCommand`, `requirementIds`, `requires` (subset of `{db, io, http, external}`), `passContract`, `exemption: null`, `waiver: null`. The script's standard interface: a `main` that exits `0`=PASS / non-zero=FAIL, and whose stdout ends with `QA-RESULT: PASS|FAIL` followed by one `REQ <id>: PASS|FAIL: <근거>` line per requirement. When the verification body is a test spec, author it with the project's own test framework (devDependency) invoked via a discovery override at `<task_root>/qa/scripts/` (jest: `--config <project config> --roots <task_root>/qa/scripts`) — never hand-roll `describe`/`expect` and never widen the project's own test config; for TypeScript specs also write `<task_root>/qa/scripts/tsconfig.json` extending the project tsconfig with the runner's `types` entry so editors resolve the file.
84
84
  - `Conformance exemption: <reason>` — only for stages that touch no db/io/http/external surface, or where unit tests fully cover the increment. (If the eventual `implementation` diff actually touches one of those surfaces, `validate-run.py`'s diff-surface cross-check is BLOCKING — an exemption cannot hide a real db/io/http/external change.)
85
- The manifest lives at the **task level** (`<task_root>/qa/`, path token `TASK_QA_PATH`) and is shared across planning → implementation → final-verification. Layout split: executable scripts (conformance + any real-IO test) live under `<task_root>/qa/scripts/`; data sidecars (`conformance-manifest.json`, `result-*.json`) stay at the `qa/` root. This declaration is enforced at three layers: `validators/validate-implementation-plan-stages.py` check **S11** forces every stage to carry one of the two lines; the manifest JSON structure — including each entry's `script` living under `qa/scripts/` — is enforced by `validate_conformance_manifest` (called from both the run path and validate-run); and the result gate (each script's `QA-RESULT`) is enforced by the verifier Tier3 + validate-run.
85
+ The manifest lives at the **task level** (`<task_root>/qa/`, path token `TASK_QA_PATH`) and is shared across planning → implementation → final-verification. Layout split: executable scripts (conformance + any real-IO test) live under `<task_root>/qa/scripts/`; data sidecars (`conformance-manifest.json`, `result-*.json`) stay at the `qa/` root. This declaration is enforced at four layers: `validators/validate-implementation-plan-stages.py` check **S11** forces every stage to carry one of the two lines; at the planning boundary `validators/validate-run.py` `_validate_planning_conformance_declared` fails when a stage that declared `Conformance tests:` has no matching `-stage-<N>` entry in the shared manifest (a declaration that was never materialized); the manifest JSON structure — including each entry's `script` living under `qa/scripts/` — is enforced by `validate_conformance_manifest` (called from both the run path and validate-run); and the result gate (each script's `QA-RESULT`) is enforced by the verifier Tier3 + validate-run.
86
86
  - `### Stage Exit Contract` — predicted added/modified files, newly exposed identifiers/types/endpoints, downstream-usable resources.
87
87
  - `### Stage Validation` — pre / mid / post exact commands or observable outcomes for this stage only.
88
88
  - **Vertical-slice-first partition rule (1st-class):** the grouping anchor is a **thin end-to-end vertical slice** — one stage delivers a single user-observable increment, crossing whatever layers are needed (data → service → API → UI) to make that one increment work. File/module proximity is demoted to the **intra-slice grouping rule**: within a slice, keep steps touching the same file/directory/module together so the diff, PR, and rollback unit stay cohesive. **Horizontal layer-splitting is forbidden** — never carve "the DB layer" into one stage and "the service layer" into the next; that produces stages that ship no standalone user value. A stage is split ONLY when (a) a real `depends-on` data/contract dependency exists, (b) effective steps would exceed 8, or (c) it is a distinct vertical slice (a different user-value increment). Maximising the number of parallel stages is NOT a reason to split — parallelism is an emergent property of independent stages, never a partitioning goal.
@@ -101,13 +101,13 @@
101
101
  - **recommendedNextSteps 정책:** cross-project 선행/후속의 substance 는 `crossProjectDependencies` 에 두고, `§3 Recommended Next Steps` 에는 그 섹션(`§5.4 Cross-Project Dependencies`)을 가리키는 포인터만 둔다 — 이중 기록 금지.
102
102
  - validation checklist (pre / mid / post) — each item is an exact command or observable outcome
103
103
  - rollback strategy — exact revert path (commits, flags, migrations) and the signal that triggers rollback
104
- - **Requirement Coverage (mandatory, §5.5.8):** one row per concrete requirement from the task brief / packet. Assign stable IDs `R-001`, `R-002`, ... in source order. Columns: `ID | Source | Requirement | Covered by option / stage / step | Status`. `Source` cites the brief heading or file/line where the requirement came from. `Covered by` must name the specific Option Candidate and Stage/Step that satisfies it, not just "recommended option". `Status` is one of `covered`, `gap`, or `blocked C-NNN`. If any row is `gap` or `blocked C-NNN`, the Plan Body Verification gate MUST NOT be `passed` / `passed-with-dissent`; add a matching `Blocks=approval` row for the blocker and keep `approved: false`.
104
+ - **Requirement Coverage (mandatory, §5.5.8):** one row per concrete requirement from the task brief / packet. Assign stable IDs `R-001`, `R-002`, ... in source order. Columns: `ID | Source | Requirement | Covered by option / stage / step | Status`. `Source` cites the brief heading or file/line where the requirement came from. `Covered by` must name the specific Option Candidate and Stage/Step that satisfies it, not just "recommended option". **Enforced:** `validators/validate-run.py` `_validate_requirement_coverage_covered_by` fails a `covered` row whose `coveredBy` is bare "recommended option", names no Option/Stage/Step anchor, or cites a Stage number absent from the Stage Map (whether the cited step *actually satisfies* the requirement remains a worker `DISAGREE(f)` judgment). `Status` is one of `covered`, `gap`, or `blocked C-NNN`. If any row is `gap` or `blocked C-NNN`, the Plan Body Verification gate MUST NOT be `passed` / `passed-with-dissent`; add a matching `Blocks=approval` row for the blocker and keep `approved: false`.
105
105
  - **Review-rule compliance plan:** when a project-local review rule pack is found, each Option Candidate MUST include the design implication of those rules in its File Structure / interfaces / blast-radius notes. For any helper or data transform used by more than one changed service, the plan must either place it in a shared module or explicitly justify why duplication is intentional. For any test step, the plan must state the observable behavior being asserted, not the internal collaborator call being pinned. For any exported/public method added or renamed, the step must carry the intended noun/side-effect semantics so implementation names can be reviewed before code is written.
106
106
  - the YAML frontmatter MUST include the line `approved: false` (report-writer always emits the unflipped value). The user authorises the next `implementation` run by flipping it to `approved: true` (manual edit or `--approve` CLI). Do NOT recreate any `User Approval Request` body block — the validator fails reports that contain one (see `validators/validate-run.py` deprecated patterns).
107
107
  - the YAML frontmatter MUST include the line `implementation-option:` directly under `approved:` (report-writer always emits it with an **empty value**). The user selects which Option Candidate the next `implementation` run executes by filling this line with that option's name (manual edit or `--implementation-option <name>` CLI). When left empty, the `implementation` run falls back to the `Recommended Option`.
108
108
  - **the frontmatter `approved: false` line is rendered unconditionally; if the plan-body verification gate (§5.5.9) returns `blocked-by-disagreement` or `aborted-non-result`, the writer MUST keep `approved: false` and the validator refuses any report that ships with `approved: true` under such a gate result.**
109
109
  - every ambiguity flagged during pre-planning that the user must resolve before approval registered as a `Blocks=approval` row in the `## 1. Clarification Items` table (do NOT create a separate `Open Questions` block under the implementation plan body — the unified table is the single home)
110
- - **§5.5.9 Plan Body Verification (BLOCKING).** After report-writer finishes the draft, the lead MUST run a worker peer-review round on the consolidated plan body (Option Candidates / Trade-off Matrix / Recommended Option / Stage Map and per-stage sections / Dependency / Validation Checklist / Rollback / Requirement Coverage) and populate `### 5.5.9 Plan Body Verification` in the final report. The round protocol, plan-item ID scheme (`P-Opt-*` / `P-Step-*` / `P-Dep-*` / `P-Val-*` / `P-Rb-*`), verdict semantics, gate-result classification, and dissent log format are defined in `prompts/lead/convergence.md` "Plan-body verification mode". The four gate-result values are `passed`, `passed-with-dissent`, `blocked-by-disagreement`, `aborted-non-result`. When the gate would have been `blocked-by-disagreement` or `aborted-non-result`, the lead MUST NOT silently flip it to one of the passing values to "unblock" the run — that is a contract violation. When `convergence.adversarial=true` (the default for this phase), this round uses the adversarial posture — verifiers confirm cited paths/commands and the burden of proof is on the plan — but the gate threshold stays `majority-disagree` (see that skill's §"Adversarial plan-body posture").
110
+ - **§5.5.9 Plan Body Verification (BLOCKING).** After report-writer finishes the draft, the lead MUST run a worker peer-review round on the consolidated plan body (Option Candidates / Trade-off Matrix / Recommended Option / Stage Map and per-stage sections / Dependency / Validation Checklist / Rollback / Requirement Coverage) and populate `### 5.5.9 Plan Body Verification` in the final report. The round protocol, plan-item ID scheme (`P-Opt-*` / `P-Step-*` / `P-Dep-*` / `P-Val-*` / `P-Rb-*`), verdict semantics, gate-result classification, and dissent log format are defined in `prompts/lead/convergence.md` "Plan-body verification mode". The four gate-result values are `passed`, `passed-with-dissent`, `blocked-by-disagreement`, `aborted-non-result`. When the gate would have been `blocked-by-disagreement` or `aborted-non-result`, the lead MUST NOT silently flip it to one of the passing values to "unblock" the run — that is a contract violation. **Enforced:** `validators/validate-run.py` `_validate_plan_body_gate_recompute` re-derives the gate from `planItems[].verdicts` and fails when the declared `gateResult` claims a healthier outcome than the recorded votes support; `_validate_plan_item_extraction_completeness` fails when any plan-body deliverable category is under-extracted into `planItems`, so a dropped item can no longer dodge the gate. When `convergence.adversarial=true` (the default for this phase), this round uses the adversarial posture — verifiers confirm cited paths/commands and the burden of proof is on the plan — but the gate threshold stays `majority-disagree` (see that skill's §"Adversarial plan-body posture").
111
111
  - **Decision-record evaluation (sole owner)**: this phase is the **single owner** of decision-record evaluation in the okstra lifecycle. The brief never evaluates or drafts decision records — it only forwards `adr-candidate:*` signals. Every `adr-candidate:*` entry inherited from the brief's `Open Questions` is a mandatory evaluation target. In addition, evaluate every decision the recommended option introduces against the three criteria:
112
112
  1. **Hard to reverse** — would changing the decision later cost meaningfully more than deciding now?
113
113
  2. **Surprising without context** — would a future reader, seeing only the code, wonder "why was it built this way?"?
@@ -127,7 +127,7 @@
127
127
  4. **Ambiguity check** — any requirement that could be read two ways must be made explicit or moved to the `## 1. Clarification Items` table as a `Blocks=approval` row.
128
128
  5. **Scope check** — if the recommended plan now spans multiple independent subsystems, recommend splitting into separate planning runs rather than shipping an oversized plan.
129
129
  6. **Review-rule preflight check** — if a project review rule pack exists, map each relevant rule to the recommended option. Reject the draft if it knowingly creates a violation that the later PR reviewer would flag, unless the plan records a specific rationale and follow-up. In particular, scan for repeated helper stacks across planned files, tests that assert delegation to the same calculator/helper they exercise, public names that hide side effects, domain rules placed in repositories/adapters, and APIs made dead by this change.
130
- 7. **Plan-body verification reconciliation (BLOCKING for implementation-planning).** Inspect the `### 5.5.9 Plan Body Verification` verdict table. For every plan-item row classified as `majority-disagree C-<N>`, the corresponding `C-<N>` row MUST exist in `## 1. Clarification Items` with `Kind` chosen per the standard policy and `Blocks=approval`. Do NOT create a parallel `Open Questions` block under the implementation plan body — the unified table is the single home. Conversely, the `Classification` column's `C-<N>` reference and the `## 1. Clarification Items` `ID` column MUST match 1:1; an orphan on either side is a contract violation. For `partial-consensus` and `worker-unique` plan-items, the dissenting opinion lives in §5.5.9 `Dissent log` and is NOT promoted to §5.
130
+ 7. **Plan-body verification reconciliation (BLOCKING for implementation-planning).** For every §5.5.9 `planItems[]` entry whose verdicts make it `majority-disagree`, set that item's `clarificationId` to a `C-<N>` row that MUST exist in `## 1. Clarification Items` with `Kind` chosen per the standard policy and `Blocks=approval`. Do NOT create a parallel `Open Questions` block under the implementation plan body — the unified table is the single home. **Enforced:** `validators/validate-run.py` `_validate_plan_body_clarification_matching` recomputes each item's class and fails when a majority-disagree item has no `clarificationId`, or its `clarificationId` is dangling / points at a non-`approval` row. For `partial-consensus` and `dissent-isolated` plan-items, the dissenting opinion lives in §5.5.9 `Dissent log` and is NOT promoted to §5.
131
131
  8. **Stage Map self-check** — for every stage, count the effective rows of its `Stepwise Execution Order` table by hand; reject the draft if any stage exceeds 8. Confirm each stage declares a non-empty `Slice value:` and `Acceptance:` line, the three `Test case (success|boundary|failure):` lines (or carries a `TDD exemption:` line), and that its first step `action` starts with `RED:` with a later `GREEN:` — this is what validator S10 enforces, including S10d on the test-case lines. Read each stage's three test-case lines as a reviewer: reject any that restates the happy path in all three slots, leaves `boundary` blank, or writes `N/A` where a real edge input exists. Walk the `depends-on` graph and confirm it is a DAG (no cycle, no self-reference). For each `depends-on` link, confirm it encodes a real data/contract dependency — do NOT add links to serialise unrelated work, and do NOT split a stage merely to create more parallel stages. **Parallel-safety:** for every pair of `depends-on (none)` stages, confirm their `Stage Exit Contract` predicted file sets are disjoint; if they share a file, merge them or add a `depends-on` link (validator S9 rejects overlap). **Project-boundary:** confirm no stage mixes edits from two projects (different repo/`PROJECT_ROOT` or different top-level deployable module); if any stage does, split it per project. For multi-project plans, confirm each stage's `title` carries its `[<project>]` tag and the `Cross-project parallelism:` line under the table records the parallel-vs-sequenced determination (with the forcing dependency) for every project pair; for cross-repo work, confirm it is split into separate per-repo runs (required — one run structurally cannot touch another repo) rather than crammed into one task's stages.
132
132
  9. **Cross-project dependency check** — 타 repo / 다른 top-level 배포 모듈 / published 패키지에 대한 의존을 빠뜨리지 않았는지 확인한다. `dependencyMigrationRisk` 에 `kind: cross-project` 행이 있으면 매칭되는 `direction: upstream-precondition` `XP-NNN` 행이 `crossProjectDependencies` 에 있고, 그 `requiredWork` 가 추상 표현("상대 작업 완료")이 아니라 상대가 실제로 만들어야 할 구체 작업인지 reviewer 로서 다시 읽는다(validator S 가 존재만 보므로 구체성은 self-review 가 책임짐). cross-repo 작업을 한 task 의 stage 로 욱여넣지 않고 별도 run + XP 행으로 분리했는지, cross-project substance 가 `§3 Recommended Next Steps` 에 중복되지 않고 `§5.4 Cross-Project Dependencies` 에만 있는지 확인한다.
133
133
  10. **Decision-draft materialization check** — `decisionDrafts` 가 비어있지 않으면, 매칭 materialization step(`.okstra/decisions/<NNNN>-<slug>.md` 생성)이 어느 stage 의 stepwise 에 존재하는지, draft 개수와 materialization step 이 1:1 로 대응하는지 reviewer 로서 확인한다. validator 는 step 의 *존재*만 보므로 `<NNNN>-<slug>` 정확성·개수 대응은 self-review 가 책임진다.
@@ -136,10 +136,12 @@ def _yaml_inline_list(values: list[str]) -> str:
136
136
  # reference must not silently jump to the wrong row.
137
137
  _HEADING_RE = re.compile(r"^(#{1,6})[ \t]+(.*?)[ \t]*$")
138
138
  _FENCE_RE = re.compile(r"^[ \t]*(?:```|~~~)")
139
- _FIRST_CELL_ID_RE = re.compile(r"^[ \t]*\*{0,2}([A-Z]{1,4}-\d{3,})\b")
139
+ _FIRST_CELL_ID_RE = re.compile(r"^[ \t]*\*{0,2}([A-Z]{1,4}-\d{1,})\b")
140
140
  # A reference token: an ID not glued to a word char / `-` on either side and
141
141
  # not prefixed by `:` (which would make it a `<worker>:<item-id>` source ref).
142
- _REF_ID_RE = re.compile(r"(?<![:\w-])[A-Z]{1,4}-\d{3,}(?![\w-])")
142
+ # `\d{1,}` (not `{3,}`): short IDs like `RC-4` are valid — the old 3-digit floor
143
+ # silently dropped them, so they could never anchor or link.
144
+ _REF_ID_RE = re.compile(r"(?<![:\w-])[A-Z]{1,4}-\d{1,}(?![\w-])")
143
145
 
144
146
 
145
147
  def _slugify(text: str) -> str:
@@ -16,6 +16,13 @@ CC v2.1.178 removed ``TeamDelete``: the implicit team ends with the session and
16
16
  run-end teardown only dismisses teammates via ``shutdown_request``. Reconcile
17
17
  keeps the roster honest so a dismissed-but-dead member does not linger as a
18
18
  live pill — see _common-contract.md "Run-end teammate teardown".
19
+
20
+ Roster resolution (``--project-root``) exists because ``team-state.lead.sessionId``
21
+ is a phase-3 snapshot: Claude Code re-issues the session id across resume /
22
+ compaction mid-run, so the live on-disk roster often sits under a DIFFERENT
23
+ ``session-*`` dir than the snapshot names. Resolving by the CURRENT live session
24
+ (the newest jsonl under the encoded-cwd projects dir) is what makes split-pane
25
+ teardown find the teammates it must dismiss.
19
26
  """
20
27
  from __future__ import annotations
21
28
 
@@ -26,6 +33,8 @@ import sys
26
33
  import tempfile
27
34
  from pathlib import Path
28
35
 
36
+ from okstra_ctl.session import resolve_inproc_lead_session_id
37
+
29
38
 
30
39
  def live_pane_ids() -> tuple[bool, set[str]]:
31
40
  """Return ``(tmux_available, live_pane_ids)``.
@@ -66,6 +75,44 @@ def reconcile_members(config: dict, live_panes: set[str]) -> list[str]:
66
75
  return flipped
67
76
 
68
77
 
78
+ def dismissible_members(config: dict) -> list[str]:
79
+ """Names of every non-lead member — the set the lead may ``shutdown_request``.
80
+
81
+ The lead further filters by confirmed-complete (result path present) before
82
+ dismissing; this just tells it which teammates the resolved roster holds.
83
+ """
84
+ lead = config.get("leadAgentId")
85
+ names: list[str] = []
86
+ for member in config.get("members", []):
87
+ if member.get("agentId") == lead:
88
+ continue
89
+ names.append(member.get("name", member.get("agentId", "?")))
90
+ return names
91
+
92
+
93
+ def _team_config_for_session(session_id: str) -> Path:
94
+ """``~/.claude/teams/session-<prefix>/config.json`` for a session id.
95
+
96
+ The harness names the team dir with the session id's first segment (the
97
+ 8-hex stem before the first dash), never the ``teamName`` label.
98
+ """
99
+ prefix = session_id.split("-", 1)[0]
100
+ return Path.home() / ".claude" / "teams" / f"session-{prefix}" / "config.json"
101
+
102
+
103
+ def _live_team_config(project_root: Path) -> Path | None:
104
+ """Resolve the CURRENT live session's team config, or None when absent.
105
+
106
+ Robust to the mid-run session-id drift that strands the phase-3
107
+ ``lead.sessionId`` snapshot: keys off the live session, not the snapshot.
108
+ """
109
+ sid = resolve_inproc_lead_session_id(project_root)
110
+ if not sid:
111
+ return None
112
+ cand = _team_config_for_session(sid)
113
+ return cand if cand.is_file() else None
114
+
115
+
69
116
  def _config_path(team_arg: str) -> Path:
70
117
  """Resolve a team name, a team dir, or a config.json path to the config file."""
71
118
  p = Path(team_arg)
@@ -92,43 +139,126 @@ def _atomic_write(path: Path, text: str) -> None:
92
139
  raise
93
140
 
94
141
 
95
- def main(argv: list[str]) -> int:
96
- dry_run = False
97
- rest: list[str] = []
98
- for arg in argv:
99
- if arg in ("--list", "--dry-run"):
100
- dry_run = True
101
- else:
102
- rest.append(arg)
103
- if len(rest) != 1:
104
- print("usage: okstra-team-reconcile.sh [--list] <team-name|team-dir|config.json>",
105
- file=sys.stderr)
106
- return 2
107
-
108
- cfg_path = _config_path(rest[0])
109
- if not cfg_path.is_file():
110
- print(f"team-reconcile: no team config at {cfg_path}", file=sys.stderr)
111
- return 1
142
+ def _reconcile_config(cfg_path: Path, dry_run: bool, print_members: bool) -> int:
143
+ """Read, reconcile, and (unless dry-run) write back one config.json.
112
144
 
145
+ When ``print_members`` is set, also emit one ``dismissible-member: <name>``
146
+ line per non-lead member so the lead knows whom to ``shutdown_request``.
147
+ """
113
148
  try:
114
149
  config = json.loads(cfg_path.read_text())
115
150
  except (OSError, json.JSONDecodeError) as exc:
116
151
  print(f"team-reconcile: cannot read team config at {cfg_path}: {exc}",
117
152
  file=sys.stderr)
118
153
  return 1
154
+
119
155
  available, live = live_pane_ids()
120
156
  if not available:
121
157
  print("team-reconcile: tmux unavailable — skipped (no flip)")
158
+ else:
159
+ flipped = reconcile_members(config, live)
160
+ if flipped and dry_run:
161
+ print("team-reconcile (dry-run): would deactivate " + ", ".join(flipped))
162
+ elif flipped:
163
+ _atomic_write(cfg_path, json.dumps(config, indent=2, ensure_ascii=False) + "\n")
164
+ print(f"team-reconcile: deactivated {len(flipped)} stale member(s): "
165
+ + ", ".join(flipped))
166
+ else:
167
+ print("team-reconcile: no stale-active members")
168
+
169
+ if print_members:
170
+ for name in dismissible_members(config):
171
+ print(f"dismissible-member: {name}")
172
+ return 0
173
+
174
+
175
+ def _session_id_from_stdin() -> str:
176
+ """Extract ``session_id`` from a SessionEnd hook's JSON payload on stdin.
177
+
178
+ Returns "" when stdin is a terminal (manual invocation), empty, or not the
179
+ expected JSON — the caller then no-ops. The hook must never block or fail a
180
+ session teardown.
181
+ """
182
+ if sys.stdin.isatty():
183
+ return ""
184
+ try:
185
+ raw = sys.stdin.read()
186
+ except OSError:
187
+ return ""
188
+ if not raw.strip():
189
+ return ""
190
+ try:
191
+ data = json.loads(raw)
192
+ except json.JSONDecodeError:
193
+ return ""
194
+ sid = data.get("session_id") or data.get("sessionId") or ""
195
+ return sid if isinstance(sid, str) else ""
196
+
197
+
198
+ def _run_resolve_and_reconcile(project_root: str, fallback_team: str, dry_run: bool) -> int:
199
+ """``--project-root`` mode: resolve the live roster, reconcile, list members."""
200
+ cfg = _live_team_config(Path(project_root))
201
+ if cfg is None and fallback_team:
202
+ fb = _config_path(fallback_team)
203
+ cfg = fb if fb.is_file() else None
204
+ if cfg is None:
205
+ print("team-reconcile: no live roster for this session — nothing to dismiss")
122
206
  return 0
207
+ print(f"team-reconcile: roster resolved at {cfg}")
208
+ return _reconcile_config(cfg, dry_run, print_members=True)
209
+
123
210
 
124
- flipped = reconcile_members(config, live)
125
- if not flipped:
126
- print("team-reconcile: no stale-active members")
211
+ def _run_session_end(dry_run: bool) -> int:
212
+ """``--session-end`` mode: reconcile the ending session's roster from the
213
+ SessionEnd hook payload. Safety net so a lead that skipped run-end teardown
214
+ still leaves no dead-pane member lingering as a live pill."""
215
+ sid = _session_id_from_stdin()
216
+ if not sid:
127
217
  return 0
128
- if dry_run:
129
- print("team-reconcile (dry-run): would deactivate " + ", ".join(flipped))
218
+ cfg = _team_config_for_session(sid)
219
+ if not cfg.is_file():
130
220
  return 0
221
+ return _reconcile_config(cfg, dry_run, print_members=False)
131
222
 
132
- _atomic_write(cfg_path, json.dumps(config, indent=2, ensure_ascii=False) + "\n")
133
- print(f"team-reconcile: deactivated {len(flipped)} stale member(s): " + ", ".join(flipped))
134
- return 0
223
+
224
+ def _parse_argv(argv: list[str]) -> tuple[bool, str, str, bool, list[str]]:
225
+ dry_run = False
226
+ project_root = ""
227
+ fallback_team = ""
228
+ session_end = False
229
+ rest: list[str] = []
230
+ it = iter(argv)
231
+ for arg in it:
232
+ if arg in ("--list", "--dry-run"):
233
+ dry_run = True
234
+ elif arg == "--project-root":
235
+ project_root = next(it, "")
236
+ elif arg == "--fallback-team":
237
+ fallback_team = next(it, "")
238
+ elif arg == "--session-end":
239
+ session_end = True
240
+ else:
241
+ rest.append(arg)
242
+ return dry_run, project_root, fallback_team, session_end, rest
243
+
244
+
245
+ def main(argv: list[str]) -> int:
246
+ dry_run, project_root, fallback_team, session_end, rest = _parse_argv(argv)
247
+
248
+ if session_end:
249
+ return _run_session_end(dry_run)
250
+ if project_root:
251
+ return _run_resolve_and_reconcile(project_root, fallback_team, dry_run)
252
+
253
+ if len(rest) != 1:
254
+ print("usage: okstra-team-reconcile.sh [--list] "
255
+ "(<team-name|team-dir|config.json> | --project-root <dir> "
256
+ "[--fallback-team <label>] | --session-end)",
257
+ file=sys.stderr)
258
+ return 2
259
+
260
+ cfg_path = _config_path(rest[0])
261
+ if not cfg_path.is_file():
262
+ print(f"team-reconcile: no team config at {cfg_path}", file=sys.stderr)
263
+ return 1
264
+ return _reconcile_config(cfg_path, dry_run, print_members=False)
@@ -1480,6 +1480,12 @@
1480
1480
  "id": { "type": "string", "minLength": 1 },
1481
1481
  "subject": { "type": "string", "minLength": 1 },
1482
1482
  "sourceSection": { "type": "string" },
1483
+ "clarificationId": {
1484
+ "anyOf": [
1485
+ { "type": "null" },
1486
+ { "type": "string", "pattern": "^C-\\d{3,}$" }
1487
+ ]
1488
+ },
1483
1489
  "verdicts": {
1484
1490
  "type": "array",
1485
1491
  "items": {
@@ -341,7 +341,7 @@ implementation-option: {{ frontmatter.implementationOption | yaml_scalar }}
341
341
  {{ t("implementationPlanning.planBodyBreakageLegend") }}
342
342
 
343
343
  {% for item in implementationPlanning.planBodyVerification.planItems %}
344
- **{{ item.id }} — {{ item.subject }}**{% if item.sourceSection %} ({{ t("implementationPlanning.planBodySourceLabel") }} {{ item.sourceSection }}){% endif %}
344
+ **{{ item.id }} — {{ item.subject }}**{% if item.sourceSection %} ({{ t("implementationPlanning.planBodySourceLabel") }} {{ item.sourceSection }}){% endif %}{% if item.clarificationId %} — {{ t("implementationPlanning.planBodyBlockerLabel") }} {{ item.clarificationId }}{% endif %}
345
345
 
346
346
 
347
347
  | Worker | Verdict | Breakage kind | Note |
@@ -95,6 +95,7 @@
95
95
  "planBodyVerdictLegend": "Verdict — **AGREE**: executable as written and internally consistent with other items · **SUPPLEMENT**: item is sound but a dependency / edge case / precondition is missing · **DISAGREE**: has a defect (see Breakage kind) · **verification-error**: the worker produced no result.",
96
96
  "planBodyBreakageLegend": "Breakage kind — a: cited file path/symbol mismatches another step or option · b: command is not executable or is ambiguous · c: validation signal is not observable · d: rollback violates commit/dependency order · e: contradicts the trade-off matrix · f: requirement-coverage row does not map to an option/stage/step that actually satisfies the requirement. (`--` = not applicable)",
97
97
  "planBodySourceLabel": "source §",
98
+ "planBodyBlockerLabel": "blocks approval →",
98
99
  "optionInterfacesLabel": "Affected interfaces / public contracts / downstream consumers",
99
100
  "optionBlastRadiusLabel": "Blast radius estimate",
100
101
  "recommendedTableHeaderLabel": "Item",
@@ -95,6 +95,7 @@
95
95
  "planBodyVerdictLegend": "판정(Verdict) — **AGREE**: 적힌 대로 실행 가능하고 다른 항목과 내부적으로 일관됨 · **SUPPLEMENT**: 항목 자체는 타당하나 의존성·엣지케이스·전제조건이 누락됨 · **DISAGREE**: 결함이 있음(결함 유형 참조) · **verification-error**: 워커 검증이 결과를 내지 못함.",
96
96
  "planBodyBreakageLegend": "결함 유형(Breakage kind) — a: 인용한 파일 경로/심볼이 다른 스텝·옵션과 불일치 · b: 명령이 실행 불가하거나 모호함 · c: 검증 신호가 관측 불가 · d: 롤백이 커밋/의존성 순서를 위반 · e: 트레이드오프 매트릭스와 모순 · f: 요구사항 커버리지 행이 요구사항을 실제로 충족하는 옵션/스테이지/스텝에 매핑되지 않음. (`--` = 해당 없음)",
97
97
  "planBodySourceLabel": "출처 §",
98
+ "planBodyBlockerLabel": "승인 차단 →",
98
99
  "optionInterfacesLabel": "영향 인터페이스 / 공개 계약 / 다운스트림 소비자",
99
100
  "optionBlastRadiusLabel": "폭발 반경 추정",
100
101
  "recommendedTableHeaderLabel": "항목",
@@ -47,6 +47,10 @@
47
47
  {
48
48
  "type": "command",
49
49
  "command": "$HOME/.okstra/bin/okstra-trace-cleanup.sh --reap"
50
+ },
51
+ {
52
+ "type": "command",
53
+ "command": "$HOME/.okstra/bin/okstra-team-reconcile.sh --session-end"
50
54
  }
51
55
  ]
52
56
  }
@@ -35,6 +35,8 @@ Checks performed per brief file:
35
35
  (`scripts/okstra_ctl/improvement_lenses.py` SSOT).
36
36
  12. When present, `Related Task Graph` is a markdown table with the canonical
37
37
  columns and relation/direction values from the okstra-brief contract.
38
+ 13. The requirement/objective section `## Desired Outcome` (required in every
39
+ brief variant) exists and its body is not blank. `_(none)_` stays valid.
38
40
 
39
41
  Exit code 0 on PASS, 1 on FAIL.
40
42
  """
@@ -391,6 +393,39 @@ def check_related_task_graph(text: str, errors: list[str]) -> None:
391
393
  )
392
394
 
393
395
 
396
+ REQUIREMENT_SECTION = "Desired Outcome"
397
+
398
+
399
+ def has_section_heading(text: str, heading: str) -> bool:
400
+ """True when a `## <heading>` line exists (distinct from an empty body)."""
401
+ pattern = re.compile(r"^##\s+" + re.escape(heading) + r"\s*$", re.MULTILINE)
402
+ return pattern.search(text) is not None
403
+
404
+
405
+ def check_requirement_section(text: str, errors: list[str]) -> None:
406
+ """The requirement/objective section must exist and carry a non-blank body.
407
+
408
+ `## Desired Outcome` is the "shape of success" required by every brief
409
+ variant (okstra-brief SKILL.md Step 5 §"Required sections by variant").
410
+ Without this check a brief that drops the heading — or wipes its body —
411
+ still passes, letting implementation-planning report 100% Requirement
412
+ Coverage against an empty objective. `_(none)_` stays valid: it is the
413
+ contract's explicit placeholder for a deliberately-empty gap (SKILL.md
414
+ Step 4 stop conditions), not a missing requirement.
415
+ """
416
+ if not has_section_heading(text, REQUIREMENT_SECTION):
417
+ errors.append(
418
+ f"required section '## {REQUIREMENT_SECTION}' is missing "
419
+ "(every brief variant must state the shape of success)"
420
+ )
421
+ return
422
+ if not section_body(text, REQUIREMENT_SECTION).strip():
423
+ errors.append(
424
+ f"required section '## {REQUIREMENT_SECTION}' has an empty body "
425
+ "(use _(none)_ only for a deliberately-empty gap)"
426
+ )
427
+
428
+
394
429
  def check_reporter_confirmations(
395
430
  rc_status: str | None, reporter_rows: list[str], errors: list[str]
396
431
  ) -> None:
@@ -452,6 +487,8 @@ def validate_brief(path: Path, briefs_root: Path) -> list[str]:
452
487
 
453
488
  check_related_task_graph(text, errors)
454
489
 
490
+ check_requirement_section(text, errors)
491
+
455
492
  # 2. brief-id matches filename stem
456
493
  stem = path.stem
457
494
  if fm.get("brief-id") and fm["brief-id"] != stem:
@@ -185,7 +185,10 @@ def _check_each_stage_section(text: str, stages: List[StageMeta]) -> List[Valida
185
185
  _LABEL_PREFIX = r"^\s*(?:[-*]\s+)?(?:\*\*)?"
186
186
  SLICE_VALUE = re.compile(_LABEL_PREFIX + r"Slice value\s*:\s*(?:\*\*)?\s*(.+?)\s*$", re.M)
187
187
  ACCEPTANCE = re.compile(_LABEL_PREFIX + r"Acceptance\s*:\s*(?:\*\*)?\s*(.+?)\s*$", re.M)
188
- TDD_EXEMPTION = re.compile(_LABEL_PREFIX + r"TDD exemption\s*:\s*(?:\*\*)?\s*\S", re.M)
188
+ TDD_EXEMPTION = re.compile(_LABEL_PREFIX + r"TDD exemption\s*:\s*(?:\*\*)?\s*(.+?)\s*$", re.M)
189
+ # Profile implementation-planning.md:81 limits the exemption to these three
190
+ # categories; any other reason (e.g. "refactor") must not waive RED/GREEN.
191
+ TDD_EXEMPTION_ALLOWED = ("doc-only", "config-only", "pure-rename")
189
192
  TEST_CASE_CATEGORIES = ("success", "boundary", "failure")
190
193
  TEST_CASE = {
191
194
  cat: re.compile(
@@ -197,17 +200,32 @@ CONFORMANCE_TESTS = re.compile(_LABEL_PREFIX + r"Conformance tests\s*:\s*(?:\*\*
197
200
  CONFORMANCE_EXEMPTION = re.compile(_LABEL_PREFIX + r"Conformance exemption\s*:\s*(?:\*\*)?\s*\S", re.M)
198
201
 
199
202
 
203
+ def _exemption_reason_allowed(section: str) -> bool:
204
+ """True when a `TDD exemption:` line is present AND its reason names an
205
+ allowed category (doc-only / config-only / pure-rename, case-insensitive).
206
+ A present-but-unlisted reason returns False so S10e can reject it."""
207
+ m = TDD_EXEMPTION.search(section)
208
+ if not m:
209
+ return False
210
+ reason = m.group(1).lower()
211
+ return any(cat in reason for cat in TDD_EXEMPTION_ALLOWED)
212
+
213
+
200
214
  def _check_slice_tdd(text: str, stages: List[StageMeta]) -> List[ValidationError]:
201
215
  """S10: each stage declares a vertical slice and follows RED→GREEN ordering.
202
216
 
203
217
  S10a — `Slice value:` line with a non-empty value.
204
218
  S10b — `Acceptance:` line with a non-empty value.
205
- S10c — first effective Stepwise step's action starts with `RED:` AND some
206
- action starts with `GREEN:`, OR a `TDD exemption:` line is present.
219
+ S10c — first effective Stepwise step's action starts with `RED:` (its
220
+ `expected` cell reading FAIL) AND some action starts with `GREEN:`
221
+ (its `expected` cell reading PASS), OR a valid `TDD exemption:` line.
207
222
  S10d — stage declares all three `Test case (success|boundary|failure):`
208
- lines with non-empty values, OR a `TDD exemption:` line is present.
223
+ lines with non-empty values, OR a valid `TDD exemption:` line.
209
224
  Forces the plan to address the happy path, edge/boundary inputs, and
210
225
  failure/negative inputs instead of a single acceptance assertion.
226
+ S10e — a present `TDD exemption:` line's reason must name an allowed
227
+ category (doc-only / config-only / pure-rename); an arbitrary reason
228
+ must not waive RED/GREEN.
211
229
  """
212
230
  errs: List[ValidationError] = []
213
231
  for s in stages:
@@ -223,6 +241,11 @@ def _check_slice_tdd(text: str, stages: List[StageMeta]) -> List[ValidationError
223
241
  "S10b: 'Acceptance:' line missing or empty"))
224
242
 
225
243
  if TDD_EXEMPTION.search(section):
244
+ if not _exemption_reason_allowed(section):
245
+ errs.append(ValidationError("S10", s.stage_number,
246
+ "S10e: 'TDD exemption:' reason must be one of "
247
+ "doc-only / config-only / pure-rename — an arbitrary reason "
248
+ "cannot waive RED/GREEN"))
226
249
  continue
227
250
 
228
251
  missing_cases = [
@@ -234,14 +257,34 @@ def _check_slice_tdd(text: str, stages: List[StageMeta]) -> List[ValidationError
234
257
  "line(s) — declare a non-empty success, boundary, and failure "
235
258
  "case each, or add a 'TDD exemption:' line"))
236
259
 
237
- rows = _effective_step_rows(section)
238
- actions = [r[1] for r in rows if len(r) > 1]
239
- first_is_red = bool(actions) and actions[0].startswith("RED:")
240
- has_green = any(a.startswith("GREEN:") for a in actions)
241
- if not (first_is_red and has_green):
242
- errs.append(ValidationError("S10", s.stage_number,
243
- "S10c: first step action must start with 'RED:' and some "
244
- "step action with 'GREEN:', or add a 'TDD exemption:' line"))
260
+ errs.extend(_check_red_green_steps(section, s.stage_number))
261
+ return errs
262
+
263
+
264
+ def _check_red_green_steps(section: str, stage_number: int) -> List[ValidationError]:
265
+ """S10c: RED-first + later GREEN, with each step's `expected` cell matching
266
+ its prefix (RED FAIL, GREEN PASS). Only certain violations fire."""
267
+ rows = _effective_step_rows(section)
268
+ steps = [(r[1], r[4]) for r in rows if len(r) > 4]
269
+ actions = [a for a, _ in steps]
270
+ first_is_red = bool(actions) and actions[0].startswith("RED:")
271
+ has_green = any(a.startswith("GREEN:") for a in actions)
272
+ errs: List[ValidationError] = []
273
+ if not (first_is_red and has_green):
274
+ errs.append(ValidationError("S10", stage_number,
275
+ "S10c: first step action must start with 'RED:' and some "
276
+ "step action with 'GREEN:', or add a 'TDD exemption:' line"))
277
+ return errs
278
+ for action, expected in steps:
279
+ exp = expected.upper()
280
+ if action.startswith("RED:") and "FAIL" not in exp:
281
+ errs.append(ValidationError("S10", stage_number,
282
+ "S10c: 'RED:' step's expected cell must read FAIL, got "
283
+ f"'{expected}'"))
284
+ elif action.startswith("GREEN:") and "PASS" not in exp:
285
+ errs.append(ValidationError("S10", stage_number,
286
+ "S10c: 'GREEN:' step's expected cell must read PASS, got "
287
+ f"'{expected}'"))
245
288
  return errs
246
289
 
247
290
 
@@ -861,6 +861,57 @@ def _task_root_from_run_dir(run_dir: Path) -> Path:
861
861
  return run_dir.parent.parent
862
862
 
863
863
 
864
+ def _validate_planning_conformance_declared(report_path: Path, failures: list[str]) -> None:
865
+ """H4-c — at planning time, every stage that DECLARES `Conformance tests:`
866
+ must already carry a matching entry in the shared task-level
867
+ `qa/conformance-manifest.json`. The profile mandates writing the script +
868
+ manifest entry as part of emitting that line
869
+ (implementation-planning.md:83/85); without this check a declaration that
870
+ was never materialized only surfaces much later at the `implementation`
871
+ entry gate. Matches by stageKey suffix (`-stage-<N>`) like
872
+ `_scope_manifest_entries`; skips stages that took a `Conformance exemption:`.
873
+ """
874
+ data_path = report_path.with_suffix(".data.json")
875
+ if not data_path.is_file():
876
+ return
877
+ try:
878
+ data = json.loads(data_path.read_text(encoding="utf-8"))
879
+ except (OSError, json.JSONDecodeError):
880
+ return
881
+ ip = data.get("implementationPlanning")
882
+ if not isinstance(ip, dict):
883
+ return
884
+ declaring_stages = [
885
+ s.get("stage")
886
+ for s in (ip.get("stages") or [])
887
+ if isinstance(s, dict) and str(s.get("conformanceTests") or "").strip()
888
+ ]
889
+ if not declaring_stages:
890
+ return
891
+ task_root = _task_root_from_run_dir(report_path.parent.parent)
892
+ manifest_path = task_root / "qa" / "conformance-manifest.json"
893
+ entries = []
894
+ if manifest_path.is_file():
895
+ try:
896
+ entries = json.loads(manifest_path.read_text()).get("entries") or []
897
+ except (OSError, json.JSONDecodeError):
898
+ entries = []
899
+ for stage_number in declaring_stages:
900
+ suffix = f"-stage-{stage_number}"
901
+ has_entry = any(
902
+ isinstance(e, dict) and str(e.get("stageKey") or "").endswith(suffix)
903
+ for e in entries
904
+ )
905
+ if not has_entry:
906
+ failures.append(
907
+ f"final-report data.json: stage {stage_number} declares "
908
+ "`Conformance tests:` but no matching entry exists in "
909
+ f"{manifest_path} (stageKey ending `{suffix}`). Emitting the "
910
+ "declaration MUST also write the manifest entry + script "
911
+ "(implementation-planning.md §Conformance)."
912
+ )
913
+
914
+
864
915
  def _validate_conformance(report_path: Path, failures: list[str],
865
916
  surface_patterns: object = None) -> None:
866
917
  """Tier 3 conformance 게이트(implementation / final-verification).
@@ -1476,6 +1527,7 @@ def validate_final_report_data(report_path: Path, failures: list[str]) -> None:
1476
1527
  failures.append(f"final-report data.json: {err}")
1477
1528
 
1478
1529
  _validate_rationale_evidence(data, failures)
1530
+ _validate_no_opaque_id_references(data, failures)
1479
1531
 
1480
1532
  task_type = (data.get("header") or {}).get("taskType")
1481
1533
  if task_type == "final-verification":
@@ -1483,10 +1535,17 @@ def validate_final_report_data(report_path: Path, failures: list[str]) -> None:
1483
1535
  elif task_type == "implementation-planning":
1484
1536
  _validate_implementation_planning_cross_project(data, failures)
1485
1537
  _validate_implementation_planning_decision_drafts(data, failures)
1538
+ _validate_plan_body_gate_recompute(data, failures)
1539
+ _validate_plan_item_extraction_completeness(data, failures)
1540
+ _validate_plan_item_subject_substance(data, failures)
1541
+ _validate_plan_body_clarification_matching(data, failures)
1542
+ _validate_requirement_coverage_covered_by(data, failures)
1486
1543
 
1487
1544
 
1488
- # path:line (foo.service.ts:268), report ID (C-001 / F-013 / G-crit-002 / RC-1),
1489
- # or a section reference (§5.4) — any one satisfies "this claim is anchored".
1545
+ # path:line (foo.service.ts:268), an in-report ID (C-001 / E-006 / P-004), a
1546
+ # namespaced audit ref (claude:F-005), or a §section reference (§5.4) — any one
1547
+ # satisfies "this claim is anchored". A bare brief/worker ID (RC-*/RF-*/F-*) is
1548
+ # handled separately by `_validate_no_opaque_id_references`.
1490
1549
  _EVIDENCE_TOKEN = re.compile(
1491
1550
  r"[\w./-]+\.\w+:\d+|\b[A-Z]{1,3}(?:-[a-z]+)?-\d+\b|§\s*\d")
1492
1551
  # Explicit "I don't know" escapes — anti-fabrication's other valid answer.
@@ -1517,12 +1576,61 @@ def _validate_rationale_evidence(data: dict, failures: list[str]) -> None:
1517
1576
  continue
1518
1577
  failures.append(
1519
1578
  f"final-report data.json: rationale.{field} cites no evidence "
1520
- f"(expected a path:line, a report ID like C-001/F-013, or a §"
1579
+ f"(expected a path:line, an in-report ID like C-001, or a §"
1521
1580
  f"section reference) and gives no explicit insufficiency marker "
1522
1581
  f"(e.g. '근거 불충분'). Anchor the claim or state what is unknown."
1523
1582
  )
1524
1583
 
1525
1584
 
1585
+ # Brief/worker-internal ID families that are NOT surfaced (anchored) in the
1586
+ # final report: `RC-` reporter confirmations live in the brief, `F-`/`RF-`
1587
+ # findings live in worker-results. A reader-facing sentence citing a bare one
1588
+ # of these has no in-report target to resolve — the reader is stuck. Namespaced
1589
+ # audit citations (`claude:F-005`) are exempt via the `:` negative lookbehind.
1590
+ _OPAQUE_ID_RE = re.compile(r"(?<![:\w-])(?:RC|RF|F)-\d+(?![\w-])")
1591
+
1592
+
1593
+ def _reader_facing_narrative(data: dict) -> list[tuple[str, str]]:
1594
+ """(label, text) for every reader-facing narrative string in the report —
1595
+ the fields a human reads to act, where an unresolvable ID is most harmful."""
1596
+ out: list[tuple[str, str]] = []
1597
+ for section in ("verdictCard", "finalVerdict"):
1598
+ block = data.get(section)
1599
+ if isinstance(block, dict):
1600
+ for field in ("finalConclusion", "nextStep"):
1601
+ value = block.get(field)
1602
+ if isinstance(value, str):
1603
+ out.append((f"{section}.{field}", value))
1604
+ rationale = data.get("rationale")
1605
+ if isinstance(rationale, dict):
1606
+ for field in _RATIONALE_FIELDS:
1607
+ value = rationale.get(field)
1608
+ if isinstance(value, str):
1609
+ out.append((f"rationale.{field}", value))
1610
+ for row in data.get("clarificationItems") or []:
1611
+ if isinstance(row, dict) and isinstance(row.get("statement"), str):
1612
+ out.append((f"clarificationItems[{row.get('id') or '?'}].statement", row["statement"]))
1613
+ return out
1614
+
1615
+
1616
+ def _validate_no_opaque_id_references(data: dict, failures: list[str]) -> None:
1617
+ """Reader-facing prose must not cite a bare brief/worker-internal ID
1618
+ (`RC-*`, `RF-*`, non-namespaced `F-*`) that the report never surfaces — the
1619
+ reader has nothing to click and no definition to find. Expand it inline
1620
+ (`the confirmed version target 1.27.47→1.27.48`) or namespace an audit
1621
+ citation (`claude:F-005`)."""
1622
+ for label, text in _reader_facing_narrative(data):
1623
+ offenders = sorted({m.group(0) for m in _OPAQUE_ID_RE.finditer(text)})
1624
+ if offenders:
1625
+ failures.append(
1626
+ f"final-report data.json: {label} cites brief/worker-internal "
1627
+ f"ID(s) {', '.join(offenders)} that this report never surfaces "
1628
+ "(no anchor to resolve them to). Expand them to plain language "
1629
+ "on the reference, or namespace an audit citation like "
1630
+ "`claude:F-005` — a reader cannot resolve a bare RC-/RF-/F- token."
1631
+ )
1632
+
1633
+
1526
1634
  def _validate_implementation_planning_cross_project(data: dict, failures: list[str]) -> None:
1527
1635
  """타 프로젝트 의존을 DM 행(`kind == 'cross-project'`)으로 선언했다면
1528
1636
  `crossProjectDependencies` 에 `direction == 'upstream-precondition'` 행이
@@ -1584,6 +1692,302 @@ def _validate_implementation_planning_decision_drafts(data: dict, failures: list
1584
1692
  )
1585
1693
 
1586
1694
 
1695
+ # Plan-body gate outcomes ranked by how favorable each is to approval.
1696
+ # A higher rank claims a healthier verification result. The recompute check
1697
+ # below fails only when the *declared* gate outranks what the recorded
1698
+ # per-worker verdicts support — i.e. the lead claimed a better outcome than
1699
+ # the votes justify. A lead writing a conservatively *worse* gate is allowed,
1700
+ # so genuine edge cases in this recompute never manufacture false failures.
1701
+ _PLAN_GATE_RANK = {
1702
+ "aborted-non-result": 0,
1703
+ "blocked-by-disagreement": 0,
1704
+ "passed-with-dissent": 1,
1705
+ "passed": 2,
1706
+ }
1707
+
1708
+ # Breakage kinds where a single DISAGREE blocks the gate on its own (no majority
1709
+ # needed), because the defect is concrete, safety-critical, and adversarially
1710
+ # verifiable: `a` = cited path/symbol mismatch, `d` = rollback violates
1711
+ # commit/dependency order. `b`/`c`/`e` still need a majority — `b` in particular
1712
+ # is prone to planning-vs-implementation environment false positives.
1713
+ _SINGLE_VOTE_BLOCKING_KINDS = {"a", "d"}
1714
+
1715
+
1716
+ def _classify_plan_item_gate(item: dict) -> str:
1717
+ """Recompute one plan item's gate class from its per-worker verdicts,
1718
+ per `prompts/lead/convergence.md` "Round protocol". Returns one of
1719
+ ``majority-disagree`` / ``has-dissent`` / ``full-consensus`` /
1720
+ ``all-non-result``. Collapses ``partial-consensus`` and
1721
+ ``dissent-isolated`` into ``has-dissent`` because they resolve to the
1722
+ same gate value; only the majority-disagree boundary changes the gate.
1723
+ """
1724
+ tokens = [
1725
+ (
1726
+ str(v.get("verdict") or "").strip().upper(),
1727
+ str(v.get("breakageKind") or "").strip().lower(),
1728
+ )
1729
+ for v in (item.get("verdicts") or [])
1730
+ if isinstance(v, dict)
1731
+ ]
1732
+ non_error = [(vd, bk) for (vd, bk) in tokens if vd and vd != "VERIFICATION-ERROR"]
1733
+ if not non_error:
1734
+ return "all-non-result"
1735
+ disagree = [(vd, bk) for (vd, bk) in non_error if vd == "DISAGREE"]
1736
+ agree = [(vd, bk) for (vd, bk) in non_error if vd in ("AGREE", "SUPPLEMENT")]
1737
+ disagree_kinds = {bk for (_vd, bk) in disagree if bk}
1738
+ if not disagree:
1739
+ return "full-consensus"
1740
+ # Single-vote-blocking kinds: one confirmed DISAGREE on a concrete,
1741
+ # safety-critical, adversarially-verifiable defect is enough to block, even
1742
+ # in a two-worker roster — a lone correct dissent must not be outvoted here.
1743
+ # `a`/`d` for any item; `f` only for P-Req items (requirement coverage).
1744
+ is_req = str(item.get("id") or "").upper().startswith("P-REQ")
1745
+ if disagree_kinds & _SINGLE_VOTE_BLOCKING_KINDS or (is_req and "f" in disagree_kinds):
1746
+ return "majority-disagree"
1747
+ # Otherwise a genuine majority is required — and a majority needs at least
1748
+ # two participating votes, so a lone surviving DISAGREE (its peer returned a
1749
+ # non-result) does NOT block. That fixes the paradox where a worker failure
1750
+ # made the gate stricter than a healthy roster would.
1751
+ if len(non_error) >= 2 and len(disagree) > len(agree):
1752
+ return "majority-disagree"
1753
+ return "has-dissent"
1754
+
1755
+
1756
+ def _recompute_plan_body_gate(pbv: dict) -> str | None:
1757
+ """Recompute the whole §5.5.9 gate value from ``planItems[].verdicts``.
1758
+ Returns a value in ``PLAN_VERIFY_GATE_VALUES`` or ``None`` when there are
1759
+ no plan items to judge (disabled / empty round)."""
1760
+ classes = [
1761
+ _classify_plan_item_gate(it)
1762
+ for it in (pbv.get("planItems") or [])
1763
+ if isinstance(it, dict)
1764
+ ]
1765
+ if not classes:
1766
+ return None
1767
+ if all(c == "all-non-result" for c in classes):
1768
+ return "aborted-non-result"
1769
+ if any(c == "majority-disagree" for c in classes):
1770
+ return "blocked-by-disagreement"
1771
+ if any(c == "has-dissent" for c in classes):
1772
+ return "passed-with-dissent"
1773
+ return "passed"
1774
+
1775
+
1776
+ def _validate_plan_body_gate_recompute(data: dict, failures: list[str]) -> None:
1777
+ """H1 — the declared `Gate result` must not claim a healthier outcome than
1778
+ the recorded per-worker verdicts support. Closes the forgery hole where a
1779
+ lead writes `gateResult: passed` while workers actually voted DISAGREE:
1780
+ the verdicts live in `planItems[].verdicts`, so the gate is recomputable
1781
+ and no longer depends on the lead's honesty alone.
1782
+ """
1783
+ ip = data.get("implementationPlanning")
1784
+ if not isinstance(ip, dict):
1785
+ return
1786
+ pbv = ip.get("planBodyVerification")
1787
+ if not isinstance(pbv, dict):
1788
+ return
1789
+ declared = str(pbv.get("gateResult") or "").strip().lower()
1790
+ recomputed = _recompute_plan_body_gate(pbv)
1791
+ if recomputed is None or declared not in _PLAN_GATE_RANK:
1792
+ return
1793
+ if _PLAN_GATE_RANK[declared] > _PLAN_GATE_RANK[recomputed]:
1794
+ failures.append(
1795
+ "final-report data.json: implementationPlanning.planBodyVerification "
1796
+ f"`gateResult` is `{declared}` but the recorded planItems[].verdicts "
1797
+ f"only support `{recomputed}` (a majority DISAGREE, a DISAGREE(f) on "
1798
+ "a P-Req item, or all-non-result dispatches were recorded). The gate "
1799
+ "value must honestly aggregate the worker votes — do not upgrade it "
1800
+ "to unblock the run (convergence.md Round protocol)."
1801
+ )
1802
+
1803
+
1804
+ # Each plan-body deliverable array and the P-* verdict-item prefix it must be
1805
+ # extracted into for §5.5.9 cross-verification (convergence.md Plan-item
1806
+ # extraction). A weak item dropped from planItems escapes the gate entirely.
1807
+ _PLAN_ITEM_SOURCES = (
1808
+ ("optionCandidates", "P-Opt", "Option Candidates (§4.5.1)"),
1809
+ ("stepwiseExecution", "P-Step", "Stepwise Execution Order (§4.5.4)"),
1810
+ ("dependencyMigrationRisk", "P-Dep", "Dependency / Migration Risk (§4.5.5)"),
1811
+ ("validationChecklist", "P-Val", "Validation Checklist (§4.5.6)"),
1812
+ ("rollbackStrategy", "P-Rb", "Rollback Strategy (§4.5.7)"),
1813
+ ("requirementCoverage", "P-Req", "Requirement Coverage (§4.5.8)"),
1814
+ )
1815
+
1816
+
1817
+ def _validate_plan_item_extraction_completeness(data: dict, failures: list[str]) -> None:
1818
+ """H2 — when a verification round ran, every plan-body deliverable item must
1819
+ appear as a matching `P-*` verdict item. Closes the omission hole where the
1820
+ lead silently drops a weak item (e.g. an out-of-order rollback) from
1821
+ planItems so no worker ever verifies it, yet the gate still reads passed.
1822
+ Requires only that no category is *under-extracted*; over-extraction is fine.
1823
+ """
1824
+ ip = data.get("implementationPlanning")
1825
+ if not isinstance(ip, dict):
1826
+ return
1827
+ pbv = ip.get("planBodyVerification")
1828
+ if not isinstance(pbv, dict):
1829
+ return
1830
+ round_count = pbv.get("roundCount")
1831
+ if not isinstance(round_count, int) or round_count < 1:
1832
+ return
1833
+ extracted_ids = [
1834
+ str(it.get("id") or "").upper()
1835
+ for it in (pbv.get("planItems") or [])
1836
+ if isinstance(it, dict)
1837
+ ]
1838
+ for field, prefix, label in _PLAN_ITEM_SOURCES:
1839
+ source_count = len([r for r in (ip.get(field) or []) if isinstance(r, dict)])
1840
+ if source_count == 0:
1841
+ continue
1842
+ extracted = len(
1843
+ [i for i in extracted_ids if i.startswith(prefix.upper() + "-")]
1844
+ )
1845
+ if extracted < source_count:
1846
+ failures.append(
1847
+ f"final-report data.json: {label} has {source_count} item(s) but "
1848
+ f"planBodyVerification.planItems carries only {extracted} "
1849
+ f"`{prefix}-*` verdict item(s). Every plan-body item MUST be "
1850
+ "extracted for cross-verification — a dropped item escapes the "
1851
+ "gate (convergence.md Plan-item extraction)."
1852
+ )
1853
+
1854
+
1855
+ # A `subject` this short or shaped like a bare `P-Opt-1` id is a placeholder,
1856
+ # not the plain-language "what this item is" label §5.5.9 renders as a heading.
1857
+ _MIN_SUBJECT_LEN = 3
1858
+ _BARE_PLAN_ITEM_ID_RE = re.compile(r"^P-(?:Opt|Step|Dep|Val|Rb|Req)-\d+$", re.IGNORECASE)
1859
+
1860
+
1861
+ def _validate_plan_item_subject_substance(data: dict, failures: list[str]) -> None:
1862
+ """H2 follow-up — `planItems[].subject` must be a real label, not a
1863
+ placeholder. The schema only enforces non-empty, so `"x"` or a copied
1864
+ `P-Opt-1` id would otherwise slip through and defeat the whole point of the
1865
+ subject (letting a reader see *what* each AGREE/DISAGREE is about).
1866
+ """
1867
+ ip = data.get("implementationPlanning")
1868
+ if not isinstance(ip, dict):
1869
+ return
1870
+ pbv = ip.get("planBodyVerification")
1871
+ if not isinstance(pbv, dict):
1872
+ return
1873
+ for item in pbv.get("planItems") or []:
1874
+ if not isinstance(item, dict):
1875
+ continue
1876
+ item_id = str(item.get("id") or "").strip()
1877
+ subject = str(item.get("subject") or "").strip()
1878
+ if (
1879
+ len(subject) < _MIN_SUBJECT_LEN
1880
+ or subject == item_id
1881
+ or _BARE_PLAN_ITEM_ID_RE.match(subject)
1882
+ ):
1883
+ failures.append(
1884
+ f"final-report data.json: plan item `{item_id or '<unknown>'}` has a "
1885
+ f"placeholder subject `{subject}`. Give a plain-language label of "
1886
+ "what the item is (e.g. 'Option A: upload v2 를 신규 모듈로 분리') so "
1887
+ "the §5.5.9 reader knows what each verdict is about "
1888
+ "(convergence.md Plan-item extraction)."
1889
+ )
1890
+
1891
+
1892
+ def _validate_plan_body_clarification_matching(data: dict, failures: list[str]) -> None:
1893
+ """H5 — every plan item that the recorded verdicts make `majority-disagree`
1894
+ must point (via `clarificationId`) at an existing `blocks: approval`
1895
+ clarification row. Closes the hole where a majority-disagree item blocks the
1896
+ gate but no §1 Clarification row is raised, so the user never sees why
1897
+ approval is withheld (convergence.md self-review step 7).
1898
+ """
1899
+ ip = data.get("implementationPlanning")
1900
+ if not isinstance(ip, dict):
1901
+ return
1902
+ pbv = ip.get("planBodyVerification")
1903
+ if not isinstance(pbv, dict):
1904
+ return
1905
+ round_count = pbv.get("roundCount")
1906
+ if not isinstance(round_count, int) or round_count < 1:
1907
+ return
1908
+ clar_rows = [r for r in (data.get("clarificationItems") or []) if isinstance(r, dict)]
1909
+ all_ids = {r.get("id") for r in clar_rows if r.get("id")}
1910
+ approval_ids = {r.get("id") for r in clar_rows if r.get("blocks") == "approval" and r.get("id")}
1911
+ for item in pbv.get("planItems") or []:
1912
+ if not isinstance(item, dict):
1913
+ continue
1914
+ if _classify_plan_item_gate(item) != "majority-disagree":
1915
+ continue
1916
+ item_id = item.get("id") or "<unknown>"
1917
+ cid = item.get("clarificationId")
1918
+ if not cid:
1919
+ failures.append(
1920
+ f"final-report data.json: plan item `{item_id}` is majority-disagree "
1921
+ "but carries no `clarificationId`. A blocking disagreement MUST "
1922
+ "surface as a `## 1. Clarification Items` row (blocks=approval) so "
1923
+ "the user sees the blocker (convergence.md self-review step 7)."
1924
+ )
1925
+ elif cid not in approval_ids:
1926
+ reason = (
1927
+ "references a non-existent §1 row"
1928
+ if cid not in all_ids
1929
+ else "references a §1 row whose `blocks` is not `approval`"
1930
+ )
1931
+ failures.append(
1932
+ f"final-report data.json: plan item `{item_id}` (majority-disagree) "
1933
+ f"has clarificationId `{cid}` which {reason}. Every majority-disagree "
1934
+ "item MUST map 1:1 to a `blocks: approval` Clarification row."
1935
+ )
1936
+
1937
+
1938
+ _COVERED_BY_ANCHOR_RE = re.compile(r"option|stage|step", re.IGNORECASE)
1939
+ _COVERED_BY_STAGE_REF_RE = re.compile(r"stage\s*(\d+)", re.IGNORECASE)
1940
+ _COVERED_BY_VAGUE = {"recommended option", "the recommended option", "recommended"}
1941
+
1942
+
1943
+ def _validate_requirement_coverage_covered_by(data: dict, failures: list[str]) -> None:
1944
+ """H3 (partial) — a `covered` requirement row's `coveredBy` must name a
1945
+ concrete plan element that actually exists, not the spec-forbidden bare
1946
+ "recommended option" nor a phantom stage. Whether the cited step truly
1947
+ *satisfies* the requirement stays a worker DISAGREE(f) judgment; this closes
1948
+ the coarser hole where `coveredBy` points at nothing real.
1949
+ """
1950
+ ip = data.get("implementationPlanning")
1951
+ if not isinstance(ip, dict):
1952
+ return
1953
+ rows = [r for r in (ip.get("requirementCoverage") or []) if isinstance(r, dict)]
1954
+ if not rows:
1955
+ return
1956
+ stage_numbers = {
1957
+ s.get("stage") for s in (ip.get("stages") or []) if isinstance(s, dict)
1958
+ }
1959
+ for row in rows:
1960
+ if row.get("status") != "covered":
1961
+ continue
1962
+ rid = row.get("id") or "<row>"
1963
+ covered = str(row.get("coveredBy") or "").strip()
1964
+ if covered.lower() in _COVERED_BY_VAGUE:
1965
+ failures.append(
1966
+ f"final-report data.json: requirementCoverage `{rid}` is `covered` "
1967
+ f"but coveredBy is just `{covered}`. Name the specific Option "
1968
+ "Candidate and Stage/Step that satisfies it, not 'recommended "
1969
+ "option' (profile Requirement Coverage)."
1970
+ )
1971
+ continue
1972
+ if not _COVERED_BY_ANCHOR_RE.search(covered):
1973
+ failures.append(
1974
+ f"final-report data.json: requirementCoverage `{rid}` coveredBy "
1975
+ f"`{covered}` names no Option / Stage / Step. A `covered` row must "
1976
+ "cite the concrete plan element that satisfies the requirement."
1977
+ )
1978
+ continue
1979
+ phantom = [
1980
+ n for m in _COVERED_BY_STAGE_REF_RE.finditer(covered)
1981
+ if stage_numbers and (n := int(m.group(1))) not in stage_numbers
1982
+ ]
1983
+ if phantom:
1984
+ failures.append(
1985
+ f"final-report data.json: requirementCoverage `{rid}` coveredBy "
1986
+ f"cites Stage {phantom[0]} which does not exist in the Stage Map. "
1987
+ "A coverage row must point at a real stage."
1988
+ )
1989
+
1990
+
1587
1991
  def _validate_final_verification_consistency(data: dict, failures: list[str]) -> None:
1588
1992
  """Enforce verdict ↔ blocker/condition/routing consistency on the
1589
1993
  final-verification data.json (SSOT). The schema guarantees field SHAPE;
@@ -2424,6 +2828,8 @@ def main() -> int:
2424
2828
  except (OSError, json.JSONDecodeError):
2425
2829
  _sp = None
2426
2830
  _validate_conformance(report_path, failures, surface_patterns=_sp)
2831
+ if task_type == "implementation-planning":
2832
+ _validate_planning_conformance_declared(report_path, failures)
2427
2833
  if task_type == "improvement-discovery":
2428
2834
  brief_relative = str(task_manifest.get("taskBriefPath") or "").strip()
2429
2835
  brief_path = (