okstra 0.140.0 → 0.141.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.
Files changed (38) 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/launch.template.md +7 -5
  8. package/runtime/prompts/lead/convergence.md +22 -0
  9. package/runtime/prompts/lead/okstra-lead-contract.md +1 -1
  10. package/runtime/prompts/lead/plan-body-verification.md +97 -26
  11. package/runtime/prompts/lead/report-writer.md +2 -0
  12. package/runtime/prompts/profiles/error-analysis.md +1 -0
  13. package/runtime/prompts/profiles/final-verification.md +1 -1
  14. package/runtime/prompts/profiles/implementation-planning.md +6 -4
  15. package/runtime/prompts/profiles/improvement-discovery.md +2 -1
  16. package/runtime/prompts/profiles/requirements-discovery.md +2 -1
  17. package/runtime/python/okstra_ctl/build_tools.py +95 -0
  18. package/runtime/python/okstra_ctl/incremental_scope.py +95 -0
  19. package/runtime/python/okstra_ctl/render.py +26 -11
  20. package/runtime/python/okstra_ctl/render_final_report.py +24 -1
  21. package/runtime/python/okstra_ctl/rollup.py +49 -0
  22. package/runtime/python/okstra_ctl/scope_provenance.py +98 -1
  23. package/runtime/python/okstra_ctl/stage_citations.py +47 -0
  24. package/runtime/python/okstra_ctl/workflow.py +7 -4
  25. package/runtime/schemas/final-report-v1.0.schema.json +60 -1
  26. package/runtime/skills/okstra-brief-gen/SKILL.md +38 -11
  27. package/runtime/skills/okstra-rollup/SKILL.md +3 -2
  28. package/runtime/templates/reports/brief.template.md +42 -11
  29. package/runtime/templates/reports/fan-out-unit.template.md +9 -4
  30. package/runtime/templates/reports/final-report.template.md +20 -0
  31. package/runtime/templates/reports/final-verification-input.template.md +4 -2
  32. package/runtime/templates/reports/i18n/en.json +11 -4
  33. package/runtime/templates/reports/i18n/ko.json +11 -4
  34. package/runtime/templates/reports/improvement-discovery-input.template.md +2 -2
  35. package/runtime/validators/validate-brief.py +171 -26
  36. package/runtime/validators/validate-run.py +528 -86
  37. package/runtime/validators/validate_fanout.py +19 -10
  38. 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
 
@@ -3183,6 +3491,9 @@ def _read_verification_target(project_root: Path, relative: str) -> dict | None:
3183
3491
  return parsed
3184
3492
 
3185
3493
 
3494
+ _PLAN_BODY_STATE_KEYS = ("schemaVersion", "planItems", "roundHistory")
3495
+
3496
+
3186
3497
  def _validate_plan_body_state_file(
3187
3498
  data: dict,
3188
3499
  report_path: Path,
@@ -3197,8 +3508,8 @@ def _validate_plan_body_state_file(
3197
3508
  rounds found. Both defect investigations of this phase depended on the
3198
3509
  sidecar to recover that history.
3199
3510
 
3200
- Deliberately does NOT cross-check `gateResult` against data.json: the two
3201
- are different views by design (per-round history vs. final state), and
3511
+ Deliberately does NOT cross-check any gate against data.json: the two are
3512
+ different views by design (per-round history vs. final state), and
3202
3513
  demanding equality would fail every run whose self-fix loop worked.
3203
3514
  """
3204
3515
  ip = data.get("implementationPlanning")
@@ -3229,12 +3540,79 @@ def _validate_plan_body_state_file(
3229
3540
  except (OSError, json.JSONDecodeError) as exc:
3230
3541
  failures.append(f"plan-body verification state file is unreadable: {exc}")
3231
3542
  return
3232
- for key in ("schemaVersion", "gateResult", "planItems"):
3233
- if key not in state:
3234
- failures.append(
3235
- f"plan-body verification state file `{expected.name}` is "
3236
- f"missing required key `{key}`."
3237
- )
3543
+ missing = [key for key in _PLAN_BODY_STATE_KEYS if key not in state]
3544
+ for key in missing:
3545
+ failures.append(
3546
+ f"plan-body verification state file `{expected.name}` is "
3547
+ f"missing required key `{key}`."
3548
+ )
3549
+ if not missing:
3550
+ _validate_plan_body_state_rounds(
3551
+ state, pbv, expected.name, round_count, failures
3552
+ )
3553
+
3554
+
3555
+ def _validate_plan_body_state_rounds(
3556
+ state: dict,
3557
+ pbv: dict,
3558
+ name: str,
3559
+ round_count: int,
3560
+ failures: list[str],
3561
+ ) -> None:
3562
+ """Every round that ran must survive in the sidecar, its votes included.
3563
+
3564
+ The round protocol used to write this file once, before the self-fix loop,
3565
+ and never asked for it again — so a run with three re-verifications kept
3566
+ round 1 only, and the superseded rounds this file exists to preserve were
3567
+ exactly the ones it dropped (jobs dev-10269 seq 001: `roundCount` 4 in
3568
+ data.json against `round` 1 here).
3569
+ """
3570
+ history = [e for e in (state.get("roundHistory") or []) if isinstance(e, dict)]
3571
+ # A non-int `round` names no round, so it cannot cover one — and reading it
3572
+ # into a set would abort the whole validation on unhashable lead-authored JSON.
3573
+ recorded = {e["round"] for e in history if isinstance(e.get("round"), int)}
3574
+ if recorded != set(range(1, round_count + 1)):
3575
+ seen = sorted(recorded)
3576
+ failures.append(
3577
+ f"plan-body verification state file `{name}` records `roundHistory[]` "
3578
+ f"rounds {seen} but the report declares `roundCount`={round_count}. "
3579
+ f"One entry per round 1..{round_count} is required: data.json keeps "
3580
+ "only the final verdicts, so a sidecar frozen at an earlier round "
3581
+ "loses every round it superseded (plan-body-verification.md "
3582
+ '§"Round protocol" step 7 "Round completion").'
3583
+ )
3584
+ gateless = [str(e.get("round")) for e in history if not e.get("gateResult")]
3585
+ if gateless:
3586
+ failures.append(
3587
+ f"plan-body verification state file `{name}`: `roundHistory[]` "
3588
+ f"round(s) {', '.join(gateless)} carry no `gateResult`. The per-round "
3589
+ "gate is what tells the reader which round blocked and on what, and "
3590
+ "the sidecar is the only place it survives."
3591
+ )
3592
+ declared = pbv.get("selfFixRoundsApplied")
3593
+ if isinstance(declared, int) and state.get("selfFixRoundsApplied") != declared:
3594
+ failures.append(
3595
+ f"plan-body verification state file `{name}` records "
3596
+ f"`selfFixRoundsApplied`={state.get('selfFixRoundsApplied')!r} but the "
3597
+ f"report declares {declared}. The sidecar is rewritten at each round's "
3598
+ "end, so a stale count means the later rounds were never written to it."
3599
+ )
3600
+ voted = {
3601
+ vote["round"]
3602
+ for item in (state.get("planItems") or [])
3603
+ if isinstance(item, dict)
3604
+ for vote in (item.get("rounds") or [])
3605
+ if isinstance(vote, dict) and isinstance(vote.get("round"), int)
3606
+ }
3607
+ uncited = [n for n in sorted(recorded & set(range(1, round_count + 1)))
3608
+ if n not in voted]
3609
+ if uncited:
3610
+ failures.append(
3611
+ f"plan-body verification state file `{name}`: round(s) {uncited} "
3612
+ "appear in `roundHistory[]` but no `planItems[].rounds[]` entry "
3613
+ "records a vote cast in them. A re-verification round whose verdicts "
3614
+ "were never written down is precisely the history this file holds."
3615
+ )
3238
3616
 
3239
3617
 
3240
3618
  _QA_NOT_CONFIGURED_TEMPLATE = "qa-command not configured: {category}"
@@ -4456,6 +4834,89 @@ def _validate_requirement_coverage_covered_by(data: dict, failures: list[str]) -
4456
4834
  )
4457
4835
 
4458
4836
 
4837
+ # The phases that read the run's brief. Resolving the brief outside this set
4838
+ # would fire the missing-brief warning on runs (implementation,
4839
+ # final-verification, release-handoff) that never consult one.
4840
+ _BRIEF_DERIVED_PHASES = frozenset(
4841
+ {
4842
+ "requirements-discovery",
4843
+ "error-analysis",
4844
+ "implementation-planning",
4845
+ "improvement-discovery",
4846
+ }
4847
+ )
4848
+ # The subset that maps the brief's end state. improvement-discovery is absent
4849
+ # on purpose: its report is free-form markdown outside the data.json schema, so
4850
+ # it has nowhere to carry endStateCoverage.
4851
+ _END_STATE_PHASES = frozenset(
4852
+ {"requirements-discovery", "error-analysis", "implementation-planning"}
4853
+ )
4854
+
4855
+
4856
+ def _validate_end_state_coverage(
4857
+ data: dict, brief_path: Path, failures: list[str]
4858
+ ) -> None:
4859
+ """Every end-state id the brief pinned is accounted for exactly once.
4860
+
4861
+ This is the lower bound the phase contracts previously lacked: a phase could
4862
+ quietly drop a reporter requirement and still produce a clean report. The
4863
+ gate does not judge whether the mapping is true — that is the Phase 5.5
4864
+ round's job — but it makes an omission an explicit, attributable statement
4865
+ rather than a silence.
4866
+
4867
+ Legacy briefs declare no ids, so the gate self-disables for them instead of
4868
+ wedging runs authored before the contract existed.
4869
+ """
4870
+ declared = brief_end_state_ids(brief_path)
4871
+ if not declared:
4872
+ return
4873
+
4874
+ rows = [r for r in (data.get("endStateCoverage") or []) if isinstance(r, dict)]
4875
+ seen: dict[str, int] = {}
4876
+ for row in rows:
4877
+ row_id = str(row.get("id") or "").strip()
4878
+
4879
+ # Counting only declared ids keeps one mistake to one failure: an
4880
+ # undeclared id repeated twice is one "not declared" per row, not that
4881
+ # plus a duplicate report about an id the brief never had.
4882
+ if row_id not in declared:
4883
+ failures.append(
4884
+ f"final-report data.json: endStateCoverage cites {row_id!r}, which "
4885
+ "is not declared by this run's brief. Map only ids the reporter "
4886
+ "pinned."
4887
+ )
4888
+ continue
4889
+ seen[row_id] = seen.get(row_id, 0) + 1
4890
+
4891
+ disposition = str(row.get("disposition") or "").strip()
4892
+ if disposition == "addressed" and not str(row.get("coveredBy") or "").strip():
4893
+ failures.append(
4894
+ f"final-report data.json: endStateCoverage `{row_id}` is `addressed` "
4895
+ "but names no `coveredBy` anchor. State which deliverable of this "
4896
+ "phase accounts for it."
4897
+ )
4898
+ if disposition != "addressed" and not str(row.get("rationale") or "").strip():
4899
+ failures.append(
4900
+ f"final-report data.json: endStateCoverage `{row_id}` is "
4901
+ f"`{disposition or '(empty)'}` but records no `rationale`. Dropping a "
4902
+ "reporter requirement is allowed; dropping it silently is not."
4903
+ )
4904
+
4905
+ for row_id, count in seen.items():
4906
+ if count > 1:
4907
+ failures.append(
4908
+ f"final-report data.json: endStateCoverage maps `{row_id}` more than "
4909
+ f"once ({count} rows). One id, one disposition."
4910
+ )
4911
+
4912
+ for missing in sorted(declared - seen.keys()):
4913
+ failures.append(
4914
+ f"final-report data.json: brief end-state `{missing}` has no "
4915
+ "endStateCoverage row. Every pinned id needs a disposition, including "
4916
+ "`not-applicable` with a rationale."
4917
+ )
4918
+
4919
+
4459
4920
  def _validate_requirement_provenance(
4460
4921
  data: dict, brief_path: Path, failures: list[str]
4461
4922
  ) -> None:
@@ -4478,15 +4939,15 @@ def _validate_requirement_provenance(
4478
4939
  for r in rows
4479
4940
  }
4480
4941
  headings = brief_headings(brief_path)
4942
+ end_state = brief_end_state_ids(brief_path)
4481
4943
 
4482
4944
  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
- )
4945
+ problem = brief_citation_problem(ref, headings, end_state)
4946
+ if problem:
4947
+ failures.append(
4948
+ f"final-report data.json: requirementCoverage `{rid}` {problem}. A "
4949
+ "requirement must trace to a line the reporter actually wrote."
4950
+ )
4490
4951
 
4491
4952
  for rid, verdict in resolve_chain(refs).items():
4492
4953
  if verdict != "ok":
@@ -4497,47 +4958,13 @@ def _validate_requirement_provenance(
4497
4958
  )
4498
4959
 
4499
4960
 
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
4961
+ # Deliberately not `_COVERED_BY_STAGE_REF_RE`: the two directions disagree on
4962
+ # what "no match" means — forward treats it as nothing to verify and passes,
4963
+ # reverse treats it as nothing cited and orphans every stage so the reverse
4964
+ # reader must cover `Stages 1, 2`, `Stages 1-3` and `Stage 1 and 2` too. The
4965
+ # grammar is shared with the incremental-scope back-trace, which resolves the
4966
+ # same citations in the opposite direction.
4967
+ _cited_stage_numbers = cited_stage_numbers
4541
4968
 
4542
4969
 
4543
4970
  # Statuses under which a row asserts the plan does work for the requirement.
@@ -4843,6 +5270,34 @@ def validate_phase_boundary(
4843
5270
  _append_stage_structure_failures(content, failures)
4844
5271
 
4845
5272
 
5273
+ def _brief_path_from_manifest(task_manifest: dict, project_root: Path) -> Path:
5274
+ """This run's brief, or a non-existent path when the manifest names none.
5275
+
5276
+ Readers of the returned path all degrade to "no brief information" on a
5277
+ missing file, so an absent brief disables brief-derived gates rather than
5278
+ failing the run.
5279
+
5280
+ That degrade is why a named-but-missing brief warns: it is indistinguishable
5281
+ from a legacy brief at every reader (both yield an empty id set), so a
5282
+ new-format run whose brief path rotted would pass every brief-derived gate
5283
+ without anyone seeing it happen. Warning rather than failing keeps the
5284
+ existing contract — an absent brief is not by itself a run failure.
5285
+ """
5286
+ relative = str(task_manifest.get("taskBriefPath") or "").strip()
5287
+ if not relative:
5288
+ return project_root / "__no-brief__"
5289
+ resolved = (project_root / relative).resolve()
5290
+ if not resolved.is_file():
5291
+ print(
5292
+ f"validate-run: warning: task manifest names taskBriefPath "
5293
+ f"{relative!r} but no file exists there — every brief-derived check "
5294
+ "(end-state coverage, requirement provenance, fan-out provenance) "
5295
+ "is skipped for this run",
5296
+ file=sys.stderr,
5297
+ )
5298
+ return resolved
5299
+
5300
+
4846
5301
  def _parse_brief_frontmatter(brief_path: Path) -> dict:
4847
5302
  """Parse YAML frontmatter from a brief file into a flat dict.
4848
5303
 
@@ -5810,35 +6265,22 @@ def main() -> int:
5810
6265
  )
5811
6266
  for warning in conformance_warnings:
5812
6267
  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)
6268
+ if task_type in _BRIEF_DERIVED_PHASES:
6269
+ brief_path = _brief_path_from_manifest(task_manifest, project_root)
6270
+ if task_type in _END_STATE_PHASES:
6271
+ if task_type == "implementation-planning":
6272
+ _validate_planning_conformance_declared(report_path, failures)
6273
+ report_data = _load_final_report_data(report_path)
6274
+ _validate_end_state_coverage(report_data, brief_path, failures)
6275
+ if task_type == "implementation-planning":
6276
+ _validate_requirement_provenance(report_data, brief_path, failures)
6277
+ _validate_stage_has_requirement(report_data, failures)
6278
+ if task_type == "improvement-discovery":
6279
+ run_dir = report_path.parent.parent
6280
+ _validate_improvement_discovery(report_path, run_dir, brief_path, failures)
6281
+ if task_type == "requirements-discovery":
6282
+ run_dir = report_path.parent.parent
6283
+ _validate_requirements_discovery_fanout(run_dir, failures, brief_path)
5842
6284
  # Phase-agnostic: convergence runs in every finding-producing phase.
5843
6285
  _validate_convergence_states(report_path.parent.parent, failures)
5844
6286
  _validate_convergence_rounds_match_manifest(