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.
- package/README.md +1 -1
- package/docs/architecture.md +1 -1
- package/docs/cli.md +1 -1
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/agents/workers/report-writer-worker.md +4 -2
- package/runtime/prompts/launch.template.md +7 -5
- package/runtime/prompts/lead/convergence.md +22 -0
- package/runtime/prompts/lead/okstra-lead-contract.md +1 -1
- package/runtime/prompts/lead/plan-body-verification.md +97 -26
- package/runtime/prompts/lead/report-writer.md +2 -0
- package/runtime/prompts/profiles/error-analysis.md +1 -0
- package/runtime/prompts/profiles/final-verification.md +1 -1
- package/runtime/prompts/profiles/implementation-planning.md +6 -4
- package/runtime/prompts/profiles/improvement-discovery.md +2 -1
- package/runtime/prompts/profiles/requirements-discovery.md +2 -1
- package/runtime/python/okstra_ctl/build_tools.py +95 -0
- package/runtime/python/okstra_ctl/incremental_scope.py +95 -0
- package/runtime/python/okstra_ctl/render.py +26 -11
- package/runtime/python/okstra_ctl/render_final_report.py +24 -1
- package/runtime/python/okstra_ctl/rollup.py +49 -0
- package/runtime/python/okstra_ctl/scope_provenance.py +98 -1
- package/runtime/python/okstra_ctl/stage_citations.py +47 -0
- package/runtime/python/okstra_ctl/workflow.py +7 -4
- package/runtime/schemas/final-report-v1.0.schema.json +60 -1
- package/runtime/skills/okstra-brief-gen/SKILL.md +38 -11
- package/runtime/skills/okstra-rollup/SKILL.md +3 -2
- package/runtime/templates/reports/brief.template.md +42 -11
- package/runtime/templates/reports/fan-out-unit.template.md +9 -4
- package/runtime/templates/reports/final-report.template.md +20 -0
- package/runtime/templates/reports/final-verification-input.template.md +4 -2
- package/runtime/templates/reports/i18n/en.json +11 -4
- package/runtime/templates/reports/i18n/ko.json +11 -4
- package/runtime/templates/reports/improvement-discovery-input.template.md +2 -2
- package/runtime/validators/validate-brief.py +171 -26
- package/runtime/validators/validate-run.py +528 -86
- package/runtime/validators/validate_fanout.py +19 -10
- 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
|
|
3201
|
-
|
|
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
|
|
3233
|
-
|
|
3234
|
-
|
|
3235
|
-
|
|
3236
|
-
|
|
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
|
-
|
|
4484
|
-
|
|
4485
|
-
|
|
4486
|
-
|
|
4487
|
-
|
|
4488
|
-
|
|
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
|
-
|
|
4501
|
-
|
|
4502
|
-
#
|
|
4503
|
-
# `Stages 1, 2
|
|
4504
|
-
|
|
4505
|
-
|
|
4506
|
-
|
|
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
|
|
5814
|
-
|
|
5815
|
-
|
|
5816
|
-
|
|
5817
|
-
|
|
5818
|
-
|
|
5819
|
-
|
|
5820
|
-
|
|
5821
|
-
|
|
5822
|
-
|
|
5823
|
-
|
|
5824
|
-
|
|
5825
|
-
|
|
5826
|
-
|
|
5827
|
-
|
|
5828
|
-
|
|
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(
|