okstra 0.129.0 → 0.130.0

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.
@@ -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
@@ -110,9 +110,13 @@ def main(argv: list[str] | None = None) -> int:
110
110
  description="Report whether pending workers are still alive (read-only).",
111
111
  )
112
112
  parser.add_argument("--audit", action="append", default=[],
113
- help="claude-worker audit sidecar path (repeatable)")
113
+ help="in-process claude-worker audit sidecar path (repeatable)")
114
114
  parser.add_argument("--prompt", action="append", default=[],
115
- help="CLI-wrapper prompt-history path (repeatable)")
115
+ help="CLI-wrapper (codex/antigravity) prompt-history path "
116
+ "(repeatable). Only the wrappers write the artifacts "
117
+ "this probe looks for, so passing an in-process "
118
+ "claude-worker path here always reports "
119
+ "did-not-launch — probe those with --audit")
116
120
  parser.add_argument("--max-idle", type=float, default=HEARTBEAT_MAX_GAP_SECONDS,
117
121
  help="heartbeat staleness budget in seconds")
118
122
  parser.add_argument("--launch-grace", type=float, default=DEFAULT_LAUNCH_GRACE_SECONDS,
@@ -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}")
@@ -319,6 +319,17 @@ export const COMMAND_REGISTRY = [
319
319
  category: "introspection",
320
320
  summary: ["Render slim AI + self-contained HTML views of a final report"],
321
321
  },
322
+ {
323
+ name: "report-finalize",
324
+ module: "./commands/report/finalize.mjs",
325
+ export: "run",
326
+ category: "introspection",
327
+ summary: [
328
+ "Run the Phase 7 post-report sequence (token-usage,",
329
+ "render-views, spawn-followups, validate-run) in its",
330
+ "contractual order against one final report.",
331
+ ],
332
+ },
322
333
  {
323
334
  name: "render-final-report",
324
335
  module: "./commands/report/render-final-report.mjs",
@@ -0,0 +1,65 @@
1
+ import { runPythonModule } from "../../lib/python-helper.mjs";
2
+ import { resolvePaths } from "../../lib/paths.mjs";
3
+
4
+ const USAGE = `okstra report-finalize — run the Phase 7 post-report sequence
5
+
6
+ Usage:
7
+ okstra report-finalize --project-root <dir> --run-manifest <path> \\
8
+ --report <final-report-<task-type>-<seq>.md> [--team-state <path>]
9
+
10
+ Runs the four Phase 7 steps in their contractual order against one final-report:
11
+
12
+ 1. token-usage substitute real token/cost numbers into the data.json
13
+ 2. render-views write the self-contained *.html sibling (skipped when the
14
+ report has no C-* clarification rows and is not a plan
15
+ approval target)
16
+ 3. spawn-followups turn section 4 rows into task stubs
17
+ 4. validate-run validate the finished run artifacts
18
+
19
+ Every step is idempotent, so re-running after a fixed failure is safe. The
20
+ sequence stops at the first non-zero exit and reports which step failed.
21
+
22
+ This is the same code path the Codex lead adapter runs automatically after its
23
+ report-writer completes, so a Claude-led and a Codex-led run finalize
24
+ identically.
25
+
26
+ --workspace-root is owned by this command.
27
+ `;
28
+
29
+ const OWNED_FLAGS = new Set(["--workspace-root"]);
30
+
31
+ function isOwnedFlag(arg) {
32
+ if (OWNED_FLAGS.has(arg)) return true;
33
+ return [...OWNED_FLAGS].some((flag) => arg.startsWith(`${flag}=`));
34
+ }
35
+
36
+ export function buildReportFinalizeArgs(args, paths) {
37
+ return ["--workspace-root", paths.workspace, ...args];
38
+ }
39
+
40
+ export async function run(args) {
41
+ if (args.includes("--help") || args.includes("-h")) {
42
+ process.stdout.write(USAGE);
43
+ return 0;
44
+ }
45
+ if (args.length === 0) {
46
+ process.stdout.write(USAGE);
47
+ return 2;
48
+ }
49
+
50
+ const forbidden = args.find(isOwnedFlag);
51
+ if (forbidden) {
52
+ process.stderr.write(
53
+ `error: ${forbidden} is set by 'okstra report-finalize' itself — remove it from your args\n`,
54
+ );
55
+ return 2;
56
+ }
57
+
58
+ const paths = await resolvePaths();
59
+ const result = await runPythonModule({
60
+ module: "okstra_ctl.report_finalize",
61
+ args: buildReportFinalizeArgs(args, paths),
62
+ stdio: "inherit-stdout",
63
+ });
64
+ return result.code;
65
+ }