okstra 0.129.1 → 0.130.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.129.1",
3
+ "version": "0.130.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.129.1",
3
- "builtAt": "2026-07-21T04:57:17.399Z",
2
+ "package": "0.130.1",
3
+ "builtAt": "2026-07-21T10:57:23.020Z",
4
4
  "repoRoot": "/home/runner/work/okstra/okstra"
5
5
  }
@@ -207,6 +207,13 @@ and the run-level error log staying empty.
207
207
  --stderr-excerpt-file "<captured-stderr-path or omit>"
208
208
  ```
209
209
 
210
+ Keep `--message` to the error you actually observed (`HTTP 429`,
211
+ `connection refused`, `1045 access denied`) — asserting that a sandbox or
212
+ permission boundary blocked the call requires `--context-json` carrying
213
+ `cause` plus both `causeEvidence` probes, and an unevidenced block claim in
214
+ `--message` is rejected on the spot (and again on dump if you route it to
215
+ the sidecar instead).
216
+
210
217
  The lead prompt provides `**Errors log path:**`, `<task-key>`, and
211
218
  `<phase>` alongside the prompt history path. If any of these are
212
219
  missing, fall back to logging to the worker errors sidecar instead —
@@ -207,6 +207,13 @@ and the run-level error log staying empty.
207
207
  --stderr-excerpt-file "<captured-stderr-path or omit>"
208
208
  ```
209
209
 
210
+ Keep `--message` to the error you actually observed (`HTTP 429`,
211
+ `connection refused`, `1045 access denied`) — asserting that a sandbox or
212
+ permission boundary blocked the call requires `--context-json` carrying
213
+ `cause` plus both `causeEvidence` probes, and an unevidenced block claim in
214
+ `--message` is rejected on the spot (and again on dump if you route it to
215
+ the sidecar instead).
216
+
210
217
  The lead prompt provides `**Errors log path:**`, `<task-key>`, and
211
218
  `<phase>` alongside the prompt history path. If any of these are
212
219
  missing, fall back to logging to the worker errors sidecar instead —
@@ -102,6 +102,7 @@ Rules (the schema enforces most of these — they are listed here so you know *w
102
102
  - Cite file paths and line numbers in every `evidence.primary[].source` / `consensus[].evidence` cell.
103
103
  - Preserve every analysis worker's ticket tagging — every row's `ticketId` field carries the ticket key or the task-fallback. For single-ticket runs, set `ticketCoverage` to `{"singleTicket": "<ticket>"}`. For runs that do not require ticket tagging (`release-handoff`, `final-verification`), set `ticketCoverage` to `{"omit": true}`.
104
104
  - For `implementation-planning`, populate `implementationPlanning.requirementCoverage` with one row per concrete requirement from the brief / packet, using IDs `R-001`, `R-002`, ... in source order. `coveredBy` MUST name the specific Option Candidate plus Stage/Step that satisfies the requirement. Use `status: "covered"` only when the report's plan actually covers it; otherwise use `gap` or `blocked C-NNN` and ensure the corresponding `Clarification Items` row blocks approval. Do not collapse this into `ticketCoverage`; ticket coverage is not requirement coverage.
105
+ - For `implementation-planning`, each `requirementCoverage` row's `source` is a graded cell, not prose — free text like `"carry-in from requirements-discovery C-001"` is rejected. Write exactly one of: `brief:<heading>`, where the heading literally exists in the task brief; `derived:R-NNN — <one-line reason>`, whose chain must terminate at a `brief:` or `contract:` row of the same table without cycling; or `contract:<rule>`, for artifacts okstra's own phase contract mandates, whose allowlist is exactly the two tokens `decision-record-step` (the §5.4 Decision Drafts materialization step) and `glossary-step` (the glossary proposal step) — any other rule name is rejected, so never invent one. (Maintainer SSOT for that allowlist: `scripts/okstra_ctl/scope_provenance.py` in the okstra repo.) A requirement you cannot source this way does not belong in the table: put it in `clarificationItems[]` with `Blocks=approval`. **Enforced:** `validators/validate-run.py` `_validate_requirement_provenance`. In the same table, anchor every stage number in `coveredBy` to a `Stage` / `Stages` word (`Stage 2`, `Stages 1-3`) — `_validate_stage_has_requirement` reads that cell as prose and fails the plan when a Stage Map stage is cited by no row.
105
106
  - For `implementation-planning`, also populate `implementationPlanning.decisionDrafts` (one row per decision meeting all three decision-record criteria; `[]` otherwise) and `implementationPlanning.skippedAdrCandidates` (evaluated-but-dropped adr-candidates; `[]` otherwise). The schema excerpt enumerates the row shape; the renderer emits §5.4 `### Decision Drafts`. When `decisionDrafts` is non-empty, the plan's stages MUST carry a stepwise step that creates `.okstra/decisions/<NNNN>-<slug>.md` (validate-run gates this).
106
107
  - When the `Task Type` is `improvement-discovery`, populate `## 5.9 Improvement Candidates` with the 10-column schema enforced by `validators/validate_improvement_report.py`. Source the row IDs (`I-NNN`), lens whitelist, and Source workers patterns from `scripts/okstra_ctl/improvement_lenses.py` — do NOT introduce new lens names or worker prefixes. `improvement-discovery` is NOT in the data.json schema enum, so author its markdown directly (not via `okstra-render-final-report.py`). Immediately after writing the markdown, run (`Bash`): `okstra inject-report-index <markdown path> --report-language <en|ko>`. That adds the top-of-report Index plus `I-NNN` / `C-NNN` scroll anchors; the run validator fails the report when the Index anchor is absent.
107
108
 
@@ -23,6 +23,26 @@ ALLOWED_AGENTS = {
23
23
  ALLOWED_AGENT_ROLES = {"lead", "worker", "report-writer"}
24
24
  SUPPORTED_SIDECAR_SCHEMA_VERSIONS = {1}
25
25
 
26
+ ALLOWED_CAUSES = {
27
+ "sandbox-denied", "service-unavailable", "auth-failed", "unknown",
28
+ }
29
+ # A `sandbox-denied` claim is only admissible with both probes attached.
30
+ CAUSE_EVIDENCE_FIELDS = ("targetProbe", "controlProbe")
31
+ # Both probes share a record's PIPE_BUF_BYTES budget with stderrExcerpt, so
32
+ # they cannot reuse the 2048 cap that assumes stderrExcerpt owns it alone.
33
+ CAUSE_PROBE_MAX_BYTES = 256
34
+ # Backstop vocabulary: scanned in `message` only — never in stderrExcerpt,
35
+ # where a kernel's real "Operation not permitted" is legitimate content.
36
+ # Deliberately excludes bare "blocked"/"blocks": everyday English that would
37
+ # reject honest records like "test blocked on upstream dependency".
38
+ _BLOCKING_CLAIM_TERMS = (
39
+ "sandbox", "not permitted", "permission denied", "eperm",
40
+ )
41
+ # The backstop targets *unclassified* blocking claims. A worker that declared
42
+ # a specific cause has already done the honest work — `auth-failed` legitimately
43
+ # reads "permission denied" (MySQL 1045).
44
+ _UNCLASSIFIED_CAUSES = (None, "unknown")
45
+
26
46
 
27
47
  def _now_utc():
28
48
  return dt.datetime.now(dt.timezone.utc)
@@ -32,24 +52,80 @@ def _iso(t):
32
52
  return t.isoformat()
33
53
 
34
54
 
35
- def truncate_stderr(s):
36
- """Truncate stderr text to STDERR_EXCERPT_MAX_BYTES, multibyte-safe."""
55
+ def _truncate_utf8(s, limit):
56
+ """Truncate to `limit` bytes without splitting a multibyte character."""
37
57
  if s is None:
38
58
  return None
39
59
  encoded = s.encode("utf-8")
40
- if len(encoded) <= STDERR_EXCERPT_MAX_BYTES:
60
+ if len(encoded) <= limit:
41
61
  return s
42
- cut = encoded[:STDERR_EXCERPT_MAX_BYTES]
43
- # 멀티바이트 경계 보호: 디코드 가능해질 때까지 끝 바이트 제거
62
+ cut = encoded[:limit]
44
63
  while cut:
45
64
  try:
46
- decoded = cut.decode("utf-8")
47
- return decoded + TRUNCATION_SUFFIX
65
+ return cut.decode("utf-8") + TRUNCATION_SUFFIX
48
66
  except UnicodeDecodeError:
49
67
  cut = cut[:-1]
50
68
  return TRUNCATION_SUFFIX
51
69
 
52
70
 
71
+ def truncate_stderr(s):
72
+ """Truncate stderr text to STDERR_EXCERPT_MAX_BYTES, multibyte-safe."""
73
+ return _truncate_utf8(s, STDERR_EXCERPT_MAX_BYTES)
74
+
75
+
76
+ def normalize_cause_context(context, *, message):
77
+ """Validate a record's cause claim and return the normalized context.
78
+
79
+ Raises ValueError on three conditions:
80
+ - `cause` is set to a value outside ALLOWED_CAUSES;
81
+ - `cause` is 'sandbox-denied' but the two probes are missing or blank —
82
+ those probes are what distinguish a real denial from an unreachable
83
+ or auth-gated target, the misdiagnosis this gate exists to stop;
84
+ - `message` asserts a block in prose while the record left its cause
85
+ unclassified, which would smuggle the same claim past the gate.
86
+ """
87
+ cause = context.get("cause") if isinstance(context, dict) else None
88
+
89
+ if cause is not None and cause not in ALLOWED_CAUSES:
90
+ raise ValueError(
91
+ f"invalid cause: {cause!r} (allowed: {sorted(ALLOWED_CAUSES)})"
92
+ )
93
+
94
+ if cause == "sandbox-denied":
95
+ evidence = context.get("causeEvidence")
96
+ if not isinstance(evidence, dict):
97
+ raise ValueError(
98
+ "cause 'sandbox-denied' requires context.causeEvidence with "
99
+ f"{list(CAUSE_EVIDENCE_FIELDS)}"
100
+ )
101
+ normalized_evidence = {}
102
+ for field in CAUSE_EVIDENCE_FIELDS:
103
+ value = evidence.get(field)
104
+ if not isinstance(value, str) or not value.strip():
105
+ raise ValueError(
106
+ f"cause 'sandbox-denied' requires a non-empty "
107
+ f"context.causeEvidence.{field}: record the command and "
108
+ f"its raw output that proves the claim"
109
+ )
110
+ normalized_evidence[field] = _truncate_utf8(
111
+ value, CAUSE_PROBE_MAX_BYTES
112
+ )
113
+ return {**context, "causeEvidence": normalized_evidence}
114
+
115
+ if message and cause in _UNCLASSIFIED_CAUSES:
116
+ lowered = message.lower()
117
+ hit = next((t for t in _BLOCKING_CLAIM_TERMS if t in lowered), None)
118
+ if hit:
119
+ raise ValueError(
120
+ f"message asserts a blocking claim ({hit!r}) without "
121
+ "context.cause='sandbox-denied' + context.causeEvidence. "
122
+ "Either attach the two probes, or state the cause you "
123
+ "actually verified."
124
+ )
125
+
126
+ return context
127
+
128
+
53
129
  def append_jsonl_line(path, record):
54
130
  """Append a single JSON record as one line to ``path``.
55
131
 
@@ -119,6 +195,9 @@ def append_observed(
119
195
  raise ValueError(f"invalid agent: {agent!r}")
120
196
  if agent_role not in ALLOWED_AGENT_ROLES:
121
197
  raise ValueError(f"invalid agentRole: {agent_role!r}")
198
+ # Runs before append_jsonl_line so a rejected claim leaves no trace in the
199
+ # log: a written-then-flagged record is still a record someone can cite.
200
+ context = normalize_cause_context(context, message=message)
122
201
  ts = _iso(now or _now_utc())
123
202
  rec = {
124
203
  "ts": ts,
@@ -159,6 +238,7 @@ def dump_from_worker_sidecar(
159
238
  - ``agent`` or ``agent_role`` is not in the allow-lists
160
239
  - sidecar ``schemaVersion`` is not in ``SUPPORTED_SIDECAR_SCHEMA_VERSIONS``
161
240
  - any entry's ``errorType`` is not in ``ALLOWED_ERROR_TYPES``
241
+ - any entry asserts a blocking cause without its required evidence
162
242
 
163
243
  Returns 0 (no-op) if the sidecar file does not exist or its
164
244
  ``errors`` list is empty.
@@ -187,6 +267,9 @@ def dump_from_worker_sidecar(
187
267
  et = e.get("errorType")
188
268
  if et not in ALLOWED_ERROR_TYPES:
189
269
  raise ValueError(f"invalid errorType in sidecar: {et!r}")
270
+ entry_context = normalize_cause_context(
271
+ e.get("context"), message=e.get("message")
272
+ )
190
273
  rec = {
191
274
  "ts": e.get("ts"),
192
275
  "recordedAt": recorded_at,
@@ -203,7 +286,7 @@ def dump_from_worker_sidecar(
203
286
  "durationMs": e.get("durationMs"),
204
287
  "message": e.get("message"),
205
288
  "stderrExcerpt": truncate_stderr(e.get("stderrExcerpt")),
206
- "context": e.get("context"),
289
+ "context": entry_context,
207
290
  }
208
291
  append_jsonl_line(out_path, rec)
209
292
  count += 1
@@ -271,6 +271,8 @@ okstra error-log append-observed \
271
271
  --stderr-excerpt "<last stderr lines, or use --stderr-excerpt-file>"
272
272
  ```
273
273
 
274
+ Keep `--message` to the error actually observed — asserting that a sandbox or permission boundary blocked the call requires `--context-json` carrying `cause` plus both `causeEvidence` probes, and an unevidenced block claim in `--message` is rejected. If an `append-from-worker` dump is rejected for that reason, correct the offending sidecar entry and re-run the dump instead of skipping it: the dump aborts at the rejected entry, so every later entry in that sidecar never reaches the run log.
275
+
274
276
  The wrapper subagent records this through its selected adapter — Lead does NOT need to re-record. Token usage is not inferred from dispatch return values; call `collect_usage` at the start of Phase 7.
275
277
 
276
278
  ## Phase 5.5: Convergence loop
@@ -120,7 +120,15 @@
120
120
  - validation checklist (pre / mid / post) — each item is an exact command or observable outcome
121
121
  - rollback strategy — exact revert path (commits, flags, migrations) and the signal that triggers rollback
122
122
  - **Requirement admissibility (scope boundary):** a brief line becomes a Requirement Coverage row only when **a stage can satisfy it by changing files in this repository** — source, tests, config, or deployment *manifest files*. A line whose satisfaction needs a person's approval, a ticket status change, or an action against live infrastructure (applying a manifest, a cutover, creating a dashboard or alert, validating in staging/production) is NOT a requirement for this phase: it belongs to the brief's `## External Gates`, and this plan neither creates a stage for it nor cites it in coverage. Briefs generated by `okstra-brief-gen` pre-split these into `## Acceptance Criteria` (admissible) and `## External Gates` (not); when reading an older brief that carries a raw Definition-of-Done checklist, apply the same test line by line. The boundary is the *action*, not the topic — "add the flag to `values-prod.yaml`" is admissible, "apply that manifest to prod" is not. Planning an operational stage this phase cannot execute (see the run-scope rule above forbidding deployments) produces steps whose commands never resolve, which the §5.5.9 gate then correctly blocks — the plan must not create that deadlock in the first place.
123
- - **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`.
123
+ - **Scope provenance (BLOCKING):** every Requirement Coverage row's `Source` cell must be exactly one of three forms, and every Stage Map stage must be cited by at least one coverage row's `Covered by`. The forms:
124
+ - `brief:<heading>` — a heading that literally exists in the task brief. A heading the brief does not contain is a fabricated requirement.
125
+ - `derived:R-NNN — <one-line rationale>` — derived from another row in the same table. The chain must terminate at a `brief:` or `contract:` row and must not cycle. Use this for genuine technical consequences: a migration stage is `derived:R-003 — R-003 cannot be satisfied without a schema change`.
126
+ - `contract:<rule>` — an artifact okstra's own phase contract mandates, so it has no brief line to cite. The allowlist is exactly `decision-record-step` (the §5.4 Decision Drafts materialization step) and `glossary-step` (the glossary proposal step); the SSOT is `scripts/okstra_ctl/scope_provenance.py`. Never widen this form to launder work the brief did not ask for.
127
+ An item you can give none of these three sources to is **not a requirement and not a stage**. Its only admissible outlet is a `## 1. Clarification Items` row with `Blocks=approval`, carrying the recommendation format from `_clarification-recommendation.md`. Do not fold it into an option, a stage, or a step "while we are in here" — that is the scope expansion this rule exists to stop. This makes concrete the planning-input rule that any change beyond what `Requirement Summary` explicitly demands is out of scope by default. **Enforced:** `validators/validate-run.py` `_validate_requirement_provenance` (source resolution) and `_validate_stage_has_requirement` (no stage without a requirement).
128
+ - **The reach of this gate — do not over-trust it.** What is mechanically enforced is the *form* of each source, that a `brief:` heading literally exists, that a `derived:` chain terminates without cycling, and that no stage is uncited. What is **not** enforced is whether the cited source genuinely demands the requirement. Every brief carries the same generic headings (`## Acceptance Criteria`, `## Context`, …), so attaching invented work to a real heading still parses clean. The gate's value is that it forces every item to name an origin and makes fabrication explicit and auditable — judging whether that origin actually demands the item remains a reviewer / `DISAGREE(f)` responsibility, and passing this gate is never evidence that the scope is justified.
129
+ - **Stage citation format:** the reverse check reads each `Covered by` cell as prose, so a stage counts as cited only when its number is anchored to a `Stage` / `Stages` word on the same line. These all read: `Stage 2`; `Stage 1, Stage 2, Stage 3`; `Stages 1, 2, 3`; `Stages 1, 2, and 3`; ranges (`Stages 1-3`, `Stages 1 to 3`, `Stages 1 through 3`); and `and` / `&` conjunctions. A bare number with no `stage` word anchoring it is NOT read as a citation, so `covered by the recommended option, step 4` cites nothing. **Enforced:** `validators/validate-run.py` `_validate_stage_has_requirement` fails the plan when any Stage Map stage is cited by no coverage row.
130
+ - Because that reader only sees prose, it cannot tell a citation from a mention: `Stage 1 (superseded by Stage 2)` still counts Stage 1 as cited, and one row citing `Stages 1-64` rubber-stamps every stage in the map. Cite the stages a requirement is actually satisfied by — not stages merely mentioned, and never a blanket range standing in for the work of checking.
131
+ - **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` follows the three-form grammar defined in the **Scope provenance** rule above — a free-form `file:line` is not one of the three forms and is rejected. When this run's brief is a fan-out packet, the task manifest's `taskBriefPath` points at that packet file, so `brief:` cites the packet's own headings (`## Scope`, `## Evidence`, `## Requirement Provenance`) — not the headings of the upstream user brief the packet 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`.
124
132
  - **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.
125
133
  - 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).
126
134
  - 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`.
@@ -144,7 +152,7 @@
144
152
  2. **Placeholder scan** — search the report for the patterns in the No-placeholder rule above and fix inline.
145
153
  3. **Internal consistency** — option file lists, trade-off matrix, and recommended step list must agree on file paths, names, and signatures. A symbol called `clearLayers()` in the matrix and `clearFullLayers()` in the steps is a bug.
146
154
  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.
147
- 5. **Scope check** — if the recommended plan now spans multiple independent subsystems, recommend splitting into separate planning runs rather than shipping an oversized plan.
155
+ 5. **Scope check** — if the recommended plan now spans multiple independent subsystems, recommend splitting into separate planning runs rather than shipping an oversized plan. Then walk the plan in the expansion direction: for every stage, name the Requirement Coverage row that demanded it, and for every requirement row, read its `Source` cell as a skeptic — does the cited brief heading actually exist, and does a `derived:` rationale state a real technical consequence rather than a preference? Move anything that fails to a `Blocks=approval` clarification row.
148
156
  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.
149
157
  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`. **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.
150
158
  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.
@@ -35,6 +35,7 @@
35
35
  `domain`(work-category 5-enum: bugfix / feature / refactor / ops / improvement),
36
36
  `depends-on`(an inline list of unit-ids within the same fan-out `[unit-001]`, or `[]` if none),
37
37
  `recommended-next-phase`(error-analysis | implementation-planning) are filled in.
38
+ Each packet MUST carry a non-empty `## Requirement Provenance` section whose every bullet is `brief:<heading>` (a heading that literally exists in the task brief) or `contract:<rule>`. `derived:` is not admissible in a packet — cross-packet derivation cannot be resolved from a single packet, so each unit anchors directly on the brief. A unit you cannot source that way is not a work item: raise it as a clarification row instead of publishing a packet for it. **Enforced:** `validators/validate_fanout.py` `_check_provenance`.
38
39
  - in `runs/requirements-discovery/fan-out/index.md`, list the packets in depends-on topological order
39
40
  as a numbered list (`1. unit-001`) (a generated view; explicitly do not hand-edit). The depends-on graph
40
41
  must be a DAG — `validate_fanout` rejects a cycle as a validation failure, so if a cycle appears,
@@ -61,7 +62,7 @@
61
62
  - if any blocking input is missing at the time of writing the final report, populate `## 1. Clarification Items` in `final-report-template.md` (a single unified table; `Blocks=next-phase` for items the next run cannot start without)
62
63
  - prefer concrete questions whose answers map directly to a routing decision (`bugfix` vs `feature`, `error-analysis` vs `implementation-planning`, etc.). State each option in plain language with one sentence describing what choosing it would mean for the next phase.
63
64
  {{INCLUDE:_clarification-recommendation.md}}
64
- - **Codebase-first ambiguity resolution (defect rule)**: any ambiguity that can be answered by `Read` / `Grep` / file inspection MUST be resolved that way and recorded with file:line evidence. Writing a clarification row for something the codebase already answers is a defect of this phase.
65
+ - **Codebase-first ambiguity resolution (defect rule)**: any ambiguity that can be answered by `Read` / `Grep` / file inspection MUST be resolved that way and recorded with file:line evidence. Writing a clarification row for something the codebase already answers is a defect of this phase. **Boundary — facts only, never intent:** this rule governs questions of *fact* ("what does this code do", "where is this called", "does this field exist"). It never governs questions of *intent* ("should we do this at all", "is this in scope", "which outcome does the reporter want"). Scope and intent are not the kind of question a codebase can answer, so inspecting files never discharges them — raise them as clarification rows. Resolving a scope question by inference and building on that inference is the mirror defect, and a more expensive one.
65
66
  - **Evidence note required inside `Statement`**: every clarification row includes `Evidence checked: <path:line>` or `Evidence checked: none — <human-only reason>` in the `Statement` cell. `none` is allowed ONLY when the row's nature is "only a human can answer this" (reporter intent, business priority, external authority). A row with `none` that *could* have been answered by the codebase is a defect.
66
67
  - Cross-verification mode:
67
68
  - Phase 5.5 convergence runs in **adversarial mode** for this phase (`convergence.adversarial=true`). Verifiers actively try to refute each worker's finding by directly re-inspecting the cited evidence; the burden of proof sits on the claim. See `prompts/lead/convergence.md` §"Adversarial Verification Mode". A single evidence-backed refutation prevents a finding from reaching consensus.
@@ -2,10 +2,12 @@
2
2
  producer/consumer of final-report (and schedule) tables.
3
3
 
4
4
  GFM treats every unescaped ``|`` inside a cell as a column boundary — even
5
- inside inline-code spans — and renders ``\\|`` back as a literal ``|``.
6
- Producers therefore escape cell text with ``escape_pipes`` (exposed as the
7
- ``mdcell`` Jinja filter in ``render_final_report``), and consumers split rows
8
- with ``split_pipe_row``, which honours the same ``\\|`` convention.
5
+ inside inline-code spans — and renders ``\\|`` back as a literal ``|``. A row
6
+ is also line-terminated, so a newline inside a value truncates the row at that
7
+ point and drops every column after it. Producers therefore normalise cell text
8
+ with ``to_cell_text`` (exposed as the ``mdcell`` Jinja filter in
9
+ ``render_final_report``), and consumers split rows with ``split_pipe_row``,
10
+ which honours the same ``\\|`` convention.
9
11
  """
10
12
  from __future__ import annotations
11
13
 
@@ -17,17 +19,24 @@ UNESCAPED_PIPE_RE = re.compile(r"(?<!\\)\|")
17
19
 
18
20
  _SEPARATOR_CELL_RE = re.compile(r"\s*:?-+:?\s*")
19
21
 
22
+ _NEWLINE_RE = re.compile(r"\r\n|\r|\n")
20
23
 
21
- def escape_pipes(value: Any) -> str:
22
- """Escape literal ``|`` as ``\\|`` for use inside a markdown table cell.
23
24
 
24
- Idempotent: pipes that are already escaped are left alone, so re-rendering
25
- (Phase 7) never stacks backslashes. ``None`` renders as the empty string
26
- the same contract as the template's ``or ''`` fallbacks.
25
+ def to_cell_text(value: Any) -> str:
26
+ """Render a value as one markdown table cell: no newlines, no bare ``|``.
27
+
28
+ Newlines fold to ``<br>`` (a blank line becomes ``<br><br>``, keeping the
29
+ paragraph break visible) and literal ``|`` is escaped as ``\\|``.
30
+
31
+ Idempotent: pipes that are already escaped are left alone and the folded
32
+ ``<br>`` carries no newline, so re-rendering (Phase 7) never stacks
33
+ backslashes or breaks. ``None`` renders as the empty string — the same
34
+ contract as the template's ``or ''`` fallbacks.
27
35
  """
28
36
  if value is None:
29
37
  return ""
30
- return UNESCAPED_PIPE_RE.sub(r"\\|", str(value))
38
+ folded = _NEWLINE_RE.sub("<br>", str(value).strip())
39
+ return UNESCAPED_PIPE_RE.sub(r"\\|", folded)
31
40
 
32
41
 
33
42
  def split_pipe_row(line: str) -> list[str]:
@@ -50,7 +50,7 @@ from jinja2 import ChainableUndefined, Environment, FileSystemLoader
50
50
 
51
51
  from okstra_ctl.final_report_schema import SchemaError, load_schema, validate as schema_validate
52
52
  from okstra_ctl.i18n import I18nError, SUPPORTED_LANGS, load_dictionary, make_jinja_global
53
- from okstra_ctl.md_table import UNESCAPED_PIPE_RE, escape_pipes
53
+ from okstra_ctl.md_table import UNESCAPED_PIPE_RE, to_cell_text
54
54
  from okstra_ctl.models import UnknownModelError, resolve_model_metadata
55
55
  from okstra_ctl.schema_excerpt import excerpt_cut_from_version
56
56
  from okstra_ctl.seeding import installed_version
@@ -437,10 +437,12 @@ def _build_environment(template_dir: Path) -> Environment:
437
437
  env.filters["yaml_scalar"] = _yaml_scalar
438
438
  env.filters["yaml_inline_list"] = _yaml_inline_list
439
439
  env.filters["model_detail"] = _model_detail
440
- # `mdcell` escapes literal `|` in worker prose so a value like "PASS|FAIL"
441
- # cannot split a markdown table row. Table-cell interpolations only —
442
- # never code blocks / headings / prose, where `\|` would render verbatim.
443
- env.filters["mdcell"] = escape_pipes
440
+ # `mdcell` neutralises the two things in worker prose that can break a
441
+ # markdown table row: a literal `|` (splits the row) and a newline
442
+ # (truncates it, dropping every later column). Table-cell interpolations
443
+ # only — never code blocks / headings / prose, where `\|` and `<br>` would
444
+ # render verbatim.
445
+ env.filters["mdcell"] = to_cell_text
444
446
  # `mdquote` re-marks continuation lines of a blockquote nested in a list
445
447
  # item; the fenced siblings use Jinja's builtin `indent` for the same
446
448
  # reason. Both exist because only the first line of an interpolation
@@ -0,0 +1,129 @@
1
+ """Single source of truth for scope-provenance grammar.
2
+
3
+ Every requirement a phase emits must declare where it came from. Shared by
4
+ validators/validate-run.py and validators/validate_fanout.py so the grammar
5
+ cannot drift between the planning report and the fan-out packets.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import re
10
+ from dataclasses import dataclass
11
+ from pathlib import Path
12
+ from typing import Literal
13
+
14
+ # Items okstra's own phase contracts mandate — they have no brief line to cite.
15
+ # Keep this narrow: a wide allowlist turns into a bypass for invented scope.
16
+ CONTRACT_RULES = frozenset(
17
+ {
18
+ "decision-record-step", # implementation-planning.md: decisionDrafts materialization
19
+ "glossary-step", # implementation-planning.md: glossary proposals
20
+ }
21
+ )
22
+
23
+ # Mirrors the report schema's requirement id pattern
24
+ # (schemas/final-report-v1.0.schema.json: `^R-\d{3,}$`).
25
+ _REQ_ID_RE = re.compile(r"^R-\d{3,}$")
26
+ _BRIEF_RE = re.compile(r"^brief:\s*(?P<heading>.+?)\s*$")
27
+ _DERIVED_RE = re.compile(r"^derived:\s*(?P<parent>\S+)\s*[—-]\s*(?P<reason>.+?)\s*$")
28
+ _CONTRACT_RE = re.compile(r"^contract:\s*(?P<rule>.+?)\s*$")
29
+ _BRIEF_HEADING_RE = re.compile(r"^(#{1,6}\s+.+?)\s*$")
30
+ _FENCE_RE = re.compile(r"^\s*(`{3,}|~{3,})")
31
+
32
+
33
+ def normalize_heading(text: str) -> str:
34
+ """Heading text stripped of its `#` markers and surrounding whitespace.
35
+
36
+ Applied to both sides of the citation comparison so `brief:Acceptance
37
+ Criteria` and `brief:## Acceptance Criteria` both name the same heading.
38
+ The comparison stays exact after this — a heading the brief lacks must
39
+ still fail.
40
+ """
41
+ return " ".join(text.lstrip("#").split())
42
+
43
+
44
+ def brief_headings(brief_path: Path) -> set[str]:
45
+ """Markdown headings present in the brief, normalized for comparison.
46
+
47
+ An unreadable brief yields an empty set so callers degrade to skipping
48
+ heading verification instead of failing every brief-sourced citation.
49
+
50
+ Fenced regions are skipped: a shell comment inside a ```bash block starts
51
+ with `#` too, and admitting it would let a requirement cite a "heading" the
52
+ reporter never wrote.
53
+ """
54
+ try:
55
+ text = Path(brief_path).read_text(encoding="utf-8")
56
+ except OSError:
57
+ return set()
58
+
59
+ headings: set[str] = set()
60
+ fence: str | None = None
61
+ for line in text.splitlines():
62
+ if m := _FENCE_RE.match(line):
63
+ marker = m.group(1)
64
+ if fence is None:
65
+ fence = marker[0]
66
+ elif marker[0] == fence:
67
+ fence = None
68
+ continue
69
+ if fence is not None:
70
+ continue
71
+ if m := _BRIEF_HEADING_RE.match(line):
72
+ headings.add(normalize_heading(m.group(1)))
73
+ return headings
74
+
75
+
76
+ @dataclass(frozen=True)
77
+ class SourceRef:
78
+ kind: Literal["brief", "derived", "contract", "invalid"]
79
+ value: str = ""
80
+ reason: str = ""
81
+
82
+
83
+ def parse_source(raw: str) -> SourceRef:
84
+ text = (raw or "").strip()
85
+ if m := _DERIVED_RE.match(text):
86
+ parent = m.group("parent")
87
+ if not _REQ_ID_RE.match(parent):
88
+ return SourceRef("invalid")
89
+ return SourceRef("derived", parent, m.group("reason"))
90
+ if m := _BRIEF_RE.match(text):
91
+ return SourceRef("brief", m.group("heading"))
92
+ if m := _CONTRACT_RE.match(text):
93
+ rule = m.group("rule")
94
+ if rule not in CONTRACT_RULES:
95
+ return SourceRef("invalid")
96
+ return SourceRef("contract", rule)
97
+ return SourceRef("invalid")
98
+
99
+
100
+ def resolve_chain(rows: dict[str, SourceRef]) -> dict[str, str]:
101
+ """Walk each row's derivation chain to its terminator.
102
+
103
+ Returns id -> "ok" or a one-line reason the chain is not admissible.
104
+ """
105
+ result: dict[str, str] = {}
106
+ for rid in rows:
107
+ seen: list[str] = []
108
+ cursor = rid
109
+ while True:
110
+ if cursor in seen:
111
+ result[rid] = f"derivation cycle: {' -> '.join(seen + [cursor])}"
112
+ break
113
+ seen.append(cursor)
114
+ ref = rows.get(cursor)
115
+ if ref is None:
116
+ result[rid] = f"derives from `{cursor}` which is not a row in this table"
117
+ break
118
+ if ref.kind in ("brief", "contract"):
119
+ result[rid] = "ok"
120
+ break
121
+ if ref.kind == "derived":
122
+ cursor = ref.value
123
+ continue
124
+ result[rid] = (
125
+ f"unrecognized source on `{cursor}` — must be one of "
126
+ "`brief:<heading>`, `derived:R-NNN — <reason>`, `contract:<rule>`"
127
+ )
128
+ break
129
+ return result
@@ -6,6 +6,7 @@ from typing import Any, Mapping
6
6
 
7
7
  from .paths import okstra_home
8
8
  from .worker_prompt_policy import (
9
+ GRILLING_LOG_HEADER,
9
10
  PromptPlan,
10
11
  WORKER_ERROR_CONTRACT_FILENAME,
11
12
  WORKER_PREAMBLE_FILENAME_BY_AUDIENCE,
@@ -75,7 +76,7 @@ def worker_prompt_headers(
75
76
  ])
76
77
  if _string_value(manifest.get("taskType")) == "improvement-discovery":
77
78
  headers.append(
78
- f"**Phase 1.5 Grilling Log:** "
79
+ f"{GRILLING_LOG_HEADER} "
79
80
  f"{_improvement_grilling_log_path(project_root, manifest)}"
80
81
  )
81
82
  if _string_value(manifest.get("taskType")) == "final-verification":
@@ -14,6 +14,9 @@ PromptAudience = Literal[
14
14
  "lead-only",
15
15
  ]
16
16
 
17
+ # The prompt emitter, the run validator, and the worker-facing docs all bind to
18
+ # this literal; restating it anywhere else silently forks the dispatch contract.
19
+ GRILLING_LOG_HEADER = "**Phase 1.5 Grilling Log:**"
17
20
  FINAL_VERIFICATION_HEADERS = (
18
21
  "**Worktree:**",
19
22
  "**Verification scope:**",
@@ -98,7 +101,7 @@ def resolve_prompt_plan(
98
101
  "analysis",
99
102
  equality_group="analysis-core",
100
103
  packet_only=True,
101
- required_headers=("**Phase 1.5 Grilling Log:**",),
104
+ required_headers=(GRILLING_LOG_HEADER,),
102
105
  )
103
106
  return _plan("analysis", equality_group="analysis-core", packet_only=True)
104
107
 
@@ -16,6 +16,17 @@ recommended-next-phase: {{NEXT_PHASE}}
16
16
 
17
17
  <!-- Self-contained description of the single work item this unit covers. Do not mix it with other units. -->
18
18
 
19
+ ## Requirement Provenance
20
+
21
+ <!-- Where this unit's scope came from. One bullet per source. Every bullet must be
22
+ `brief:<heading>` (a heading that literally exists in the task brief) or
23
+ `contract:<rule>` (an okstra-mandated artifact). A unit you cannot source this
24
+ way is not a work item — raise it as a clarification instead of inventing it.
25
+ Unlike a planning report, `derived:<parent> — <reason>` is NOT accepted here: a
26
+ packet is validated on its own, so the parent it names cannot be resolved. -->
27
+
28
+ - brief:
29
+
19
30
  ## Evidence
20
31
 
21
32
  <!-- path:line evidence. Locations that requirements-discovery confirmed via file inspection. -->
@@ -27,14 +27,24 @@ If the sidecar does not exist when a real failure occurs, create `{"schemaVersio
27
27
  "durationMs": null,
28
28
  "message": "<one-line human summary>",
29
29
  "stderrExcerpt": null,
30
- "context": null
30
+ "context": {
31
+ "cause": "sandbox-denied | service-unavailable | auth-failed | unknown",
32
+ "causeEvidence": {
33
+ "targetProbe": "<command + raw output proving the target's state>",
34
+ "controlProbe": "<command + raw output proving another target is reachable>"
35
+ }
36
+ }
31
37
  }
32
38
  ```
33
39
 
40
+ `context` and both `cause` fields are optional; omit `context` entirely (`null`) when there is nothing to add.
41
+
34
42
  ### Rules
35
43
 
36
44
  - Do not add `source`, `recordedAt`, `agent`, `agentRole`, `model`, or `taskKey`; the lead supplies them when merging the sidecar.
37
45
  - The sidecar accepts only `errorType: "tool-failure"`. CLI wrappers write `cli-failure` directly to the run log; the lead records `contract-violation`.
46
+ - To record a block (sandbox/permission) as the cause, submit `context.cause = "sandbox-denied"` together with both probes in `context.causeEvidence`. Without the probes, leave `cause` as `unknown` — and then keep `message` to what you observed (`connection refused`, `1045 access denied`), not what you infer. A block asserted in `message` prose is rejected exactly like one asserted in `cause`, so "sandbox blocked it" with `cause: unknown` fails the write too. Failing to reach a target and the target being down are not the same fact — never assert the former without checking. Only `sandbox-denied` requires evidence; `service-unavailable` and `auth-failed` do not. Each probe is stored truncated to 256 bytes, so lead with the decisive command and its output.
47
+ - [Guideline] Put raw output in `stderrExcerpt` verbatim. Never merge several commands' output into one line, summarize it, or elide it with `...`. Once the host, port, and errno are gone, the cause cannot be recovered later.
38
48
  - Continue after recording unless the failure makes the task impossible.
39
49
 
40
50
  ### Path extraction (BLOCKING)
@@ -33,12 +33,3 @@ assert_contains() {
33
33
  fail "Expected to find [$expected_text] in $target_path"
34
34
  fi
35
35
  }
36
-
37
- assert_not_contains() {
38
- local target_path="$1"
39
- local unexpected_text="$2"
40
-
41
- if grep -F -- "$unexpected_text" "$target_path" >/dev/null 2>&1; then
42
- fail "Did not expect to find [$unexpected_text] in $target_path"
43
- fi
44
- }
@@ -49,6 +49,13 @@ from okstra_ctl.final_report_paths import final_report_data_path as _data_path_f
49
49
  from okstra_ctl.improvement_assignment import ( # noqa: E402
50
50
  validate_primary_lens_assignments,
51
51
  )
52
+ from okstra_ctl.worker_prompt_policy import GRILLING_LOG_HEADER # noqa: E402
53
+ from okstra_ctl.scope_provenance import ( # noqa: E402
54
+ brief_headings,
55
+ normalize_heading,
56
+ parse_source,
57
+ resolve_chain,
58
+ )
52
59
  from okstra_ctl.design_prep import ( # noqa: E402
53
60
  DesignPrepError,
54
61
  _planning_seq as _design_prep_planning_seq,
@@ -472,7 +479,7 @@ def _grilling_log_path_from_prompts(
472
479
  worker_ids: list[str],
473
480
  records: list[PromptRecord],
474
481
  ) -> tuple[Path | None, list[str]]:
475
- header = "**Phase 1.5 Grilling Log:**"
482
+ header = GRILLING_LOG_HEADER
476
483
  values: set[str] = set()
477
484
  errors: list[str] = []
478
485
  for record in records:
@@ -2869,6 +2876,134 @@ def _validate_requirement_coverage_covered_by(data: dict, failures: list[str]) -
2869
2876
  )
2870
2877
 
2871
2878
 
2879
+ def _validate_requirement_provenance(
2880
+ data: dict, brief_path: Path, failures: list[str]
2881
+ ) -> None:
2882
+ """Every requirement must declare where it came from.
2883
+
2884
+ The planning input template already states that any change beyond what
2885
+ `Requirement Summary` demands is out of scope by default; without this
2886
+ check that sentence has no enforcement point and a phase can invent
2887
+ requirements freely.
2888
+ """
2889
+ ip = data.get("implementationPlanning")
2890
+ if not isinstance(ip, dict):
2891
+ return
2892
+ rows = [r for r in (ip.get("requirementCoverage") or []) if isinstance(r, dict)]
2893
+ if not rows:
2894
+ return
2895
+
2896
+ refs = {
2897
+ str(r.get("id") or "").strip(): parse_source(str(r.get("source") or ""))
2898
+ for r in rows
2899
+ }
2900
+ headings = brief_headings(brief_path)
2901
+
2902
+ for rid, ref in refs.items():
2903
+ if ref.kind == "brief" and headings:
2904
+ if normalize_heading(ref.value) not in headings:
2905
+ failures.append(
2906
+ f"final-report data.json: requirementCoverage `{rid}` cites brief "
2907
+ f"heading `{ref.value}` which does not exist in the brief. A "
2908
+ "requirement must trace to a line the reporter actually wrote."
2909
+ )
2910
+
2911
+ for rid, verdict in resolve_chain(refs).items():
2912
+ if verdict != "ok":
2913
+ failures.append(
2914
+ f"final-report data.json: requirementCoverage `{rid}` provenance "
2915
+ f"failed — {verdict}. An item with no admissible source is not a "
2916
+ "requirement: raise it as a `Blocks=approval` clarification instead."
2917
+ )
2918
+
2919
+
2920
+ _CITED_STAGE_RANGE = r"(?:[-–]|\bto\b|\bthrough\b)"
2921
+ _CITED_STAGE_ITEM = rf"\d+(?:\s*{_CITED_STAGE_RANGE}\s*\d+)?"
2922
+ # `, and` / `, &` is one separator, not a separator followed by a stray word:
2923
+ # `Stages 1, 2, and 3` is the idiomatic plural and must not orphan stage 3.
2924
+ _CITED_STAGE_SEP = r"(?:,\s*(?:and|&)?|and|&)"
2925
+ _CITED_STAGE_LIST_RE = re.compile(
2926
+ rf"\bstages?[ \t]*({_CITED_STAGE_ITEM}(?:\s*{_CITED_STAGE_SEP}\s*{_CITED_STAGE_ITEM})*)",
2927
+ re.IGNORECASE,
2928
+ )
2929
+ _CITED_STAGE_ITEM_RE = re.compile(
2930
+ rf"(\d+)(?:\s*{_CITED_STAGE_RANGE}\s*(\d+))?", re.IGNORECASE
2931
+ )
2932
+ _CITED_STAGE_RANGE_MAX_SPAN = 64
2933
+
2934
+
2935
+ def _cited_stage_numbers(covered_by: str) -> set[int]:
2936
+ """Stage numbers a `coveredBy` cell cites, in every prose form a planner writes.
2937
+
2938
+ Deliberately not `_COVERED_BY_STAGE_REF_RE`: the two directions disagree on
2939
+ what "no match" means — forward treats it as nothing to verify and passes,
2940
+ reverse treats it as nothing cited and orphans every stage — so the reverse
2941
+ reader must cover `Stages 1, 2`, `Stages 1-3` and `Stage 1 and 2` too.
2942
+ Numbers stay anchored to a word-initial `stage`/`stages` token on the same
2943
+ line; harvesting bare numbers — or letting the anchor reach across a line
2944
+ break, or match the tail of `Substage`/`Backstage` — would let any prose,
2945
+ including a row disclaiming every stage, justify any stage.
2946
+ """
2947
+ cited: set[int] = set()
2948
+ for span in _CITED_STAGE_LIST_RE.finditer(covered_by):
2949
+ for item in _CITED_STAGE_ITEM_RE.finditer(span.group(1)):
2950
+ start = int(item.group(1))
2951
+ cited.add(start)
2952
+ if item.group(2) is None:
2953
+ continue
2954
+ end = int(item.group(2))
2955
+ cited.add(end)
2956
+ # A reversed or absurdly wide range is a typo, not a citation of
2957
+ # everything between its endpoints.
2958
+ if 0 <= end - start <= _CITED_STAGE_RANGE_MAX_SPAN:
2959
+ cited.update(range(start, end))
2960
+ return cited
2961
+
2962
+
2963
+ # Statuses under which a row asserts the plan does work for the requirement.
2964
+ # `gap` is excluded: it states the plan does NOT cover the requirement, so it
2965
+ # can justify no stage. `blocked C-NNN` is included — the requirement is real
2966
+ # and the stage serving it is not invented scope, only pending a user answer,
2967
+ # and such a row already forces a non-passing gate and `approved: false`, so
2968
+ # orphaning its stage here would only push planners to delete legitimate work.
2969
+ _COVERAGE_CLAIMING_STATUS_RE = re.compile(r"^(covered|blocked C-\d{3,})$")
2970
+
2971
+
2972
+ def _validate_stage_has_requirement(data: dict, failures: list[str]) -> None:
2973
+ """Reverse of `_validate_requirement_coverage_covered_by`.
2974
+
2975
+ That check proves every requirement reaches a stage; this one proves every
2976
+ stage traces back to a requirement. Without it a plan can carry stages no
2977
+ one asked for and still pass every gate.
2978
+ """
2979
+ ip = data.get("implementationPlanning")
2980
+ if not isinstance(ip, dict):
2981
+ return
2982
+ stage_numbers = {
2983
+ s.get("stage")
2984
+ for s in (ip.get("stages") or [])
2985
+ if isinstance(s, dict) and isinstance(s.get("stage"), int)
2986
+ }
2987
+ if not stage_numbers:
2988
+ return
2989
+
2990
+ cited: set[int] = set()
2991
+ for row in (ip.get("requirementCoverage") or []):
2992
+ if not isinstance(row, dict):
2993
+ continue
2994
+ if not _COVERAGE_CLAIMING_STATUS_RE.match(str(row.get("status") or "").strip()):
2995
+ continue
2996
+ cited |= _cited_stage_numbers(str(row.get("coveredBy") or ""))
2997
+
2998
+ for orphan in sorted(stage_numbers - cited):
2999
+ failures.append(
3000
+ f"final-report data.json: Stage {orphan} is cited by no requirementCoverage "
3001
+ "row — it traces back to nothing the brief asked for. Either cite the "
3002
+ "requirement it serves, or drop it and raise it as a `Blocks=approval` "
3003
+ "clarification (profile: scope provenance)."
3004
+ )
3005
+
3006
+
2872
3007
  def _validate_final_verification_consistency(data: dict, failures: list[str]) -> None:
2873
3008
  """Enforce verdict ↔ blocker/condition/routing consistency on the
2874
3009
  final-verification data.json (SSOT). The schema guarantees field SHAPE;
@@ -3323,7 +3458,7 @@ def _validate_convergence_states(run_dir, failures) -> None:
3323
3458
  )
3324
3459
 
3325
3460
 
3326
- def _validate_requirements_discovery_fanout(run_dir, failures) -> None:
3461
+ def _validate_requirements_discovery_fanout(run_dir, failures, brief_path=None) -> None:
3327
3462
  """requirements-discovery run 에 fan-out/ 이 있으면 packet+index 를 검증해
3328
3463
  실패를 ``requirements-discovery: `` 접두로 folding 한다. fan-out 이 없으면 no-op.
3329
3464
  """
@@ -3340,7 +3475,7 @@ def _validate_requirements_discovery_fanout(run_dir, failures) -> None:
3340
3475
  f"requirements-discovery: validate_fanout import failed — {exc}"
3341
3476
  )
3342
3477
  return
3343
- result = validate_fanout(_Path(run_dir))
3478
+ result = validate_fanout(_Path(run_dir), brief_path)
3344
3479
  if not result.ok:
3345
3480
  for err in result.errors:
3346
3481
  failures.append(f"requirements-discovery: {err}")
@@ -3757,6 +3892,15 @@ def main() -> int:
3757
3892
  _validate_conformance(report_path, failures, surface_patterns=_sp)
3758
3893
  if task_type == "implementation-planning":
3759
3894
  _validate_planning_conformance_declared(report_path, failures)
3895
+ brief_relative = str(task_manifest.get("taskBriefPath") or "").strip()
3896
+ planning_brief = (
3897
+ (project_root / brief_relative).resolve()
3898
+ if brief_relative
3899
+ else project_root / "__no-brief__" # absent path → brief_headings returns set()
3900
+ )
3901
+ planning_data = _load_final_report_data(report_path)
3902
+ _validate_requirement_provenance(planning_data, planning_brief, failures)
3903
+ _validate_stage_has_requirement(planning_data, failures)
3760
3904
  if task_type == "improvement-discovery":
3761
3905
  brief_relative = str(task_manifest.get("taskBriefPath") or "").strip()
3762
3906
  brief_path = (
@@ -3767,8 +3911,14 @@ def main() -> int:
3767
3911
  run_dir = report_path.parent.parent
3768
3912
  _validate_improvement_discovery(report_path, run_dir, brief_path, failures)
3769
3913
  if task_type == "requirements-discovery":
3914
+ brief_relative = str(task_manifest.get("taskBriefPath") or "").strip()
3915
+ brief_path = (
3916
+ (project_root / brief_relative).resolve()
3917
+ if brief_relative
3918
+ else project_root / "__no-brief__" # absent path → brief_headings returns set()
3919
+ )
3770
3920
  run_dir = report_path.parent.parent
3771
- _validate_requirements_discovery_fanout(run_dir, failures)
3921
+ _validate_requirements_discovery_fanout(run_dir, failures, brief_path)
3772
3922
  # Phase-agnostic: convergence runs in every finding-producing phase.
3773
3923
  _validate_convergence_states(report_path.parent.parent, failures)
3774
3924
  validate_report_views(report_path, failures)
@@ -17,11 +17,67 @@ for _ssot_dir in (_VALIDATORS_DIR.parent / "scripts", _VALIDATORS_DIR.parent / "
17
17
 
18
18
  from okstra_ctl.work_categories import WORK_CATEGORIES # noqa: E402
19
19
  from okstra_ctl.fanout import topological_order, CycleError # noqa: E402
20
+ from okstra_ctl.scope_provenance import ( # noqa: E402
21
+ brief_headings,
22
+ normalize_heading,
23
+ parse_source,
24
+ )
20
25
 
21
26
  _NEXT_PHASES = ("error-analysis", "implementation-planning")
22
27
  _UNIT_RE = re.compile(r"unit-\d{3}")
23
28
  # index.md 번호목록 항목에서만 unit-NNN 을 추출 — 내러티브 문장 중복 방지
24
29
  _INDEX_ENTRY_RE = re.compile(r"^\s*\d+\.\s+(unit-\d{3})\b", re.MULTILINE)
30
+ # A `###` subsection inside the section still ends it: its prose is commentary,
31
+ # not provenance, and parsing it as bullets rejects legitimate packets.
32
+ _PROVENANCE_SECTION_RE = re.compile(
33
+ r"^##\s+Requirement Provenance\s*$(?P<body>.*?)(?=^#{2,}\s|\Z)",
34
+ re.MULTILINE | re.DOTALL,
35
+ )
36
+ _PROVENANCE_BULLET_RE = re.compile(r"^\s*[-*]\s+(?P<src>.+?)\s*$", re.MULTILINE)
37
+
38
+
39
+ def _check_provenance(
40
+ pkt_name: str, text: str, errors: list[str], headings: set[str]
41
+ ) -> None:
42
+ """A fan-out unit must cite the brief line that demanded it.
43
+
44
+ `derived:` is not admissible here: cross-packet derivation cannot be
45
+ resolved from a single packet, so each unit anchors directly on the brief
46
+ or on an okstra contract rule.
47
+
48
+ `headings` empty means the brief was unavailable — the grammar still
49
+ applies, only the heading-existence half degrades to unverifiable.
50
+ """
51
+ section = _PROVENANCE_SECTION_RE.search(text)
52
+ if not section:
53
+ errors.append(
54
+ f"{pkt_name}: missing `## Requirement Provenance` section — a unit must "
55
+ "cite the brief line that demanded it"
56
+ )
57
+ return
58
+ bullets = [
59
+ b for b in _PROVENANCE_BULLET_RE.findall(section.group("body")) if b.strip()
60
+ ]
61
+ if not bullets:
62
+ errors.append(
63
+ f"{pkt_name}: `## Requirement Provenance` is empty — cite at least one "
64
+ "`brief:<heading>` or `contract:<rule>`"
65
+ )
66
+ return
67
+ for raw in bullets:
68
+ ref = parse_source(raw)
69
+ if ref.kind not in ("brief", "contract"):
70
+ errors.append(
71
+ f"{pkt_name}: unrecognized provenance `{raw}` — a fan-out unit's "
72
+ "source must be `brief:<heading>` or `contract:<rule>`"
73
+ )
74
+ elif ref.kind == "brief" and headings:
75
+ if normalize_heading(ref.value) not in headings:
76
+ errors.append(
77
+ f"{pkt_name}: cites brief heading `{ref.value}` which does not "
78
+ "exist in the brief. A fan-out unit becomes the brief for a whole "
79
+ "downstream task — it must trace to a line the reporter wrote."
80
+ )
25
81
 
26
82
 
27
83
  @dataclass
@@ -54,16 +110,20 @@ def _parse_deps(raw: str) -> list[str]:
54
110
  return _UNIT_RE.findall(raw or "")
55
111
 
56
112
 
57
- def validate_fanout(run_dir: Path) -> ValidationResult:
113
+ def validate_fanout(run_dir: Path, brief_path: Path | None = None) -> ValidationResult:
58
114
  run_dir = Path(run_dir)
59
115
  fo = run_dir / "fan-out"
60
116
  if not fo.is_dir():
61
117
  return ValidationResult(ok=True)
62
118
 
119
+ headings = brief_headings(brief_path) if brief_path is not None else set()
120
+
63
121
  errors: list[str] = []
64
122
  units: dict[str, list[str]] = {}
65
123
  for pkt in sorted(fo.glob("unit-*.md")):
66
- fm = _frontmatter(pkt.read_text(encoding="utf-8"))
124
+ text = pkt.read_text(encoding="utf-8")
125
+ fm = _frontmatter(text)
126
+ _check_provenance(pkt.name, text, errors, headings)
67
127
  uid = fm.get("unit-id", "")
68
128
  if uid != pkt.stem:
69
129
  errors.append(f"{pkt.name}: unit-id {uid!r} != filename stem {pkt.stem!r}")