okstra 0.107.0 → 0.107.1

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.1",
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.1",
3
+ "builtAt": "2026-07-01T07:21:31.825Z",
4
4
  "repoRoot": "/home/runner/work/okstra/okstra"
5
5
  }
@@ -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
@@ -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
 
@@ -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 가 책임진다.
@@ -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": "항목",
@@ -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).
@@ -1483,6 +1534,11 @@ def validate_final_report_data(report_path: Path, failures: list[str]) -> None:
1483
1534
  elif task_type == "implementation-planning":
1484
1535
  _validate_implementation_planning_cross_project(data, failures)
1485
1536
  _validate_implementation_planning_decision_drafts(data, failures)
1537
+ _validate_plan_body_gate_recompute(data, failures)
1538
+ _validate_plan_item_extraction_completeness(data, failures)
1539
+ _validate_plan_item_subject_substance(data, failures)
1540
+ _validate_plan_body_clarification_matching(data, failures)
1541
+ _validate_requirement_coverage_covered_by(data, failures)
1486
1542
 
1487
1543
 
1488
1544
  # path:line (foo.service.ts:268), report ID (C-001 / F-013 / G-crit-002 / RC-1),
@@ -1584,6 +1640,302 @@ def _validate_implementation_planning_decision_drafts(data: dict, failures: list
1584
1640
  )
1585
1641
 
1586
1642
 
1643
+ # Plan-body gate outcomes ranked by how favorable each is to approval.
1644
+ # A higher rank claims a healthier verification result. The recompute check
1645
+ # below fails only when the *declared* gate outranks what the recorded
1646
+ # per-worker verdicts support — i.e. the lead claimed a better outcome than
1647
+ # the votes justify. A lead writing a conservatively *worse* gate is allowed,
1648
+ # so genuine edge cases in this recompute never manufacture false failures.
1649
+ _PLAN_GATE_RANK = {
1650
+ "aborted-non-result": 0,
1651
+ "blocked-by-disagreement": 0,
1652
+ "passed-with-dissent": 1,
1653
+ "passed": 2,
1654
+ }
1655
+
1656
+ # Breakage kinds where a single DISAGREE blocks the gate on its own (no majority
1657
+ # needed), because the defect is concrete, safety-critical, and adversarially
1658
+ # verifiable: `a` = cited path/symbol mismatch, `d` = rollback violates
1659
+ # commit/dependency order. `b`/`c`/`e` still need a majority — `b` in particular
1660
+ # is prone to planning-vs-implementation environment false positives.
1661
+ _SINGLE_VOTE_BLOCKING_KINDS = {"a", "d"}
1662
+
1663
+
1664
+ def _classify_plan_item_gate(item: dict) -> str:
1665
+ """Recompute one plan item's gate class from its per-worker verdicts,
1666
+ per `prompts/lead/convergence.md` "Round protocol". Returns one of
1667
+ ``majority-disagree`` / ``has-dissent`` / ``full-consensus`` /
1668
+ ``all-non-result``. Collapses ``partial-consensus`` and
1669
+ ``dissent-isolated`` into ``has-dissent`` because they resolve to the
1670
+ same gate value; only the majority-disagree boundary changes the gate.
1671
+ """
1672
+ tokens = [
1673
+ (
1674
+ str(v.get("verdict") or "").strip().upper(),
1675
+ str(v.get("breakageKind") or "").strip().lower(),
1676
+ )
1677
+ for v in (item.get("verdicts") or [])
1678
+ if isinstance(v, dict)
1679
+ ]
1680
+ non_error = [(vd, bk) for (vd, bk) in tokens if vd and vd != "VERIFICATION-ERROR"]
1681
+ if not non_error:
1682
+ return "all-non-result"
1683
+ disagree = [(vd, bk) for (vd, bk) in non_error if vd == "DISAGREE"]
1684
+ agree = [(vd, bk) for (vd, bk) in non_error if vd in ("AGREE", "SUPPLEMENT")]
1685
+ disagree_kinds = {bk for (_vd, bk) in disagree if bk}
1686
+ if not disagree:
1687
+ return "full-consensus"
1688
+ # Single-vote-blocking kinds: one confirmed DISAGREE on a concrete,
1689
+ # safety-critical, adversarially-verifiable defect is enough to block, even
1690
+ # in a two-worker roster — a lone correct dissent must not be outvoted here.
1691
+ # `a`/`d` for any item; `f` only for P-Req items (requirement coverage).
1692
+ is_req = str(item.get("id") or "").upper().startswith("P-REQ")
1693
+ if disagree_kinds & _SINGLE_VOTE_BLOCKING_KINDS or (is_req and "f" in disagree_kinds):
1694
+ return "majority-disagree"
1695
+ # Otherwise a genuine majority is required — and a majority needs at least
1696
+ # two participating votes, so a lone surviving DISAGREE (its peer returned a
1697
+ # non-result) does NOT block. That fixes the paradox where a worker failure
1698
+ # made the gate stricter than a healthy roster would.
1699
+ if len(non_error) >= 2 and len(disagree) > len(agree):
1700
+ return "majority-disagree"
1701
+ return "has-dissent"
1702
+
1703
+
1704
+ def _recompute_plan_body_gate(pbv: dict) -> str | None:
1705
+ """Recompute the whole §5.5.9 gate value from ``planItems[].verdicts``.
1706
+ Returns a value in ``PLAN_VERIFY_GATE_VALUES`` or ``None`` when there are
1707
+ no plan items to judge (disabled / empty round)."""
1708
+ classes = [
1709
+ _classify_plan_item_gate(it)
1710
+ for it in (pbv.get("planItems") or [])
1711
+ if isinstance(it, dict)
1712
+ ]
1713
+ if not classes:
1714
+ return None
1715
+ if all(c == "all-non-result" for c in classes):
1716
+ return "aborted-non-result"
1717
+ if any(c == "majority-disagree" for c in classes):
1718
+ return "blocked-by-disagreement"
1719
+ if any(c == "has-dissent" for c in classes):
1720
+ return "passed-with-dissent"
1721
+ return "passed"
1722
+
1723
+
1724
+ def _validate_plan_body_gate_recompute(data: dict, failures: list[str]) -> None:
1725
+ """H1 — the declared `Gate result` must not claim a healthier outcome than
1726
+ the recorded per-worker verdicts support. Closes the forgery hole where a
1727
+ lead writes `gateResult: passed` while workers actually voted DISAGREE:
1728
+ the verdicts live in `planItems[].verdicts`, so the gate is recomputable
1729
+ and no longer depends on the lead's honesty alone.
1730
+ """
1731
+ ip = data.get("implementationPlanning")
1732
+ if not isinstance(ip, dict):
1733
+ return
1734
+ pbv = ip.get("planBodyVerification")
1735
+ if not isinstance(pbv, dict):
1736
+ return
1737
+ declared = str(pbv.get("gateResult") or "").strip().lower()
1738
+ recomputed = _recompute_plan_body_gate(pbv)
1739
+ if recomputed is None or declared not in _PLAN_GATE_RANK:
1740
+ return
1741
+ if _PLAN_GATE_RANK[declared] > _PLAN_GATE_RANK[recomputed]:
1742
+ failures.append(
1743
+ "final-report data.json: implementationPlanning.planBodyVerification "
1744
+ f"`gateResult` is `{declared}` but the recorded planItems[].verdicts "
1745
+ f"only support `{recomputed}` (a majority DISAGREE, a DISAGREE(f) on "
1746
+ "a P-Req item, or all-non-result dispatches were recorded). The gate "
1747
+ "value must honestly aggregate the worker votes — do not upgrade it "
1748
+ "to unblock the run (convergence.md Round protocol)."
1749
+ )
1750
+
1751
+
1752
+ # Each plan-body deliverable array and the P-* verdict-item prefix it must be
1753
+ # extracted into for §5.5.9 cross-verification (convergence.md Plan-item
1754
+ # extraction). A weak item dropped from planItems escapes the gate entirely.
1755
+ _PLAN_ITEM_SOURCES = (
1756
+ ("optionCandidates", "P-Opt", "Option Candidates (§4.5.1)"),
1757
+ ("stepwiseExecution", "P-Step", "Stepwise Execution Order (§4.5.4)"),
1758
+ ("dependencyMigrationRisk", "P-Dep", "Dependency / Migration Risk (§4.5.5)"),
1759
+ ("validationChecklist", "P-Val", "Validation Checklist (§4.5.6)"),
1760
+ ("rollbackStrategy", "P-Rb", "Rollback Strategy (§4.5.7)"),
1761
+ ("requirementCoverage", "P-Req", "Requirement Coverage (§4.5.8)"),
1762
+ )
1763
+
1764
+
1765
+ def _validate_plan_item_extraction_completeness(data: dict, failures: list[str]) -> None:
1766
+ """H2 — when a verification round ran, every plan-body deliverable item must
1767
+ appear as a matching `P-*` verdict item. Closes the omission hole where the
1768
+ lead silently drops a weak item (e.g. an out-of-order rollback) from
1769
+ planItems so no worker ever verifies it, yet the gate still reads passed.
1770
+ Requires only that no category is *under-extracted*; over-extraction is fine.
1771
+ """
1772
+ ip = data.get("implementationPlanning")
1773
+ if not isinstance(ip, dict):
1774
+ return
1775
+ pbv = ip.get("planBodyVerification")
1776
+ if not isinstance(pbv, dict):
1777
+ return
1778
+ round_count = pbv.get("roundCount")
1779
+ if not isinstance(round_count, int) or round_count < 1:
1780
+ return
1781
+ extracted_ids = [
1782
+ str(it.get("id") or "").upper()
1783
+ for it in (pbv.get("planItems") or [])
1784
+ if isinstance(it, dict)
1785
+ ]
1786
+ for field, prefix, label in _PLAN_ITEM_SOURCES:
1787
+ source_count = len([r for r in (ip.get(field) or []) if isinstance(r, dict)])
1788
+ if source_count == 0:
1789
+ continue
1790
+ extracted = len(
1791
+ [i for i in extracted_ids if i.startswith(prefix.upper() + "-")]
1792
+ )
1793
+ if extracted < source_count:
1794
+ failures.append(
1795
+ f"final-report data.json: {label} has {source_count} item(s) but "
1796
+ f"planBodyVerification.planItems carries only {extracted} "
1797
+ f"`{prefix}-*` verdict item(s). Every plan-body item MUST be "
1798
+ "extracted for cross-verification — a dropped item escapes the "
1799
+ "gate (convergence.md Plan-item extraction)."
1800
+ )
1801
+
1802
+
1803
+ # A `subject` this short or shaped like a bare `P-Opt-1` id is a placeholder,
1804
+ # not the plain-language "what this item is" label §5.5.9 renders as a heading.
1805
+ _MIN_SUBJECT_LEN = 3
1806
+ _BARE_PLAN_ITEM_ID_RE = re.compile(r"^P-(?:Opt|Step|Dep|Val|Rb|Req)-\d+$", re.IGNORECASE)
1807
+
1808
+
1809
+ def _validate_plan_item_subject_substance(data: dict, failures: list[str]) -> None:
1810
+ """H2 follow-up — `planItems[].subject` must be a real label, not a
1811
+ placeholder. The schema only enforces non-empty, so `"x"` or a copied
1812
+ `P-Opt-1` id would otherwise slip through and defeat the whole point of the
1813
+ subject (letting a reader see *what* each AGREE/DISAGREE is about).
1814
+ """
1815
+ ip = data.get("implementationPlanning")
1816
+ if not isinstance(ip, dict):
1817
+ return
1818
+ pbv = ip.get("planBodyVerification")
1819
+ if not isinstance(pbv, dict):
1820
+ return
1821
+ for item in pbv.get("planItems") or []:
1822
+ if not isinstance(item, dict):
1823
+ continue
1824
+ item_id = str(item.get("id") or "").strip()
1825
+ subject = str(item.get("subject") or "").strip()
1826
+ if (
1827
+ len(subject) < _MIN_SUBJECT_LEN
1828
+ or subject == item_id
1829
+ or _BARE_PLAN_ITEM_ID_RE.match(subject)
1830
+ ):
1831
+ failures.append(
1832
+ f"final-report data.json: plan item `{item_id or '<unknown>'}` has a "
1833
+ f"placeholder subject `{subject}`. Give a plain-language label of "
1834
+ "what the item is (e.g. 'Option A: upload v2 를 신규 모듈로 분리') so "
1835
+ "the §5.5.9 reader knows what each verdict is about "
1836
+ "(convergence.md Plan-item extraction)."
1837
+ )
1838
+
1839
+
1840
+ def _validate_plan_body_clarification_matching(data: dict, failures: list[str]) -> None:
1841
+ """H5 — every plan item that the recorded verdicts make `majority-disagree`
1842
+ must point (via `clarificationId`) at an existing `blocks: approval`
1843
+ clarification row. Closes the hole where a majority-disagree item blocks the
1844
+ gate but no §1 Clarification row is raised, so the user never sees why
1845
+ approval is withheld (convergence.md self-review step 7).
1846
+ """
1847
+ ip = data.get("implementationPlanning")
1848
+ if not isinstance(ip, dict):
1849
+ return
1850
+ pbv = ip.get("planBodyVerification")
1851
+ if not isinstance(pbv, dict):
1852
+ return
1853
+ round_count = pbv.get("roundCount")
1854
+ if not isinstance(round_count, int) or round_count < 1:
1855
+ return
1856
+ clar_rows = [r for r in (data.get("clarificationItems") or []) if isinstance(r, dict)]
1857
+ all_ids = {r.get("id") for r in clar_rows if r.get("id")}
1858
+ approval_ids = {r.get("id") for r in clar_rows if r.get("blocks") == "approval" and r.get("id")}
1859
+ for item in pbv.get("planItems") or []:
1860
+ if not isinstance(item, dict):
1861
+ continue
1862
+ if _classify_plan_item_gate(item) != "majority-disagree":
1863
+ continue
1864
+ item_id = item.get("id") or "<unknown>"
1865
+ cid = item.get("clarificationId")
1866
+ if not cid:
1867
+ failures.append(
1868
+ f"final-report data.json: plan item `{item_id}` is majority-disagree "
1869
+ "but carries no `clarificationId`. A blocking disagreement MUST "
1870
+ "surface as a `## 1. Clarification Items` row (blocks=approval) so "
1871
+ "the user sees the blocker (convergence.md self-review step 7)."
1872
+ )
1873
+ elif cid not in approval_ids:
1874
+ reason = (
1875
+ "references a non-existent §1 row"
1876
+ if cid not in all_ids
1877
+ else "references a §1 row whose `blocks` is not `approval`"
1878
+ )
1879
+ failures.append(
1880
+ f"final-report data.json: plan item `{item_id}` (majority-disagree) "
1881
+ f"has clarificationId `{cid}` which {reason}. Every majority-disagree "
1882
+ "item MUST map 1:1 to a `blocks: approval` Clarification row."
1883
+ )
1884
+
1885
+
1886
+ _COVERED_BY_ANCHOR_RE = re.compile(r"option|stage|step", re.IGNORECASE)
1887
+ _COVERED_BY_STAGE_REF_RE = re.compile(r"stage\s*(\d+)", re.IGNORECASE)
1888
+ _COVERED_BY_VAGUE = {"recommended option", "the recommended option", "recommended"}
1889
+
1890
+
1891
+ def _validate_requirement_coverage_covered_by(data: dict, failures: list[str]) -> None:
1892
+ """H3 (partial) — a `covered` requirement row's `coveredBy` must name a
1893
+ concrete plan element that actually exists, not the spec-forbidden bare
1894
+ "recommended option" nor a phantom stage. Whether the cited step truly
1895
+ *satisfies* the requirement stays a worker DISAGREE(f) judgment; this closes
1896
+ the coarser hole where `coveredBy` points at nothing real.
1897
+ """
1898
+ ip = data.get("implementationPlanning")
1899
+ if not isinstance(ip, dict):
1900
+ return
1901
+ rows = [r for r in (ip.get("requirementCoverage") or []) if isinstance(r, dict)]
1902
+ if not rows:
1903
+ return
1904
+ stage_numbers = {
1905
+ s.get("stage") for s in (ip.get("stages") or []) if isinstance(s, dict)
1906
+ }
1907
+ for row in rows:
1908
+ if row.get("status") != "covered":
1909
+ continue
1910
+ rid = row.get("id") or "<row>"
1911
+ covered = str(row.get("coveredBy") or "").strip()
1912
+ if covered.lower() in _COVERED_BY_VAGUE:
1913
+ failures.append(
1914
+ f"final-report data.json: requirementCoverage `{rid}` is `covered` "
1915
+ f"but coveredBy is just `{covered}`. Name the specific Option "
1916
+ "Candidate and Stage/Step that satisfies it, not 'recommended "
1917
+ "option' (profile Requirement Coverage)."
1918
+ )
1919
+ continue
1920
+ if not _COVERED_BY_ANCHOR_RE.search(covered):
1921
+ failures.append(
1922
+ f"final-report data.json: requirementCoverage `{rid}` coveredBy "
1923
+ f"`{covered}` names no Option / Stage / Step. A `covered` row must "
1924
+ "cite the concrete plan element that satisfies the requirement."
1925
+ )
1926
+ continue
1927
+ phantom = [
1928
+ n for m in _COVERED_BY_STAGE_REF_RE.finditer(covered)
1929
+ if stage_numbers and (n := int(m.group(1))) not in stage_numbers
1930
+ ]
1931
+ if phantom:
1932
+ failures.append(
1933
+ f"final-report data.json: requirementCoverage `{rid}` coveredBy "
1934
+ f"cites Stage {phantom[0]} which does not exist in the Stage Map. "
1935
+ "A coverage row must point at a real stage."
1936
+ )
1937
+
1938
+
1587
1939
  def _validate_final_verification_consistency(data: dict, failures: list[str]) -> None:
1588
1940
  """Enforce verdict ↔ blocker/condition/routing consistency on the
1589
1941
  final-verification data.json (SSOT). The schema guarantees field SHAPE;
@@ -2424,6 +2776,8 @@ def main() -> int:
2424
2776
  except (OSError, json.JSONDecodeError):
2425
2777
  _sp = None
2426
2778
  _validate_conformance(report_path, failures, surface_patterns=_sp)
2779
+ if task_type == "implementation-planning":
2780
+ _validate_planning_conformance_declared(report_path, failures)
2427
2781
  if task_type == "improvement-discovery":
2428
2782
  brief_relative = str(task_manifest.get("taskBriefPath") or "").strip()
2429
2783
  brief_path = (