okstra 0.154.2 → 0.156.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/docs/architecture.md +1 -0
- package/docs/cli.md +1 -1
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/bin/okstra-compact-reminder.sh +34 -0
- package/runtime/bin/okstra-render-final-report.py +2 -0
- package/runtime/prompts/lead/adapters/claude-code.md +1 -0
- package/runtime/prompts/lead/convergence.md +2 -0
- package/runtime/prompts/lead/okstra-lead-contract.md +4 -1
- package/runtime/prompts/lead/plan-body-verification.md +13 -2
- package/runtime/prompts/lead/team-contract.md +40 -14
- package/runtime/python/okstra_ctl/convergence.py +31 -0
- package/runtime/python/okstra_ctl/convergence_provenance.py +185 -0
- package/runtime/python/okstra_ctl/dispatch_state.py +6 -0
- package/runtime/python/okstra_ctl/pane_reclaim.py +30 -6
- package/runtime/python/okstra_ctl/render_final_report.py +29 -0
- package/runtime/python/okstra_ctl/report_finalize.py +22 -3
- package/runtime/python/okstra_ctl/stage_map.py +98 -1
- package/runtime/python/okstra_ctl/worker_liveness.py +113 -8
- package/runtime/python/okstra_ctl/worker_prompt_contract.py +32 -17
- package/runtime/skills/okstra-run/SKILL.md +2 -0
- package/runtime/templates/reports/settings.template.json +11 -0
- package/runtime/validators/validate-run.py +185 -161
- package/runtime/validators/validate_session_conformance.py +57 -0
- package/src/cli-registry.mjs +7 -0
- package/src/commands/execute/plan-verify.mjs +44 -0
- package/src/lib/python-helper.mjs +37 -11
|
@@ -109,6 +109,9 @@ from okstra_ctl.convergence_engine import ( # noqa: E402
|
|
|
109
109
|
grouped_input_digest,
|
|
110
110
|
validate_final_state,
|
|
111
111
|
)
|
|
112
|
+
from okstra_ctl.convergence_provenance import ( # noqa: E402
|
|
113
|
+
run_dir_provenance_errors,
|
|
114
|
+
)
|
|
112
115
|
|
|
113
116
|
TERMINAL_STATUSES = {"completed", "timeout", "error", "not-run"}
|
|
114
117
|
ATTEMPTED_STATUSES = {"completed", "timeout", "error"}
|
|
@@ -3241,17 +3244,7 @@ def validate_final_report_data(
|
|
|
3241
3244
|
active_report_contracts = report_contracts or set()
|
|
3242
3245
|
_validate_implementation_planning_cross_project(data, failures)
|
|
3243
3246
|
_validate_implementation_planning_decision_drafts(data, failures)
|
|
3244
|
-
|
|
3245
|
-
_validate_gate_blocked_by(data, failures)
|
|
3246
|
-
_validate_participating_analysers(data, failures)
|
|
3247
|
-
_validate_self_fix_rewrite_scope(data, failures)
|
|
3248
|
-
for warning in _detect_self_fix_recurrence(
|
|
3249
|
-
(data.get("implementationPlanning") or {}).get("planBodyVerification") or {}
|
|
3250
|
-
):
|
|
3251
|
-
print(f"validate-run: warning: {warning}", file=sys.stderr)
|
|
3252
|
-
for warning in _detect_uniform_verifier(
|
|
3253
|
-
(data.get("implementationPlanning") or {}).get("planBodyVerification") or {}
|
|
3254
|
-
):
|
|
3247
|
+
for warning in validate_plan_body_section(data, report_path, failures):
|
|
3255
3248
|
print(f"validate-run: warning: {warning}", file=sys.stderr)
|
|
3256
3249
|
for warning in _detect_unmapped_incremental_fallback(data, report_path):
|
|
3257
3250
|
print(f"validate-run: warning: {warning}", file=sys.stderr)
|
|
@@ -3260,23 +3253,12 @@ def validate_final_report_data(
|
|
|
3260
3253
|
):
|
|
3261
3254
|
print(f"validate-run: warning: {warning}", file=sys.stderr)
|
|
3262
3255
|
_validate_supersession_ledger(data, failures)
|
|
3263
|
-
_validate_self_fix_grouping(data, failures)
|
|
3264
3256
|
_validate_clarification_evidence_note(data, failures)
|
|
3265
|
-
_validate_plan_body_verdict_provenance(data, report_path, failures)
|
|
3266
|
-
_validate_reverify_result_addresses_prior_dissent(data, report_path, failures)
|
|
3267
|
-
_validate_aborted_gate_has_clarification(data, failures)
|
|
3268
|
-
_validate_round_recorded_verdicts(data, failures)
|
|
3269
|
-
_validate_verdicts_match_current_subjects(data, failures)
|
|
3270
|
-
_validate_plan_item_extraction_completeness(data, failures)
|
|
3271
3257
|
_validate_variation_point_analysis(
|
|
3272
3258
|
(data.get("implementationPlanning") or {}).get("variationPointAnalysis"),
|
|
3273
3259
|
resolve_architecture(_project_root_from_report(report_path)),
|
|
3274
3260
|
failures,
|
|
3275
3261
|
)
|
|
3276
|
-
_validate_plan_item_subject_substance(data, failures)
|
|
3277
|
-
_validate_plan_body_clarification_matching(data, failures)
|
|
3278
|
-
_validate_disagree_has_fixability(data, failures)
|
|
3279
|
-
_validate_self_fix_before_clarification(data, failures)
|
|
3280
3262
|
_validate_requirement_deviations(data, failures)
|
|
3281
3263
|
_validate_requirement_coverage_covered_by(data, failures)
|
|
3282
3264
|
warnings = _validate_design_prep_contract(
|
|
@@ -5838,6 +5820,86 @@ def _validate_disagree_has_fixability(data: dict, failures: list[str]) -> None:
|
|
|
5838
5820
|
)
|
|
5839
5821
|
|
|
5840
5822
|
|
|
5823
|
+
def validate_plan_body_section(
|
|
5824
|
+
data: dict,
|
|
5825
|
+
report_path: Path,
|
|
5826
|
+
failures: list[str],
|
|
5827
|
+
) -> list[str]:
|
|
5828
|
+
"""Run every §5.5.9 plan-body check and return the advisory warnings.
|
|
5829
|
+
|
|
5830
|
+
Grouped into one callable so the round protocol can run the same checks at
|
|
5831
|
+
each round boundary that the full run validation runs at the end. Before
|
|
5832
|
+
this seam existed the only way to reach them was a finished report plus all
|
|
5833
|
+
four manifests, so a lead computing the gate by hand mid-loop had nothing to
|
|
5834
|
+
check itself against until Phase 7 — and a whole self-fix budget could be
|
|
5835
|
+
spent against a mis-scored gate.
|
|
5836
|
+
"""
|
|
5837
|
+
pbv = (data.get("implementationPlanning") or {}).get("planBodyVerification") or {}
|
|
5838
|
+
_validate_plan_body_gate_recompute(data, failures)
|
|
5839
|
+
_validate_gate_blocked_by(data, failures)
|
|
5840
|
+
_validate_participating_analysers(data, failures)
|
|
5841
|
+
_validate_self_fix_rewrite_scope(data, failures)
|
|
5842
|
+
_validate_self_fix_grouping(data, failures)
|
|
5843
|
+
_validate_plan_body_verdict_provenance(data, report_path, failures)
|
|
5844
|
+
_validate_reverify_result_addresses_prior_dissent(data, report_path, failures)
|
|
5845
|
+
_validate_aborted_gate_has_clarification(data, failures)
|
|
5846
|
+
_validate_round_recorded_verdicts(data, failures)
|
|
5847
|
+
_validate_verdicts_match_current_subjects(data, failures)
|
|
5848
|
+
_validate_plan_item_extraction_completeness(data, failures)
|
|
5849
|
+
_validate_plan_item_subject_substance(data, failures)
|
|
5850
|
+
_validate_plan_body_clarification_matching(data, failures)
|
|
5851
|
+
_validate_disagree_has_fixability(data, failures)
|
|
5852
|
+
_validate_self_fix_before_clarification(data, failures)
|
|
5853
|
+
return [*_detect_self_fix_recurrence(pbv), *_detect_uniform_verifier(pbv)]
|
|
5854
|
+
|
|
5855
|
+
|
|
5856
|
+
def plan_body_gate_summary(data: dict) -> dict | None:
|
|
5857
|
+
"""The §5.5.9 gate as the round protocol's step 5 needs it — per-item
|
|
5858
|
+
classification, the whole-gate value, and the `gateBlockedBy` causes, all
|
|
5859
|
+
recomputed from `planItems[].verdicts`. Returns ``None`` when the report
|
|
5860
|
+
carries no plan items to judge.
|
|
5861
|
+
|
|
5862
|
+
This is what a lead records instead of tallying the verdicts by hand: the
|
|
5863
|
+
single-vote-blocking kinds, the advisory-only kinds and the P-Var / P-Rb
|
|
5864
|
+
exemptions are one implementation here, not a rule to be re-derived per
|
|
5865
|
+
round from the prompt's prose.
|
|
5866
|
+
"""
|
|
5867
|
+
ip = data.get("implementationPlanning")
|
|
5868
|
+
if not isinstance(ip, dict):
|
|
5869
|
+
return None
|
|
5870
|
+
pbv = ip.get("planBodyVerification")
|
|
5871
|
+
if not isinstance(pbv, dict):
|
|
5872
|
+
return None
|
|
5873
|
+
recomputed = _recompute_plan_body_gate(pbv)
|
|
5874
|
+
if recomputed is None:
|
|
5875
|
+
return None
|
|
5876
|
+
items = [
|
|
5877
|
+
{
|
|
5878
|
+
"id": item.get("id"),
|
|
5879
|
+
"classification": "has-dissent"
|
|
5880
|
+
if _is_dissent_downgraded(item, pbv)
|
|
5881
|
+
else _classify_plan_item_gate(item),
|
|
5882
|
+
"correctnessCritical": _is_correctness_critical(item),
|
|
5883
|
+
}
|
|
5884
|
+
for item in (pbv.get("planItems") or [])
|
|
5885
|
+
if isinstance(item, dict)
|
|
5886
|
+
]
|
|
5887
|
+
coverage_blockers = _independent_coverage_blockers(ip, pbv)
|
|
5888
|
+
return {
|
|
5889
|
+
"declared": pbv.get("gateResult"),
|
|
5890
|
+
"recomputed": recomputed,
|
|
5891
|
+
"declaredBlockedBy": sorted(
|
|
5892
|
+
str(c) for c in (pbv.get("gateBlockedBy") or []) if isinstance(c, str)
|
|
5893
|
+
),
|
|
5894
|
+
"blockedBy": sorted(_gate_blocking_causes(pbv, coverage_blockers)),
|
|
5895
|
+
"coverageBlockers": coverage_blockers,
|
|
5896
|
+
"blockingItems": [
|
|
5897
|
+
item["id"] for item in items if item["classification"] == "majority-disagree"
|
|
5898
|
+
],
|
|
5899
|
+
"items": items,
|
|
5900
|
+
}
|
|
5901
|
+
|
|
5902
|
+
|
|
5841
5903
|
_COVERED_BY_ANCHOR_RE = re.compile(r"option|stage|step", re.IGNORECASE)
|
|
5842
5904
|
_COVERED_BY_STAGE_REF_RE = re.compile(r"stage\s*(\d+)", re.IGNORECASE)
|
|
5843
5905
|
_COVERED_BY_VAGUE = {"recommended option", "the recommended option", "recommended"}
|
|
@@ -6809,146 +6871,16 @@ def _nonempty_string(value) -> bool:
|
|
|
6809
6871
|
# Round-0 grouping filename: `convergence-groups-<task-type>-<seq>.json`. The
|
|
6810
6872
|
# `<task-type>-<seq>` suffix is shared verbatim with the worker-result and
|
|
6811
6873
|
# working-state basenames, so capture it as one token to reconstruct both.
|
|
6812
|
-
_CONVERGENCE_GROUPS_BASENAME_RE = re.compile(
|
|
6813
|
-
r"^convergence-groups-(?P<suffix>[a-z][a-z-]*?-\d{3})\.json$"
|
|
6814
|
-
)
|
|
6815
|
-
|
|
6816
|
-
|
|
6817
|
-
def _convergence_group_claims(document) -> list[tuple[str, str, str]] | None:
|
|
6818
|
-
"""Extract `(findingId, worker, itemId)` provenance claims from a grouping.
|
|
6819
|
-
|
|
6820
|
-
Returns ``None`` when the document does not match the convergence-groups
|
|
6821
|
-
schema shape closely enough to read its provenance fields safely — the
|
|
6822
|
-
caller refuses to judge such a file. Otherwise returns the deduplicated
|
|
6823
|
-
claims drawn from every group's ``sourceItems[]`` and ``discoveredBy`` map.
|
|
6824
|
-
"""
|
|
6825
|
-
if not isinstance(document, dict) or not isinstance(document.get("groups"), list):
|
|
6826
|
-
return None
|
|
6827
|
-
claims: list[tuple[str, str, str]] = []
|
|
6828
|
-
seen: set[tuple[str, str, str]] = set()
|
|
6829
|
-
for group in document["groups"]:
|
|
6830
|
-
if not isinstance(group, dict):
|
|
6831
|
-
return None
|
|
6832
|
-
finding_id = group.get("findingId")
|
|
6833
|
-
source_items = group.get("sourceItems")
|
|
6834
|
-
discovered_by = group.get("discoveredBy")
|
|
6835
|
-
if (
|
|
6836
|
-
not _nonempty_string(finding_id)
|
|
6837
|
-
or not isinstance(source_items, list)
|
|
6838
|
-
or not isinstance(discovered_by, dict)
|
|
6839
|
-
):
|
|
6840
|
-
return None
|
|
6841
|
-
pairs: list[tuple[object, object]] = []
|
|
6842
|
-
for item in source_items:
|
|
6843
|
-
if not isinstance(item, dict):
|
|
6844
|
-
return None
|
|
6845
|
-
pairs.append((item.get("worker"), item.get("itemId")))
|
|
6846
|
-
for worker, discovery in discovered_by.items():
|
|
6847
|
-
if not isinstance(discovery, dict):
|
|
6848
|
-
return None
|
|
6849
|
-
pairs.append((worker, discovery.get("itemId")))
|
|
6850
|
-
for worker, item_id in pairs:
|
|
6851
|
-
if not _nonempty_string(worker) or not _nonempty_string(item_id):
|
|
6852
|
-
return None
|
|
6853
|
-
claim = (finding_id, worker, item_id)
|
|
6854
|
-
if claim not in seen:
|
|
6855
|
-
seen.add(claim)
|
|
6856
|
-
claims.append(claim)
|
|
6857
|
-
return claims
|
|
6858
|
-
|
|
6859
|
-
|
|
6860
|
-
def _convergence_groups_digest_ok(state_dir, suffix: str, document: dict) -> bool:
|
|
6861
|
-
"""False only when a recorded groupsDigest exists and no longer matches.
|
|
6862
|
-
|
|
6863
|
-
``okstra convergence seed`` pins ``grouped_input_digest`` of the grouping
|
|
6864
|
-
into the working state. When that record is present and disagrees with the
|
|
6865
|
-
on-disk grouping, the file was tampered with or written out of band — not
|
|
6866
|
-
evidence of fabrication — so the caller refuses to judge it. A missing
|
|
6867
|
-
working state or missing digest means judge the file as parsed.
|
|
6868
|
-
"""
|
|
6869
|
-
work_path = state_dir / f"convergence-work-{suffix}.json"
|
|
6870
|
-
try:
|
|
6871
|
-
work = json.loads(work_path.read_text(encoding="utf-8"))
|
|
6872
|
-
except (OSError, ValueError):
|
|
6873
|
-
return True
|
|
6874
|
-
recorded = work.get("groupsDigest") if isinstance(work, dict) else None
|
|
6875
|
-
if not _nonempty_string(recorded):
|
|
6876
|
-
return True
|
|
6877
|
-
return grouped_input_digest(document) == recorded
|
|
6878
|
-
|
|
6879
|
-
|
|
6880
|
-
def _read_canonical_worker_result(worker_results_dir, worker: str, suffix: str):
|
|
6881
|
-
"""Return the `<worker>-<suffix>.md` text, or ``None`` when it is not a
|
|
6882
|
-
resolvable file directly inside ``worker-results/`` (missing, unreadable,
|
|
6883
|
-
or a worker slug that is not a single path component)."""
|
|
6884
|
-
candidate = worker_results_dir / f"{worker}-{suffix}.md"
|
|
6885
|
-
try:
|
|
6886
|
-
if candidate.resolve().parent != worker_results_dir.resolve():
|
|
6887
|
-
return None
|
|
6888
|
-
return candidate.read_text(encoding="utf-8")
|
|
6889
|
-
except (OSError, ValueError):
|
|
6890
|
-
return None
|
|
6891
|
-
|
|
6892
|
-
|
|
6893
|
-
def _id_occurs_wordbounded(text: str, item_id: str) -> bool:
|
|
6894
|
-
"""True when `item_id` occurs as a literal not flanked by another
|
|
6895
|
-
identifier char, so `F-7` matches neither `F-70` nor `xF-7`."""
|
|
6896
|
-
pattern = r"(?<![0-9A-Za-z_-])" + re.escape(item_id) + r"(?![0-9A-Za-z_-])"
|
|
6897
|
-
return re.search(pattern, text) is not None
|
|
6898
|
-
|
|
6899
|
-
|
|
6900
6874
|
def _validate_convergence_group_provenance(run_dir, failures) -> None:
|
|
6901
6875
|
"""Every source item a round-0 grouping cites must exist in the worker file.
|
|
6902
6876
|
|
|
6903
|
-
|
|
6904
|
-
|
|
6905
|
-
|
|
6906
|
-
|
|
6907
|
-
|
|
6908
|
-
anchored, in the worker's canonical result file.
|
|
6909
|
-
|
|
6910
|
-
A validator failure blocks user approval, so unjudgeable input is skipped
|
|
6911
|
-
silently: no groups file, unreadable/malformed JSON, a groups file that
|
|
6912
|
-
fails the schema shape, a missing worker result file (provider-unavailable
|
|
6913
|
-
substitution is legitimate), or a recorded groupsDigest that no longer
|
|
6914
|
-
matches.
|
|
6877
|
+
Shared with `okstra convergence seed`, which runs the same check when the
|
|
6878
|
+
grouping is handed over — this pass re-runs it over the finished run so a
|
|
6879
|
+
grouping written out of band is still caught. A validator failure blocks
|
|
6880
|
+
user approval, so unjudgeable input is skipped silently
|
|
6881
|
+
(`okstra_ctl.convergence_provenance.run_dir_provenance_errors`).
|
|
6915
6882
|
"""
|
|
6916
|
-
|
|
6917
|
-
|
|
6918
|
-
state_dir = _Path(run_dir) / "state"
|
|
6919
|
-
worker_results_dir = _Path(run_dir) / "worker-results"
|
|
6920
|
-
if not state_dir.is_dir() or not worker_results_dir.is_dir():
|
|
6921
|
-
return
|
|
6922
|
-
for groups_path in sorted(state_dir.glob("convergence-groups-*.json")):
|
|
6923
|
-
match = _CONVERGENCE_GROUPS_BASENAME_RE.match(groups_path.name)
|
|
6924
|
-
if match is None:
|
|
6925
|
-
continue
|
|
6926
|
-
suffix = match.group("suffix")
|
|
6927
|
-
try:
|
|
6928
|
-
document = json.loads(groups_path.read_text(encoding="utf-8"))
|
|
6929
|
-
except (OSError, ValueError):
|
|
6930
|
-
continue
|
|
6931
|
-
claims = _convergence_group_claims(document)
|
|
6932
|
-
if claims is None:
|
|
6933
|
-
continue
|
|
6934
|
-
if not _convergence_groups_digest_ok(state_dir, suffix, document):
|
|
6935
|
-
continue
|
|
6936
|
-
contents: dict[str, str | None] = {}
|
|
6937
|
-
for finding_id, worker, item_id in claims:
|
|
6938
|
-
if worker not in contents:
|
|
6939
|
-
contents[worker] = _read_canonical_worker_result(
|
|
6940
|
-
worker_results_dir, worker, suffix
|
|
6941
|
-
)
|
|
6942
|
-
text = contents[worker]
|
|
6943
|
-
if text is None or _id_occurs_wordbounded(text, item_id):
|
|
6944
|
-
continue
|
|
6945
|
-
failures.append(
|
|
6946
|
-
f"convergence groups `{groups_path.name}` group {finding_id} "
|
|
6947
|
-
f"cites source item `{worker}:{item_id}`, but that ID does not "
|
|
6948
|
-
f"occur in the worker's result file `{worker}-{suffix}.md` — a "
|
|
6949
|
-
"grouping may not invent a provenance link to an item the "
|
|
6950
|
-
"worker never reported."
|
|
6951
|
-
)
|
|
6883
|
+
failures.extend(run_dir_provenance_errors(Path(run_dir)))
|
|
6952
6884
|
|
|
6953
6885
|
|
|
6954
6886
|
# Reverify-prompt basename: `<role-slug>-reverify-r<N>-<task-type>-<seq>.md`.
|
|
@@ -7371,13 +7303,92 @@ def _rerender_report_views_after_autofix(report_path: Path) -> str:
|
|
|
7371
7303
|
return "report-views re-rendered"
|
|
7372
7304
|
|
|
7373
7305
|
|
|
7306
|
+
SECTION_FULL = "full"
|
|
7307
|
+
SECTION_PLAN_BODY = "plan-body"
|
|
7308
|
+
|
|
7309
|
+
# `full` needs the whole run assembled; the round-boundary section reads only
|
|
7310
|
+
# the report and its data.json sibling, which is what exists mid-loop.
|
|
7311
|
+
_FULL_ONLY_REQUIRED_FLAGS = ("team_state", "run_manifest", "task_manifest")
|
|
7312
|
+
|
|
7313
|
+
|
|
7314
|
+
def run_plan_body_section(report_path: Path) -> int:
|
|
7315
|
+
"""`--section plan-body` — the §5.5.9 checks a self-fix round can run on
|
|
7316
|
+
its own, plus the recomputed gate the lead records for that round.
|
|
7317
|
+
|
|
7318
|
+
Emits one JSON object on stdout so the caller reads the gate instead of
|
|
7319
|
+
re-deriving it: exit 0 when the section is clean, 2 when it is not.
|
|
7320
|
+
"""
|
|
7321
|
+
data_path = _data_path_for(report_path)
|
|
7322
|
+
if not data_path.is_file():
|
|
7323
|
+
print(
|
|
7324
|
+
f"validate-run: --section plan-body needs {data_path}, which does "
|
|
7325
|
+
"not exist yet — render the round's data.json first.",
|
|
7326
|
+
file=sys.stderr,
|
|
7327
|
+
)
|
|
7328
|
+
return 2
|
|
7329
|
+
try:
|
|
7330
|
+
data = json.loads(data_path.read_text(encoding="utf-8"))
|
|
7331
|
+
except ValueError as exc:
|
|
7332
|
+
print(
|
|
7333
|
+
f"validate-run: {data_path} is not parseable JSON ({exc}). "
|
|
7334
|
+
"Restore it from the sibling `.data.json.last-valid` snapshot the "
|
|
7335
|
+
"renderer writes after each successful render.",
|
|
7336
|
+
file=sys.stderr,
|
|
7337
|
+
)
|
|
7338
|
+
return 2
|
|
7339
|
+
failures = _data_schema_failures(data)
|
|
7340
|
+
warnings = validate_plan_body_section(data, report_path, failures)
|
|
7341
|
+
_validate_plan_body_state_file(data, report_path, failures)
|
|
7342
|
+
payload = {
|
|
7343
|
+
"ok": not failures,
|
|
7344
|
+
"section": SECTION_PLAN_BODY,
|
|
7345
|
+
"gate": plan_body_gate_summary(data),
|
|
7346
|
+
"failures": failures,
|
|
7347
|
+
"warnings": warnings,
|
|
7348
|
+
}
|
|
7349
|
+
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
|
7350
|
+
return 0 if not failures else 2
|
|
7351
|
+
|
|
7352
|
+
|
|
7353
|
+
def _data_schema_failures(data: dict) -> list[str]:
|
|
7354
|
+
"""Schema errors in the round's data.json, as failures.
|
|
7355
|
+
|
|
7356
|
+
The gate block is hand-edited between rounds, so a structure written in the
|
|
7357
|
+
lead's own internal shape is schema-invalid while still being readable
|
|
7358
|
+
JSON. Nothing caught that until the renderer ran, which is one round too
|
|
7359
|
+
late — by then the previous verdicts have already been overwritten.
|
|
7360
|
+
"""
|
|
7361
|
+
if schema_validate is None or load_schema_for_data is None:
|
|
7362
|
+
return []
|
|
7363
|
+
try:
|
|
7364
|
+
schema = load_schema_for_data(data)
|
|
7365
|
+
except SchemaError as exc:
|
|
7366
|
+
return [f"final-report data.json: schema not locatable ({exc})"]
|
|
7367
|
+
return [
|
|
7368
|
+
f"final-report data.json: schema violation — {error}"
|
|
7369
|
+
for error in schema_validate(data, schema)
|
|
7370
|
+
]
|
|
7371
|
+
|
|
7372
|
+
|
|
7374
7373
|
def main() -> int:
|
|
7375
7374
|
parser = argparse.ArgumentParser(
|
|
7376
7375
|
description="Validate okstra run contract artifacts."
|
|
7377
7376
|
)
|
|
7377
|
+
parser.add_argument(
|
|
7378
|
+
"--section",
|
|
7379
|
+
choices=(SECTION_FULL, SECTION_PLAN_BODY),
|
|
7380
|
+
default=SECTION_FULL,
|
|
7381
|
+
help=(
|
|
7382
|
+
"Which contract surface to validate. `full` (default) validates the "
|
|
7383
|
+
"finished run. `plan-body` validates only the §5.5.9 plan-body "
|
|
7384
|
+
"verification surface from the report and its data.json, and prints "
|
|
7385
|
+
"the recomputed gate — the round-boundary check a self-fix loop runs "
|
|
7386
|
+
"before it records a round."
|
|
7387
|
+
),
|
|
7388
|
+
)
|
|
7378
7389
|
parser.add_argument(
|
|
7379
7390
|
"--team-state",
|
|
7380
|
-
required=
|
|
7391
|
+
required=False,
|
|
7381
7392
|
help="Project-relative or absolute path to the team state JSON.",
|
|
7382
7393
|
)
|
|
7383
7394
|
parser.add_argument(
|
|
@@ -7387,12 +7398,12 @@ def main() -> int:
|
|
|
7387
7398
|
)
|
|
7388
7399
|
parser.add_argument(
|
|
7389
7400
|
"--run-manifest",
|
|
7390
|
-
required=
|
|
7401
|
+
required=False,
|
|
7391
7402
|
help="Project-relative or absolute path to the run manifest JSON.",
|
|
7392
7403
|
)
|
|
7393
7404
|
parser.add_argument(
|
|
7394
7405
|
"--task-manifest",
|
|
7395
|
-
required=
|
|
7406
|
+
required=False,
|
|
7396
7407
|
help="Project-relative or absolute path to the task manifest JSON.",
|
|
7397
7408
|
)
|
|
7398
7409
|
parser.add_argument(
|
|
@@ -7409,6 +7420,19 @@ def main() -> int:
|
|
|
7409
7420
|
)
|
|
7410
7421
|
args = parser.parse_args()
|
|
7411
7422
|
|
|
7423
|
+
if args.section == SECTION_PLAN_BODY:
|
|
7424
|
+
return run_plan_body_section(Path(args.report).resolve())
|
|
7425
|
+
|
|
7426
|
+
missing = [
|
|
7427
|
+
f"--{flag.replace('_', '-')}"
|
|
7428
|
+
for flag in _FULL_ONLY_REQUIRED_FLAGS
|
|
7429
|
+
if not getattr(args, flag)
|
|
7430
|
+
]
|
|
7431
|
+
if missing:
|
|
7432
|
+
parser.error(
|
|
7433
|
+
f"--section {SECTION_FULL} requires {', '.join(missing)}"
|
|
7434
|
+
)
|
|
7435
|
+
|
|
7412
7436
|
run_manifest_path = Path(args.run_manifest).resolve()
|
|
7413
7437
|
run_manifest = load_json(run_manifest_path)
|
|
7414
7438
|
task_manifest_path = Path(args.task_manifest).resolve()
|
|
@@ -385,6 +385,26 @@ def _convergence_rounds_ran(run_dir: Path, suffix: str | None) -> bool:
|
|
|
385
385
|
return isinstance(doc, dict) and (doc.get("totalRounds") or 0) >= 1
|
|
386
386
|
|
|
387
387
|
|
|
388
|
+
def _plan_body_rounds_ran(run_dir: Path, suffix: str | None) -> int:
|
|
389
|
+
"""이 run 의 plan-body 검증이 실제로 돈 라운드 수.
|
|
390
|
+
|
|
391
|
+
각 라운드는 새 워커 배치를 띄우므로 라운드마다 배치 경계가 하나씩 생긴다.
|
|
392
|
+
Phase 6 이후에 도는 구간이라 기존 배치-정리 강제(수렴 라운드 1 직전 /
|
|
393
|
+
report-writer 디스패치 직전)의 바깥이었고, 그래서 self-fix 를 여러 라운드
|
|
394
|
+
돈 run 은 매 라운드의 완료 워커가 그대로 남았다."""
|
|
395
|
+
if not suffix:
|
|
396
|
+
return 0
|
|
397
|
+
path = run_dir / "state" / f"plan-body-verification-{suffix}.json"
|
|
398
|
+
try:
|
|
399
|
+
doc = json.loads(path.read_text())
|
|
400
|
+
except (OSError, json.JSONDecodeError):
|
|
401
|
+
return 0
|
|
402
|
+
if not isinstance(doc, dict):
|
|
403
|
+
return 0
|
|
404
|
+
rounds = doc.get("roundCount")
|
|
405
|
+
return rounds if isinstance(rounds, int) and rounds > 0 else 0
|
|
406
|
+
|
|
407
|
+
|
|
388
408
|
def _phase_mentions_worker(lines: list[tuple[str, str]], needles: list[str]) -> bool:
|
|
389
409
|
return any(needle in _norm(line) for _ts, line in lines for needle in needles)
|
|
390
410
|
|
|
@@ -449,6 +469,40 @@ def _check_batch_cleanup_checkpoints(
|
|
|
449
469
|
)
|
|
450
470
|
|
|
451
471
|
|
|
472
|
+
def _check_plan_verify_cleanup_checkpoints(
|
|
473
|
+
by_phase: dict[str, list[tuple[str, str]]],
|
|
474
|
+
plan_body_rounds: int,
|
|
475
|
+
errors: list[str],
|
|
476
|
+
) -> None:
|
|
477
|
+
"""plan-body 라운드도 배치 경계다 — 라운드마다 새 검증 워커를 띄운다.
|
|
478
|
+
|
|
479
|
+
Phase 6 뒤에 도는 구간이라 위 두 지점의 바깥이었고, self-fix 를 다섯 라운드
|
|
480
|
+
돈 run 은 라운드마다 완료 워커를 남겨 유휴 세션이 쌓였다. 라운드 2 이상은
|
|
481
|
+
직전 라운드의 워커가 반드시 존재하므로, 각 라운드 직전에 정리가 있어야
|
|
482
|
+
한다(라운드 1의 앞 경계는 report-writer 디스패치 정리가 이미 덮는다)."""
|
|
483
|
+
if plan_body_rounds < 2:
|
|
484
|
+
return
|
|
485
|
+
detail = "prompts/lead/plan-body-verification.md §\"Round protocol\" step 7"
|
|
486
|
+
round_ts = sorted(ts for ts, _line in by_phase.get("phase-5.5.9-plan-verify", []))
|
|
487
|
+
if not round_ts:
|
|
488
|
+
errors.append(
|
|
489
|
+
"PROGRESS checkpoint missing: `phase-5.5.9-plan-verify` — the state "
|
|
490
|
+
f"file records {plan_body_rounds} plan-body rounds, each of which "
|
|
491
|
+
f"dispatches a worker batch and must announce itself ({detail})."
|
|
492
|
+
)
|
|
493
|
+
return
|
|
494
|
+
cleanup_ts = sorted(ts for ts, _line in by_phase.get("phase-batch-cleanup", []))
|
|
495
|
+
for index, dispatched_at in enumerate(round_ts[1:], start=1):
|
|
496
|
+
previous = round_ts[index - 1]
|
|
497
|
+
if not any(previous <= ts <= dispatched_at for ts in cleanup_ts):
|
|
498
|
+
errors.append(
|
|
499
|
+
"PROGRESS checkpoint missing: no `phase-batch-cleanup` line "
|
|
500
|
+
f"between plan-body rounds ({previous} → {dispatched_at}) — the "
|
|
501
|
+
f"previous round's completed verifiers must be cleared before "
|
|
502
|
+
f"the next round dispatches ({detail})."
|
|
503
|
+
)
|
|
504
|
+
|
|
505
|
+
|
|
452
506
|
def _check_progress_checkpoints(
|
|
453
507
|
evidence: _LeadEvidence,
|
|
454
508
|
team_state: dict,
|
|
@@ -513,6 +567,9 @@ def _check_progress_checkpoints(
|
|
|
513
567
|
_check_batch_cleanup_checkpoints(
|
|
514
568
|
by_phase, convergence_ran, report_writer_dispatched, errors
|
|
515
569
|
)
|
|
570
|
+
_check_plan_verify_cleanup_checkpoints(
|
|
571
|
+
by_phase, _plan_body_rounds_ran(run_dir, suffix), errors
|
|
572
|
+
)
|
|
516
573
|
|
|
517
574
|
|
|
518
575
|
def _parse_iso(ts: str) -> datetime | None:
|
package/src/cli-registry.mjs
CHANGED
|
@@ -80,6 +80,13 @@ export const COMMAND_REGISTRY = [
|
|
|
80
80
|
category: "admin",
|
|
81
81
|
summary: ["Extract and validate deterministic plan-body items"],
|
|
82
82
|
},
|
|
83
|
+
{
|
|
84
|
+
name: "plan-verify",
|
|
85
|
+
module: "./commands/execute/plan-verify.mjs",
|
|
86
|
+
export: "run",
|
|
87
|
+
category: "admin",
|
|
88
|
+
summary: ["Score the §5.5.9 plan-body gate for one self-fix round"],
|
|
89
|
+
},
|
|
83
90
|
{
|
|
84
91
|
name: "git-reconcile",
|
|
85
92
|
module: "./commands/execute/git-reconcile.mjs",
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { runInstalledValidator } from "../../lib/python-helper.mjs";
|
|
2
|
+
|
|
3
|
+
const USAGE = `okstra plan-verify — score the §5.5.9 plan-body gate for one self-fix round
|
|
4
|
+
|
|
5
|
+
Usage:
|
|
6
|
+
okstra plan-verify --report <final-report-implementation-planning-<seq>.md>
|
|
7
|
+
|
|
8
|
+
Recomputes the gate from the report data.json's \`planItems[].verdicts\` and runs
|
|
9
|
+
every plan-body contract check that a round can be judged on its own — verdict
|
|
10
|
+
provenance, fixability, subject substance, self-fix grouping, round recording,
|
|
11
|
+
clarification matching, and the state-file round history.
|
|
12
|
+
|
|
13
|
+
Emits one JSON object:
|
|
14
|
+
|
|
15
|
+
gate.recomputed the gate value this round's votes actually support
|
|
16
|
+
gate.blockedBy the \`gateBlockedBy\` causes, recomputed
|
|
17
|
+
gate.blockingItems the plan items classified \`majority-disagree\`
|
|
18
|
+
gate.items[] per-item classification
|
|
19
|
+
failures[] contract violations — exit code 2 when non-empty
|
|
20
|
+
warnings[] advisory signals (self-fix recurrence, uniform verifier)
|
|
21
|
+
|
|
22
|
+
Run this at every round boundary and record what it returns. The single-vote
|
|
23
|
+
blocking kinds, the advisory-only kinds and the P-Var / P-Rb exemptions live in
|
|
24
|
+
this one implementation — a lead that tallies the verdicts by hand instead is
|
|
25
|
+
re-deriving them per round, and a round scored wrong spends its self-fix budget
|
|
26
|
+
on the wrong items.
|
|
27
|
+
`;
|
|
28
|
+
|
|
29
|
+
export async function run(args) {
|
|
30
|
+
if (args.length === 0 || args.includes("--help") || args.includes("-h")) {
|
|
31
|
+
process.stdout.write(USAGE);
|
|
32
|
+
return args.length === 0 ? 2 : 0;
|
|
33
|
+
}
|
|
34
|
+
if (args.includes("--section")) {
|
|
35
|
+
process.stderr.write(
|
|
36
|
+
"error: --section is set by 'okstra plan-verify' itself — remove it from your args\n",
|
|
37
|
+
);
|
|
38
|
+
return 2;
|
|
39
|
+
}
|
|
40
|
+
return await runInstalledValidator({
|
|
41
|
+
validatorName: "validate-run.py",
|
|
42
|
+
args: ["--section", "plan-body", ...args],
|
|
43
|
+
});
|
|
44
|
+
}
|
|
@@ -15,6 +15,30 @@ export function resolveInstalledScript(paths, scriptName) {
|
|
|
15
15
|
return existsSync(dev) ? dev : null;
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
export function resolveInstalledValidator(paths, validatorName) {
|
|
19
|
+
// `okstra install` lands the validators tree at ~/.okstra/lib/validators;
|
|
20
|
+
// a checkout that has not been installed still has the in-repo source.
|
|
21
|
+
const installed = join(paths.home, "lib", "validators", validatorName);
|
|
22
|
+
if (existsSync(installed)) return installed;
|
|
23
|
+
const repoRoot = fileURLToPath(new URL("../..", import.meta.url));
|
|
24
|
+
const dev = resolvePath(repoRoot, "validators", validatorName);
|
|
25
|
+
return existsSync(dev) ? dev : null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function spawnPythonEntry(entry, args, paths) {
|
|
29
|
+
return await new Promise((resolve) => {
|
|
30
|
+
const child = spawn("python3", [entry, ...args], {
|
|
31
|
+
stdio: "inherit",
|
|
32
|
+
env: { ...process.env, PYTHONPATH: buildPythonpath(paths) },
|
|
33
|
+
});
|
|
34
|
+
child.on("error", (err) => {
|
|
35
|
+
process.stderr.write(`error: failed to spawn python3: ${err.message}\n`);
|
|
36
|
+
resolve(1);
|
|
37
|
+
});
|
|
38
|
+
child.on("close", (code) => resolve(typeof code === "number" ? code : 1));
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
18
42
|
// Thin spawn shim shared by every `okstra <cmd>` subcommand that fronts a
|
|
19
43
|
// `scripts/okstra-*.py` entry point. Centralizing it keeps PYTHONPATH wiring
|
|
20
44
|
// and installed/dev resolution in one place so skills call `okstra <cmd>`
|
|
@@ -40,17 +64,19 @@ export async function runInstalledScript({ scriptName, args, usage, emptyArgsCod
|
|
|
40
64
|
);
|
|
41
65
|
return 1;
|
|
42
66
|
}
|
|
43
|
-
return await
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
67
|
+
return await spawnPythonEntry(entry, args, paths);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export async function runInstalledValidator({ validatorName, args }) {
|
|
71
|
+
const paths = await resolvePaths();
|
|
72
|
+
const entry = resolveInstalledValidator(paths, validatorName);
|
|
73
|
+
if (!entry) {
|
|
74
|
+
process.stderr.write(
|
|
75
|
+
`error: ${validatorName} not found — run 'okstra install' (or 'okstra ensure-installed') first\n`,
|
|
76
|
+
);
|
|
77
|
+
return 1;
|
|
78
|
+
}
|
|
79
|
+
return await spawnPythonEntry(entry, args, paths);
|
|
54
80
|
}
|
|
55
81
|
|
|
56
82
|
export async function runPythonSnippet({ script, args = [], extraEnv = {} }) {
|