okstra 0.139.0 → 0.141.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.
Files changed (42) hide show
  1. package/README.md +1 -1
  2. package/docs/architecture.md +1 -1
  3. package/docs/cli.md +1 -1
  4. package/package.json +1 -1
  5. package/runtime/BUILD.json +2 -2
  6. package/runtime/agents/workers/report-writer-worker.md +4 -2
  7. package/runtime/prompts/coding-preflight/clean-code.md +55 -0
  8. package/runtime/prompts/coding-preflight/overview.md +3 -2
  9. package/runtime/prompts/launch.template.md +7 -5
  10. package/runtime/prompts/lead/convergence.md +22 -0
  11. package/runtime/prompts/lead/plan-body-verification.md +45 -6
  12. package/runtime/prompts/lead/report-writer.md +2 -0
  13. package/runtime/prompts/profiles/_implementation-diff-review.md +3 -2
  14. package/runtime/prompts/profiles/_implementation-self-check.md +1 -1
  15. package/runtime/prompts/profiles/error-analysis.md +1 -0
  16. package/runtime/prompts/profiles/final-verification.md +1 -1
  17. package/runtime/prompts/profiles/implementation-planning.md +6 -4
  18. package/runtime/prompts/profiles/improvement-discovery.md +2 -1
  19. package/runtime/prompts/profiles/requirements-discovery.md +2 -1
  20. package/runtime/python/okstra_ctl/build_tools.py +95 -0
  21. package/runtime/python/okstra_ctl/incremental_scope.py +95 -0
  22. package/runtime/python/okstra_ctl/render.py +26 -11
  23. package/runtime/python/okstra_ctl/render_final_report.py +24 -1
  24. package/runtime/python/okstra_ctl/rollup.py +49 -0
  25. package/runtime/python/okstra_ctl/scope_provenance.py +98 -1
  26. package/runtime/python/okstra_ctl/stage_citations.py +47 -0
  27. package/runtime/python/okstra_ctl/workflow.py +7 -4
  28. package/runtime/schemas/final-report-v1.0.schema.json +60 -1
  29. package/runtime/skills/okstra-brief-gen/SKILL.md +38 -11
  30. package/runtime/skills/okstra-code-review/SKILL.md +1 -1
  31. package/runtime/skills/okstra-rollup/SKILL.md +3 -2
  32. package/runtime/templates/reports/brief.template.md +42 -11
  33. package/runtime/templates/reports/fan-out-unit.template.md +9 -4
  34. package/runtime/templates/reports/final-report.template.md +20 -0
  35. package/runtime/templates/reports/final-verification-input.template.md +4 -2
  36. package/runtime/templates/reports/i18n/en.json +11 -4
  37. package/runtime/templates/reports/i18n/ko.json +11 -4
  38. package/runtime/templates/reports/improvement-discovery-input.template.md +2 -2
  39. package/runtime/validators/validate-brief.py +171 -26
  40. package/runtime/validators/validate-run.py +450 -78
  41. package/runtime/validators/validate_fanout.py +19 -10
  42. package/runtime/validators/validate_improvement_report.py +17 -4
@@ -44,6 +44,11 @@ from okstra_ctl.conformance import ( # noqa: E402
44
44
  validate_conformance_manifest,
45
45
  )
46
46
  from okstra_ctl.paths import RunRef # noqa: E402
47
+ from okstra_ctl.build_tools import ( # noqa: E402
48
+ command_invokes_build_tool,
49
+ resolve_build_tool_tokens,
50
+ )
51
+ from okstra_ctl.stage_citations import cited_stage_numbers # noqa: E402
47
52
  from okstra_ctl.workflow import DEFAULT_NEXT_PHASE, PHASE_SEQUENCE # noqa: E402
48
53
  from okstra_ctl.md_table import ( # noqa: E402
49
54
  is_separator_row as _is_markdown_separator,
@@ -55,8 +60,9 @@ from okstra_ctl.improvement_assignment import ( # noqa: E402
55
60
  )
56
61
  from okstra_ctl.worker_prompt_policy import GRILLING_LOG_HEADER # noqa: E402
57
62
  from okstra_ctl.scope_provenance import ( # noqa: E402
63
+ brief_citation_problem,
64
+ brief_end_state_ids,
58
65
  brief_headings,
59
- normalize_heading,
60
66
  parse_source,
61
67
  resolve_chain,
62
68
  )
@@ -2317,6 +2323,18 @@ def validate_final_report_data(
2317
2323
  _validate_implementation_planning_decision_drafts(data, failures)
2318
2324
  _validate_plan_body_gate_recompute(data, failures)
2319
2325
  _validate_gate_blocked_by(data, failures)
2326
+ _validate_participating_analysers(data, failures)
2327
+ _validate_self_fix_rewrite_scope(data, failures)
2328
+ for warning in _detect_self_fix_recurrence(
2329
+ (data.get("implementationPlanning") or {}).get("planBodyVerification") or {}
2330
+ ):
2331
+ print(f"validate-run: warning: {warning}", file=sys.stderr)
2332
+ for warning in _detect_unmapped_incremental_fallback(data, report_path):
2333
+ print(f"validate-run: warning: {warning}", file=sys.stderr)
2334
+ for warning in _detect_missing_dependency_precondition(
2335
+ data, _project_root_from_report(report_path)
2336
+ ):
2337
+ print(f"validate-run: warning: {warning}", file=sys.stderr)
2320
2338
  _validate_supersession_ledger(data, failures)
2321
2339
  _validate_self_fix_grouping(data, failures)
2322
2340
  _validate_clarification_evidence_note(data, failures)
@@ -2737,6 +2755,296 @@ def _gate_blocking_causes(pbv: dict, coverage_blockers: list[str]) -> set[str]:
2737
2755
  return causes
2738
2756
 
2739
2757
 
2758
+ _CHECKLIST_REF_RE = re.compile(r"VC-\d+")
2759
+
2760
+
2761
+ def _project_root_from_report(report_path: Path) -> Path:
2762
+ """Walk out of `<project>/.okstra/tasks/.../reports/` to the project root."""
2763
+ for parent in report_path.parents:
2764
+ if parent.name == ".okstra":
2765
+ return parent.parent
2766
+ return report_path.parent
2767
+
2768
+
2769
+ def _detect_missing_dependency_precondition(
2770
+ data: dict, project_root: Path
2771
+ ) -> list[str]:
2772
+ """A stage that runs the toolchain must point at a declared precondition.
2773
+
2774
+ The planning worktree installs no dependencies, so `yarn … test` cannot run
2775
+ there. A plan that says nothing about it produces steps whose commands die
2776
+ on `exit 127`, and the verification round then spends itself on a defect the
2777
+ planner cannot fix by editing the plan.
2778
+
2779
+ The shape checked is the one an observed self-fix loop arrived at after two
2780
+ rounds: one `phase: pre` checklist item declaring the install, referenced by
2781
+ every stage that needs it. Only the reference is machine-checked — whether
2782
+ the cited item genuinely covers dependencies is a semantic judgement left to
2783
+ the §5.5.9 round, the same boundary the scope-provenance gate draws.
2784
+ """
2785
+ planning = data.get("implementationPlanning")
2786
+ if not isinstance(planning, dict):
2787
+ return []
2788
+ tokens = resolve_build_tool_tokens(project_root)
2789
+ if not tokens:
2790
+ return []
2791
+
2792
+ checklist = {
2793
+ str(row.get("id")): str(row.get("phase") or "")
2794
+ for row in (planning.get("validationChecklist") or [])
2795
+ if isinstance(row, dict) and row.get("id")
2796
+ }
2797
+
2798
+ warnings: list[str] = []
2799
+ for stage in planning.get("stages") or []:
2800
+ if not isinstance(stage, dict):
2801
+ continue
2802
+ commands = [
2803
+ str(step.get("command") or "")
2804
+ for step in (stage.get("stepwiseExecution") or [])
2805
+ if isinstance(step, dict)
2806
+ ]
2807
+ if not any(command_invokes_build_tool(c, tokens=tokens) for c in commands):
2808
+ continue
2809
+ refs = _CHECKLIST_REF_RE.findall(str(stage.get("stageValidation") or ""))
2810
+ if not refs:
2811
+ warnings.append(
2812
+ f"Stage {stage.get('stage')} runs the project toolchain but its "
2813
+ "Stage Validation cites no `VC-NNN` precondition. The planning "
2814
+ "worktree has no dependencies installed, so declare the install "
2815
+ "once as a `phase: pre` Validation Checklist item and reference "
2816
+ "it here."
2817
+ )
2818
+ continue
2819
+ if not any(checklist.get(ref) == "pre" for ref in refs):
2820
+ cited = ", ".join(sorted(set(refs)))
2821
+ warnings.append(
2822
+ f"Stage {stage.get('stage')} runs the project toolchain and cites "
2823
+ f"{cited}, but none of those is a `phase: pre` Validation "
2824
+ "Checklist item — a precondition verified after the fact is not a "
2825
+ "precondition."
2826
+ )
2827
+ return warnings
2828
+
2829
+
2830
+ _UNMAPPED_FALLBACK_REASON = "no impacted stages resolved"
2831
+
2832
+
2833
+ def _prior_planning_data(report_path: Path) -> dict | None:
2834
+ """The newest implementation-planning data.json preceding *report_path*."""
2835
+ match = re.search(r"-(\d+)\.md$", report_path.name)
2836
+ if not match:
2837
+ return None
2838
+ current_seq = int(match.group(1))
2839
+ candidates = sorted(
2840
+ (
2841
+ path
2842
+ for path in report_path.parent.glob(
2843
+ "final-report-implementation-planning-*.data.json"
2844
+ )
2845
+ if (m := re.search(r"-(\d+)\.data\.json$", path.name))
2846
+ and int(m.group(1)) < current_seq
2847
+ ),
2848
+ key=lambda p: p.name,
2849
+ )
2850
+ for path in reversed(candidates):
2851
+ try:
2852
+ payload = json.loads(path.read_text(encoding="utf-8"))
2853
+ except (OSError, json.JSONDecodeError):
2854
+ continue
2855
+ if isinstance(payload, dict):
2856
+ return payload
2857
+ return None
2858
+
2859
+
2860
+ def _detect_unmapped_incremental_fallback(
2861
+ data: dict, report_path: Path
2862
+ ) -> list[str]:
2863
+ """A re-run that fell back to full while the prior report could have mapped it.
2864
+
2865
+ Both "the answer restructures the plan" and "no stage could be resolved"
2866
+ return `mode: full`, and only the second is a missed narrowing. The lead
2867
+ declares the first through `--full-reason`, so the reason prefix separates
2868
+ them; this reports the second only when the trace would have succeeded.
2869
+
2870
+ Advisory: a lead that never passed `--full-reason` produces the fallback
2871
+ reason for both cases, so failing here would punish runs written before the
2872
+ flag existed.
2873
+ """
2874
+ planning = data.get("implementationPlanning")
2875
+ if not isinstance(planning, dict):
2876
+ return []
2877
+ decision = planning.get("incrementalDecision")
2878
+ if not isinstance(decision, dict) or decision.get("mode") != "full":
2879
+ return []
2880
+ if _UNMAPPED_FALLBACK_REASON not in str(decision.get("reason") or ""):
2881
+ return []
2882
+
2883
+ answered = _answered_clarification_ids(data)
2884
+ if not answered:
2885
+ return []
2886
+ prior = _prior_planning_data(report_path)
2887
+ if prior is None:
2888
+ return []
2889
+
2890
+ try:
2891
+ from okstra_ctl.incremental_scope import clarification_impacted_stages
2892
+
2893
+ stages = clarification_impacted_stages(prior, set(answered))
2894
+ except (ImportError, ValueError):
2895
+ return []
2896
+ if not stages:
2897
+ return []
2898
+ return [
2899
+ "incrementalDecision fell back to full for lack of a resolved stage, but "
2900
+ f"the prior report maps {', '.join(sorted(answered))} to stage(s) "
2901
+ f"{', '.join(str(s) for s in sorted(stages))}. Pass the answered ids "
2902
+ "through `--answered-clarifications` so the re-run narrows, or declare "
2903
+ "the structural change with `--full-reason` when full is the judgement."
2904
+ ]
2905
+
2906
+
2907
+ _SELF_FIX_NOTE_ROUND_RE = re.compile(r"self-fixed in round\s*(\d+)", re.IGNORECASE)
2908
+
2909
+
2910
+ def _items_resolved_in_round(plan_items: object, round_number: int) -> set[str]:
2911
+ resolved: set[str] = set()
2912
+ for item in plan_items if isinstance(plan_items, list) else []:
2913
+ if not isinstance(item, dict):
2914
+ continue
2915
+ match = _SELF_FIX_NOTE_ROUND_RE.search(str(item.get("selfFixNote") or ""))
2916
+ if match and int(match.group(1)) == round_number:
2917
+ resolved.add(str(item.get("id")))
2918
+ return resolved
2919
+
2920
+
2921
+ def _detect_self_fix_recurrence(pbv: dict) -> list[str]:
2922
+ """Rounds that re-target ground the previous round already worked, unresolved.
2923
+
2924
+ `no-progress` counts resolutions, which a round can claim while its
2925
+ correction introduces the next round's defect — the observed shape was two
2926
+ rounds spent on one identical seven-item set. Repeating the previous
2927
+ round's *unresolved* remainder is the earlier signal.
2928
+
2929
+ Advisory only. Narrowing onto what the last round genuinely left open is
2930
+ legitimate progress, and the rule separating that from re-digging the same
2931
+ hole is not settled (design D-1), so this reports rather than fails.
2932
+ """
2933
+ groups = pbv.get("selfFixGroups") if isinstance(pbv, dict) else None
2934
+ if not isinstance(groups, list):
2935
+ return []
2936
+ by_round: dict[int, set[str]] = {}
2937
+ for group in groups:
2938
+ if not isinstance(group, dict) or not isinstance(group.get("round"), int):
2939
+ continue
2940
+ ids = {str(i) for i in group.get("itemIds") or []}
2941
+ by_round.setdefault(group["round"], set()).update(ids)
2942
+
2943
+ warnings: list[str] = []
2944
+ plan_items = pbv.get("planItems")
2945
+ for round_number in sorted(by_round)[1:]:
2946
+ previous = by_round.get(round_number - 1)
2947
+ if not previous:
2948
+ continue
2949
+ unresolved = previous - _items_resolved_in_round(plan_items, round_number - 1)
2950
+ current = by_round[round_number]
2951
+ if current and current <= unresolved:
2952
+ warnings.append(
2953
+ f"self-fix round {round_number} re-targets only items round "
2954
+ f"{round_number - 1} left unresolved ({', '.join(sorted(current))}) "
2955
+ "— the previous round's correction did not move this cause. "
2956
+ "Consider exiting with `cause-group-recurrence` instead of "
2957
+ "spending the remaining budget on the same ground."
2958
+ )
2959
+ return warnings
2960
+
2961
+
2962
+ def _validate_self_fix_rewrite_scope(data: dict, failures: list[str]) -> None:
2963
+ """Keep the rewrite-reach measurement internally consistent.
2964
+
2965
+ No threshold is applied: the contract calls a self-fix round a targeted
2966
+ correction rather than a full regeneration, but how far a correct one
2967
+ reaches is not yet known from enough runs to name a number. What is
2968
+ checkable now is that the narrower figure sits inside the wider one — an
2969
+ outside-scope path missing from the rewritten set makes the reach read
2970
+ smaller than it was.
2971
+ """
2972
+ groups = (
2973
+ (data.get("implementationPlanning") or {})
2974
+ .get("planBodyVerification") or {}
2975
+ ).get("selfFixGroups")
2976
+ for group in groups if isinstance(groups, list) else []:
2977
+ if not isinstance(group, dict):
2978
+ continue
2979
+ outside = group.get("outsideScopePaths")
2980
+ if not isinstance(outside, list) or not outside:
2981
+ continue
2982
+ rewritten = group.get("rewrittenPaths")
2983
+ if not isinstance(rewritten, list):
2984
+ failures.append(
2985
+ "final-report data.json: selfFixGroups round "
2986
+ f"{group.get('round')} lists outsideScopePaths without "
2987
+ "rewrittenPaths — the wider set is what the narrower one is a "
2988
+ "subset of, so it cannot be omitted."
2989
+ )
2990
+ continue
2991
+ for path in outside:
2992
+ if path not in rewritten:
2993
+ failures.append(
2994
+ "final-report data.json: selfFixGroups round "
2995
+ f"{group.get('round')} lists `{path}` as outside-scope but "
2996
+ "not among rewrittenPaths — a path the round touched belongs "
2997
+ "in both."
2998
+ )
2999
+
3000
+
3001
+ def _validate_participating_analysers(data: dict, failures: list[str]) -> None:
3002
+ """The gate's own arithmetic base, checked against the votes it ran on.
3003
+
3004
+ A majority over two votes and a majority over three are different claims,
3005
+ and a shrunken roster loosens the gate silently: with two analysers,
3006
+ 1-AGREE/1-DISAGREE is a tie, so it never reaches `majority-disagree`. The
3007
+ field only reports; the arithmetic is unchanged. It is recomputable from
3008
+ the recorded verdicts, so a figure the table denies is a defect.
3009
+ """
3010
+ pbv = ((data.get("implementationPlanning") or {}).get("planBodyVerification") or {})
3011
+ declared = pbv.get("participatingAnalysers")
3012
+ if not isinstance(declared, dict):
3013
+ return
3014
+
3015
+ rostered = declared.get("rostered")
3016
+ voting = declared.get("voting")
3017
+ if not isinstance(rostered, int) or not isinstance(voting, int):
3018
+ failures.append(
3019
+ "final-report data.json: planBodyVerification.participatingAnalysers "
3020
+ "needs integer `rostered` and `voting`."
3021
+ )
3022
+ return
3023
+ if voting > rostered:
3024
+ failures.append(
3025
+ "final-report data.json: planBodyVerification.participatingAnalysers "
3026
+ f"claims {voting} voting of {rostered} rostered — more workers voted "
3027
+ "than were on the roster."
3028
+ )
3029
+ return
3030
+
3031
+ observed = {
3032
+ str(v.get("worker"))
3033
+ for item in (pbv.get("planItems") or [])
3034
+ if isinstance(item, dict)
3035
+ for v in (item.get("verdicts") or [])
3036
+ if isinstance(v, dict) and str(v.get("verdict") or "") != "verification-error"
3037
+ }
3038
+ if observed and voting != len(observed):
3039
+ failures.append(
3040
+ "final-report data.json: planBodyVerification.participatingAnalysers "
3041
+ f"declares {voting} voting analyser(s) but the recorded verdicts carry "
3042
+ f"{len(observed)} ({', '.join(sorted(observed))}). A worker whose "
3043
+ "dispatch returned no result is excluded from the gate arithmetic and "
3044
+ "must not be counted here either."
3045
+ )
3046
+
3047
+
2740
3048
  def _validate_gate_blocked_by(data: dict, failures: list[str]) -> None:
2741
3049
  """The gate value names an *outcome*; `gateBlockedBy` names the *cause*.
2742
3050
 
@@ -4456,6 +4764,89 @@ def _validate_requirement_coverage_covered_by(data: dict, failures: list[str]) -
4456
4764
  )
4457
4765
 
4458
4766
 
4767
+ # The phases that read the run's brief. Resolving the brief outside this set
4768
+ # would fire the missing-brief warning on runs (implementation,
4769
+ # final-verification, release-handoff) that never consult one.
4770
+ _BRIEF_DERIVED_PHASES = frozenset(
4771
+ {
4772
+ "requirements-discovery",
4773
+ "error-analysis",
4774
+ "implementation-planning",
4775
+ "improvement-discovery",
4776
+ }
4777
+ )
4778
+ # The subset that maps the brief's end state. improvement-discovery is absent
4779
+ # on purpose: its report is free-form markdown outside the data.json schema, so
4780
+ # it has nowhere to carry endStateCoverage.
4781
+ _END_STATE_PHASES = frozenset(
4782
+ {"requirements-discovery", "error-analysis", "implementation-planning"}
4783
+ )
4784
+
4785
+
4786
+ def _validate_end_state_coverage(
4787
+ data: dict, brief_path: Path, failures: list[str]
4788
+ ) -> None:
4789
+ """Every end-state id the brief pinned is accounted for exactly once.
4790
+
4791
+ This is the lower bound the phase contracts previously lacked: a phase could
4792
+ quietly drop a reporter requirement and still produce a clean report. The
4793
+ gate does not judge whether the mapping is true — that is the Phase 5.5
4794
+ round's job — but it makes an omission an explicit, attributable statement
4795
+ rather than a silence.
4796
+
4797
+ Legacy briefs declare no ids, so the gate self-disables for them instead of
4798
+ wedging runs authored before the contract existed.
4799
+ """
4800
+ declared = brief_end_state_ids(brief_path)
4801
+ if not declared:
4802
+ return
4803
+
4804
+ rows = [r for r in (data.get("endStateCoverage") or []) if isinstance(r, dict)]
4805
+ seen: dict[str, int] = {}
4806
+ for row in rows:
4807
+ row_id = str(row.get("id") or "").strip()
4808
+
4809
+ # Counting only declared ids keeps one mistake to one failure: an
4810
+ # undeclared id repeated twice is one "not declared" per row, not that
4811
+ # plus a duplicate report about an id the brief never had.
4812
+ if row_id not in declared:
4813
+ failures.append(
4814
+ f"final-report data.json: endStateCoverage cites {row_id!r}, which "
4815
+ "is not declared by this run's brief. Map only ids the reporter "
4816
+ "pinned."
4817
+ )
4818
+ continue
4819
+ seen[row_id] = seen.get(row_id, 0) + 1
4820
+
4821
+ disposition = str(row.get("disposition") or "").strip()
4822
+ if disposition == "addressed" and not str(row.get("coveredBy") or "").strip():
4823
+ failures.append(
4824
+ f"final-report data.json: endStateCoverage `{row_id}` is `addressed` "
4825
+ "but names no `coveredBy` anchor. State which deliverable of this "
4826
+ "phase accounts for it."
4827
+ )
4828
+ if disposition != "addressed" and not str(row.get("rationale") or "").strip():
4829
+ failures.append(
4830
+ f"final-report data.json: endStateCoverage `{row_id}` is "
4831
+ f"`{disposition or '(empty)'}` but records no `rationale`. Dropping a "
4832
+ "reporter requirement is allowed; dropping it silently is not."
4833
+ )
4834
+
4835
+ for row_id, count in seen.items():
4836
+ if count > 1:
4837
+ failures.append(
4838
+ f"final-report data.json: endStateCoverage maps `{row_id}` more than "
4839
+ f"once ({count} rows). One id, one disposition."
4840
+ )
4841
+
4842
+ for missing in sorted(declared - seen.keys()):
4843
+ failures.append(
4844
+ f"final-report data.json: brief end-state `{missing}` has no "
4845
+ "endStateCoverage row. Every pinned id needs a disposition, including "
4846
+ "`not-applicable` with a rationale."
4847
+ )
4848
+
4849
+
4459
4850
  def _validate_requirement_provenance(
4460
4851
  data: dict, brief_path: Path, failures: list[str]
4461
4852
  ) -> None:
@@ -4478,15 +4869,15 @@ def _validate_requirement_provenance(
4478
4869
  for r in rows
4479
4870
  }
4480
4871
  headings = brief_headings(brief_path)
4872
+ end_state = brief_end_state_ids(brief_path)
4481
4873
 
4482
4874
  for rid, ref in refs.items():
4483
- if ref.kind == "brief" and headings:
4484
- if normalize_heading(ref.value) not in headings:
4485
- failures.append(
4486
- f"final-report data.json: requirementCoverage `{rid}` cites brief "
4487
- f"heading `{ref.value}` which does not exist in the brief. A "
4488
- "requirement must trace to a line the reporter actually wrote."
4489
- )
4875
+ problem = brief_citation_problem(ref, headings, end_state)
4876
+ if problem:
4877
+ failures.append(
4878
+ f"final-report data.json: requirementCoverage `{rid}` {problem}. A "
4879
+ "requirement must trace to a line the reporter actually wrote."
4880
+ )
4490
4881
 
4491
4882
  for rid, verdict in resolve_chain(refs).items():
4492
4883
  if verdict != "ok":
@@ -4497,47 +4888,13 @@ def _validate_requirement_provenance(
4497
4888
  )
4498
4889
 
4499
4890
 
4500
- _CITED_STAGE_RANGE = r"(?:[-–]|\bto\b|\bthrough\b)"
4501
- _CITED_STAGE_ITEM = rf"\d+(?:\s*{_CITED_STAGE_RANGE}\s*\d+)?"
4502
- # `, and` / `, &` is one separator, not a separator followed by a stray word:
4503
- # `Stages 1, 2, and 3` is the idiomatic plural and must not orphan stage 3.
4504
- _CITED_STAGE_SEP = r"(?:,\s*(?:and|&)?|and|&)"
4505
- _CITED_STAGE_LIST_RE = re.compile(
4506
- rf"\bstages?[ \t]*({_CITED_STAGE_ITEM}(?:\s*{_CITED_STAGE_SEP}\s*{_CITED_STAGE_ITEM})*)",
4507
- re.IGNORECASE,
4508
- )
4509
- _CITED_STAGE_ITEM_RE = re.compile(
4510
- rf"(\d+)(?:\s*{_CITED_STAGE_RANGE}\s*(\d+))?", re.IGNORECASE
4511
- )
4512
- _CITED_STAGE_RANGE_MAX_SPAN = 64
4513
-
4514
-
4515
- def _cited_stage_numbers(covered_by: str) -> set[int]:
4516
- """Stage numbers a `coveredBy` cell cites, in every prose form a planner writes.
4517
-
4518
- Deliberately not `_COVERED_BY_STAGE_REF_RE`: the two directions disagree on
4519
- what "no match" means — forward treats it as nothing to verify and passes,
4520
- reverse treats it as nothing cited and orphans every stage — so the reverse
4521
- reader must cover `Stages 1, 2`, `Stages 1-3` and `Stage 1 and 2` too.
4522
- Numbers stay anchored to a word-initial `stage`/`stages` token on the same
4523
- line; harvesting bare numbers — or letting the anchor reach across a line
4524
- break, or match the tail of `Substage`/`Backstage` — would let any prose,
4525
- including a row disclaiming every stage, justify any stage.
4526
- """
4527
- cited: set[int] = set()
4528
- for span in _CITED_STAGE_LIST_RE.finditer(covered_by):
4529
- for item in _CITED_STAGE_ITEM_RE.finditer(span.group(1)):
4530
- start = int(item.group(1))
4531
- cited.add(start)
4532
- if item.group(2) is None:
4533
- continue
4534
- end = int(item.group(2))
4535
- cited.add(end)
4536
- # A reversed or absurdly wide range is a typo, not a citation of
4537
- # everything between its endpoints.
4538
- if 0 <= end - start <= _CITED_STAGE_RANGE_MAX_SPAN:
4539
- cited.update(range(start, end))
4540
- return cited
4891
+ # Deliberately not `_COVERED_BY_STAGE_REF_RE`: the two directions disagree on
4892
+ # what "no match" means — forward treats it as nothing to verify and passes,
4893
+ # reverse treats it as nothing cited and orphans every stage so the reverse
4894
+ # reader must cover `Stages 1, 2`, `Stages 1-3` and `Stage 1 and 2` too. The
4895
+ # grammar is shared with the incremental-scope back-trace, which resolves the
4896
+ # same citations in the opposite direction.
4897
+ _cited_stage_numbers = cited_stage_numbers
4541
4898
 
4542
4899
 
4543
4900
  # Statuses under which a row asserts the plan does work for the requirement.
@@ -4843,6 +5200,34 @@ def validate_phase_boundary(
4843
5200
  _append_stage_structure_failures(content, failures)
4844
5201
 
4845
5202
 
5203
+ def _brief_path_from_manifest(task_manifest: dict, project_root: Path) -> Path:
5204
+ """This run's brief, or a non-existent path when the manifest names none.
5205
+
5206
+ Readers of the returned path all degrade to "no brief information" on a
5207
+ missing file, so an absent brief disables brief-derived gates rather than
5208
+ failing the run.
5209
+
5210
+ That degrade is why a named-but-missing brief warns: it is indistinguishable
5211
+ from a legacy brief at every reader (both yield an empty id set), so a
5212
+ new-format run whose brief path rotted would pass every brief-derived gate
5213
+ without anyone seeing it happen. Warning rather than failing keeps the
5214
+ existing contract — an absent brief is not by itself a run failure.
5215
+ """
5216
+ relative = str(task_manifest.get("taskBriefPath") or "").strip()
5217
+ if not relative:
5218
+ return project_root / "__no-brief__"
5219
+ resolved = (project_root / relative).resolve()
5220
+ if not resolved.is_file():
5221
+ print(
5222
+ f"validate-run: warning: task manifest names taskBriefPath "
5223
+ f"{relative!r} but no file exists there — every brief-derived check "
5224
+ "(end-state coverage, requirement provenance, fan-out provenance) "
5225
+ "is skipped for this run",
5226
+ file=sys.stderr,
5227
+ )
5228
+ return resolved
5229
+
5230
+
4846
5231
  def _parse_brief_frontmatter(brief_path: Path) -> dict:
4847
5232
  """Parse YAML frontmatter from a brief file into a flat dict.
4848
5233
 
@@ -5810,35 +6195,22 @@ def main() -> int:
5810
6195
  )
5811
6196
  for warning in conformance_warnings:
5812
6197
  print(f"validate-run: warning: {warning}", file=sys.stderr)
5813
- if task_type == "implementation-planning":
5814
- _validate_planning_conformance_declared(report_path, failures)
5815
- brief_relative = str(task_manifest.get("taskBriefPath") or "").strip()
5816
- planning_brief = (
5817
- (project_root / brief_relative).resolve()
5818
- if brief_relative
5819
- else project_root / "__no-brief__" # absent path → brief_headings returns set()
5820
- )
5821
- planning_data = _load_final_report_data(report_path)
5822
- _validate_requirement_provenance(planning_data, planning_brief, failures)
5823
- _validate_stage_has_requirement(planning_data, failures)
5824
- if task_type == "improvement-discovery":
5825
- brief_relative = str(task_manifest.get("taskBriefPath") or "").strip()
5826
- brief_path = (
5827
- (project_root / brief_relative).resolve()
5828
- if brief_relative
5829
- else project_root / "__no-brief__" # absent path → _parse_brief_frontmatter returns {}
5830
- )
5831
- run_dir = report_path.parent.parent
5832
- _validate_improvement_discovery(report_path, run_dir, brief_path, failures)
5833
- if task_type == "requirements-discovery":
5834
- brief_relative = str(task_manifest.get("taskBriefPath") or "").strip()
5835
- brief_path = (
5836
- (project_root / brief_relative).resolve()
5837
- if brief_relative
5838
- else project_root / "__no-brief__" # absent path → brief_headings returns set()
5839
- )
5840
- run_dir = report_path.parent.parent
5841
- _validate_requirements_discovery_fanout(run_dir, failures, brief_path)
6198
+ if task_type in _BRIEF_DERIVED_PHASES:
6199
+ brief_path = _brief_path_from_manifest(task_manifest, project_root)
6200
+ if task_type in _END_STATE_PHASES:
6201
+ if task_type == "implementation-planning":
6202
+ _validate_planning_conformance_declared(report_path, failures)
6203
+ report_data = _load_final_report_data(report_path)
6204
+ _validate_end_state_coverage(report_data, brief_path, failures)
6205
+ if task_type == "implementation-planning":
6206
+ _validate_requirement_provenance(report_data, brief_path, failures)
6207
+ _validate_stage_has_requirement(report_data, failures)
6208
+ if task_type == "improvement-discovery":
6209
+ run_dir = report_path.parent.parent
6210
+ _validate_improvement_discovery(report_path, run_dir, brief_path, failures)
6211
+ if task_type == "requirements-discovery":
6212
+ run_dir = report_path.parent.parent
6213
+ _validate_requirements_discovery_fanout(run_dir, failures, brief_path)
5842
6214
  # Phase-agnostic: convergence runs in every finding-producing phase.
5843
6215
  _validate_convergence_states(report_path.parent.parent, failures)
5844
6216
  _validate_convergence_rounds_match_manifest(
@@ -18,8 +18,9 @@ for _ssot_dir in (_VALIDATORS_DIR.parent / "scripts", _VALIDATORS_DIR.parent / "
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
20
  from okstra_ctl.scope_provenance import ( # noqa: E402
21
+ brief_citation_problem,
22
+ brief_end_state_ids,
21
23
  brief_headings,
22
- normalize_heading,
23
24
  parse_source,
24
25
  )
25
26
 
@@ -37,7 +38,11 @@ _PROVENANCE_BULLET_RE = re.compile(r"^\s*[-*]\s+(?P<src>.+?)\s*$", re.MULTILINE)
37
38
 
38
39
 
39
40
  def _check_provenance(
40
- pkt_name: str, text: str, errors: list[str], headings: set[str]
41
+ pkt_name: str,
42
+ text: str,
43
+ errors: list[str],
44
+ headings: set[str],
45
+ end_state: set[str],
41
46
  ) -> None:
42
47
  """A fan-out unit must cite the brief line that demanded it.
43
48
 
@@ -47,6 +52,8 @@ def _check_provenance(
47
52
 
48
53
  `headings` empty means the brief was unavailable — the grammar still
49
54
  applies, only the heading-existence half degrades to unverifiable.
55
+ `end_state` empty means the brief predates the end-state sections, so the
56
+ older heading form is still the admissible one.
50
57
  """
51
58
  section = _PROVENANCE_SECTION_RE.search(text)
52
59
  if not section:
@@ -61,7 +68,7 @@ def _check_provenance(
61
68
  if not bullets:
62
69
  errors.append(
63
70
  f"{pkt_name}: `## Requirement Provenance` is empty — cite at least one "
64
- "`brief:<heading>` or `contract:<rule>`"
71
+ "`brief:EB-001` (or the legacy `brief:<heading>`) or `contract:<rule>`"
65
72
  )
66
73
  return
67
74
  for raw in bullets:
@@ -69,14 +76,15 @@ def _check_provenance(
69
76
  if ref.kind not in ("brief", "contract"):
70
77
  errors.append(
71
78
  f"{pkt_name}: unrecognized provenance `{raw}` — a fan-out unit's "
72
- "source must be `brief:<heading>` or `contract:<rule>`"
79
+ "source must be `brief:EB-001` (or the legacy `brief:<heading>`) "
80
+ "or `contract:<rule>`"
73
81
  )
74
- elif ref.kind == "brief" and headings:
75
- if normalize_heading(ref.value) not in headings:
82
+ elif ref.kind == "brief":
83
+ problem = brief_citation_problem(ref, headings, end_state)
84
+ if problem:
76
85
  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."
86
+ f"{pkt_name}: {problem}. A fan-out unit becomes the brief for a "
87
+ "whole downstream task it must trace to a line the reporter wrote."
80
88
  )
81
89
 
82
90
 
@@ -117,13 +125,14 @@ def validate_fanout(run_dir: Path, brief_path: Path | None = None) -> Validation
117
125
  return ValidationResult(ok=True)
118
126
 
119
127
  headings = brief_headings(brief_path) if brief_path is not None else set()
128
+ end_state = brief_end_state_ids(brief_path) if brief_path is not None else set()
120
129
 
121
130
  errors: list[str] = []
122
131
  units: dict[str, list[str]] = {}
123
132
  for pkt in sorted(fo.glob("unit-*.md")):
124
133
  text = pkt.read_text(encoding="utf-8")
125
134
  fm = _frontmatter(text)
126
- _check_provenance(pkt.name, text, errors, headings)
135
+ _check_provenance(pkt.name, text, errors, headings, end_state)
127
136
  uid = fm.get("unit-id", "")
128
137
  if uid != pkt.stem:
129
138
  errors.append(f"{pkt.name}: unit-id {uid!r} != filename stem {pkt.stem!r}")