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.
- 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/coding-preflight/clean-code.md +55 -0
- package/runtime/prompts/coding-preflight/overview.md +3 -2
- package/runtime/prompts/launch.template.md +7 -5
- package/runtime/prompts/lead/convergence.md +22 -0
- package/runtime/prompts/lead/plan-body-verification.md +45 -6
- package/runtime/prompts/lead/report-writer.md +2 -0
- package/runtime/prompts/profiles/_implementation-diff-review.md +3 -2
- package/runtime/prompts/profiles/_implementation-self-check.md +1 -1
- 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-code-review/SKILL.md +1 -1
- 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 +450 -78
- 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
|
|
|
@@ -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
|
-
|
|
4484
|
-
|
|
4485
|
-
|
|
4486
|
-
|
|
4487
|
-
|
|
4488
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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)
|
|
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,
|
|
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
|
|
79
|
+
"source must be `brief:EB-001` (or the legacy `brief:<heading>`) "
|
|
80
|
+
"or `contract:<rule>`"
|
|
73
81
|
)
|
|
74
|
-
elif ref.kind == "brief"
|
|
75
|
-
|
|
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}:
|
|
78
|
-
"
|
|
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}")
|