okstra 0.121.1 → 0.123.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 +4 -2
- package/docs/architecture/storage-model.md +14 -0
- package/docs/architecture.md +38 -8
- package/docs/cli.md +45 -5
- package/docs/for-ai/README.md +2 -2
- package/docs/for-ai/skills/okstra-rollup.md +1 -1
- package/docs/for-ai/skills/{okstra-schedule.md → okstra-schedule-gen.md} +3 -3
- package/docs/project-structure-overview.md +4 -3
- package/docs/task-process/implementation-planning.md +33 -9
- package/docs/task-process/implementation.md +21 -2
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/agents/workers/claude-worker.md +1 -1
- package/runtime/bin/lib/okstra/usage.sh +3 -3
- package/runtime/prompts/launch.template.md +9 -6
- package/runtime/prompts/lead/adapters/claude-code.md +104 -0
- package/runtime/prompts/lead/adapters/codex.md +45 -0
- package/runtime/prompts/lead/adapters/external.md +46 -0
- package/runtime/prompts/lead/context-loader.md +5 -5
- package/runtime/prompts/lead/convergence.md +11 -28
- package/runtime/prompts/lead/okstra-lead-contract.md +44 -94
- package/runtime/prompts/lead/plan-body-verification.md +50 -5
- package/runtime/prompts/lead/report-writer.md +59 -54
- package/runtime/prompts/lead/team-contract.md +33 -106
- package/runtime/prompts/profiles/_common-contract.md +3 -3
- package/runtime/prompts/profiles/_implementation-deliverable.md +3 -1
- package/runtime/prompts/profiles/_implementation-executor.md +6 -1
- package/runtime/prompts/profiles/_implementation-verifier.md +2 -2
- package/runtime/prompts/profiles/final-verification.md +2 -0
- package/runtime/prompts/profiles/implementation-planning.md +14 -4
- package/runtime/prompts/profiles/requirements-discovery.md +1 -1
- package/runtime/prompts/wizard/prompts.ko.json +44 -0
- package/runtime/python/okstra_ctl/codex_dispatch.py +23 -1
- package/runtime/python/okstra_ctl/design_prep.py +1462 -0
- package/runtime/python/okstra_ctl/design_surfaces.py +243 -0
- package/runtime/python/okstra_ctl/final_report_schema.py +33 -1
- package/runtime/python/okstra_ctl/implementation_stage.py +35 -0
- package/runtime/python/okstra_ctl/incremental_carry.py +294 -21
- package/runtime/python/okstra_ctl/incremental_scope.py +51 -5
- package/runtime/python/okstra_ctl/lead_runtime.py +4 -0
- package/runtime/python/okstra_ctl/material.py +1 -1
- package/runtime/python/okstra_ctl/model_discovery.py +98 -0
- package/runtime/python/okstra_ctl/models.py +8 -3
- package/runtime/python/okstra_ctl/render.py +81 -194
- package/runtime/python/okstra_ctl/report_views.py +22 -7
- package/runtime/python/okstra_ctl/run.py +53 -5
- package/runtime/python/okstra_ctl/team_reconcile.py +3 -1
- package/runtime/python/okstra_ctl/user_response.py +67 -2
- package/runtime/python/okstra_ctl/wizard.py +283 -3
- package/runtime/python/okstra_token_usage/report.py +11 -0
- package/runtime/schemas/final-report-v1.0.schema.json +339 -0
- package/runtime/skills/okstra-inspect/SKILL.md +2 -2
- package/runtime/skills/okstra-rollup/SKILL.md +1 -1
- package/runtime/skills/{okstra-schedule → okstra-schedule-gen}/SKILL.md +62 -20
- package/runtime/skills/okstra-user-response/SKILL.md +8 -6
- package/runtime/templates/reports/final-report.template.md +71 -4
- package/runtime/templates/reports/i18n/en.json +32 -1
- package/runtime/templates/reports/i18n/ko.json +32 -1
- package/runtime/validators/validate-run.py +515 -5
- package/runtime/validators/validate-schedule.py +11 -7
- package/runtime/validators/validate_session_conformance.py +58 -33
- package/src/cli-registry.mjs +14 -0
- package/src/commands/inspect/design-prep.mjs +23 -0
- package/src/commands/inspect/stage-map.mjs +100 -0
- package/src/commands/lifecycle/install.mjs +3 -0
- package/src/commands/lifecycle/uninstall.mjs +8 -23
- package/src/lib/skill-catalog.mjs +10 -1
|
@@ -45,6 +45,18 @@ from okstra_ctl.md_table import ( # noqa: E402
|
|
|
45
45
|
split_pipe_row as _split_pipe_row,
|
|
46
46
|
)
|
|
47
47
|
from okstra_ctl.final_report_paths import final_report_data_path as _data_path_for # noqa: E402
|
|
48
|
+
from okstra_ctl.design_prep import ( # noqa: E402
|
|
49
|
+
DesignPrepError,
|
|
50
|
+
_planning_seq as _design_prep_planning_seq,
|
|
51
|
+
_render_request as _render_design_prep_request,
|
|
52
|
+
_report_language as _design_prep_report_language,
|
|
53
|
+
)
|
|
54
|
+
from okstra_ctl.design_surfaces import ( # noqa: E402
|
|
55
|
+
RULES as DESIGN_SURFACE_RULES,
|
|
56
|
+
DesignSurfaceError,
|
|
57
|
+
detect_design_surfaces,
|
|
58
|
+
expected_prep_plan_item_id,
|
|
59
|
+
)
|
|
48
60
|
|
|
49
61
|
TERMINAL_STATUSES = {"completed", "timeout", "error", "not-run"}
|
|
50
62
|
ATTEMPTED_STATUSES = {"completed", "timeout", "error"}
|
|
@@ -1530,7 +1542,12 @@ def _load_final_report_data(report_path: Path) -> dict:
|
|
|
1530
1542
|
return {}
|
|
1531
1543
|
|
|
1532
1544
|
|
|
1533
|
-
def validate_final_report_data(
|
|
1545
|
+
def validate_final_report_data(
|
|
1546
|
+
report_path: Path,
|
|
1547
|
+
failures: list[str],
|
|
1548
|
+
*,
|
|
1549
|
+
report_contracts: set[str] | None = None,
|
|
1550
|
+
) -> None:
|
|
1534
1551
|
"""Validate the final-report data.json against the v1.0 schema.
|
|
1535
1552
|
|
|
1536
1553
|
The data.json is the source-of-truth that the renderer reads to
|
|
@@ -1584,13 +1601,29 @@ def validate_final_report_data(report_path: Path, failures: list[str]) -> None:
|
|
|
1584
1601
|
if task_type == "final-verification":
|
|
1585
1602
|
_validate_final_verification_consistency(data, failures)
|
|
1586
1603
|
elif task_type == "implementation-planning":
|
|
1604
|
+
active_report_contracts = report_contracts or set()
|
|
1605
|
+
enforce_design_prep = _DESIGN_PREP_CONTRACT in active_report_contracts
|
|
1587
1606
|
_validate_implementation_planning_cross_project(data, failures)
|
|
1588
1607
|
_validate_implementation_planning_decision_drafts(data, failures)
|
|
1589
1608
|
_validate_plan_body_gate_recompute(data, failures)
|
|
1590
|
-
_validate_plan_item_extraction_completeness(
|
|
1609
|
+
_validate_plan_item_extraction_completeness(
|
|
1610
|
+
data,
|
|
1611
|
+
failures,
|
|
1612
|
+
enforce_design_prep=enforce_design_prep,
|
|
1613
|
+
)
|
|
1591
1614
|
_validate_plan_item_subject_substance(data, failures)
|
|
1592
1615
|
_validate_plan_body_clarification_matching(data, failures)
|
|
1616
|
+
_validate_disagree_has_fixability(data, failures)
|
|
1617
|
+
_validate_self_fix_before_clarification(data, failures)
|
|
1593
1618
|
_validate_requirement_coverage_covered_by(data, failures)
|
|
1619
|
+
warnings = _validate_design_prep_contract(
|
|
1620
|
+
data,
|
|
1621
|
+
report_path,
|
|
1622
|
+
active_report_contracts,
|
|
1623
|
+
failures,
|
|
1624
|
+
)
|
|
1625
|
+
for warning in warnings:
|
|
1626
|
+
print(f"validate-run: warning: {warning}", file=sys.stderr)
|
|
1594
1627
|
|
|
1595
1628
|
|
|
1596
1629
|
# path:line (foo.service.ts:268), an in-report ID (C-001 / E-006 / P-004), a
|
|
@@ -1865,7 +1898,12 @@ _PLAN_ITEM_SOURCES = (
|
|
|
1865
1898
|
)
|
|
1866
1899
|
|
|
1867
1900
|
|
|
1868
|
-
def _validate_plan_item_extraction_completeness(
|
|
1901
|
+
def _validate_plan_item_extraction_completeness(
|
|
1902
|
+
data: dict,
|
|
1903
|
+
failures: list[str],
|
|
1904
|
+
*,
|
|
1905
|
+
enforce_design_prep: bool = False,
|
|
1906
|
+
) -> None:
|
|
1869
1907
|
"""H2 — when a verification round ran, every plan-body deliverable item must
|
|
1870
1908
|
appear as a matching `P-*` verdict item. Closes the omission hole where the
|
|
1871
1909
|
lead silently drops a weak item (e.g. an out-of-order rollback) from
|
|
@@ -1901,6 +1939,384 @@ def _validate_plan_item_extraction_completeness(data: dict, failures: list[str])
|
|
|
1901
1939
|
"extracted for cross-verification — a dropped item escapes the "
|
|
1902
1940
|
"gate (plan-body-verification.md Plan-item extraction)."
|
|
1903
1941
|
)
|
|
1942
|
+
if enforce_design_prep:
|
|
1943
|
+
_validate_expected_prep_plan_items(ip, extracted_ids, failures)
|
|
1944
|
+
|
|
1945
|
+
|
|
1946
|
+
def _validate_expected_prep_plan_items(
|
|
1947
|
+
planning: dict,
|
|
1948
|
+
extracted_ids: list[str],
|
|
1949
|
+
failures: list[str],
|
|
1950
|
+
) -> None:
|
|
1951
|
+
try:
|
|
1952
|
+
expected_prep_ids = {}
|
|
1953
|
+
for trigger in detect_design_surfaces(planning):
|
|
1954
|
+
item_id = expected_prep_plan_item_id(trigger.stage, trigger.kind)
|
|
1955
|
+
expected_prep_ids[item_id.upper()] = item_id
|
|
1956
|
+
except DesignSurfaceError as exc:
|
|
1957
|
+
failures.append(
|
|
1958
|
+
f"final-report data.json: design-surface detection failed: {exc}"
|
|
1959
|
+
)
|
|
1960
|
+
return
|
|
1961
|
+
except Exception as exc: # noqa: BLE001
|
|
1962
|
+
failures.append(
|
|
1963
|
+
"final-report data.json: design-surface detection failed on "
|
|
1964
|
+
f"malformed input: {exc}"
|
|
1965
|
+
)
|
|
1966
|
+
return
|
|
1967
|
+
actual_prep_ids = [
|
|
1968
|
+
item_id for item_id in extracted_ids if item_id.startswith("P-PREP-")
|
|
1969
|
+
]
|
|
1970
|
+
missing_prep_ids = [
|
|
1971
|
+
expected_prep_ids[item_id]
|
|
1972
|
+
for item_id in sorted(set(expected_prep_ids) - set(actual_prep_ids))
|
|
1973
|
+
]
|
|
1974
|
+
if missing_prep_ids:
|
|
1975
|
+
failures.append(
|
|
1976
|
+
"final-report data.json: planBodyVerification.planItems is missing "
|
|
1977
|
+
"detector-required design-prep verdict item(s): "
|
|
1978
|
+
+ ", ".join(missing_prep_ids)
|
|
1979
|
+
)
|
|
1980
|
+
unexpected_prep_ids = sorted(set(actual_prep_ids) - set(expected_prep_ids))
|
|
1981
|
+
if unexpected_prep_ids:
|
|
1982
|
+
failures.append(
|
|
1983
|
+
"final-report data.json: planBodyVerification.planItems contains "
|
|
1984
|
+
"unexpected design-prep verdict item(s) not produced by the "
|
|
1985
|
+
"detector: "
|
|
1986
|
+
+ ", ".join(unexpected_prep_ids)
|
|
1987
|
+
)
|
|
1988
|
+
duplicate_prep_ids = sorted(
|
|
1989
|
+
item_id
|
|
1990
|
+
for item_id in set(actual_prep_ids)
|
|
1991
|
+
if actual_prep_ids.count(item_id) > 1
|
|
1992
|
+
)
|
|
1993
|
+
if duplicate_prep_ids:
|
|
1994
|
+
failures.append(
|
|
1995
|
+
"final-report data.json: planBodyVerification.planItems contains "
|
|
1996
|
+
"duplicate detector-required design-prep verdict item(s): "
|
|
1997
|
+
+ ", ".join(duplicate_prep_ids)
|
|
1998
|
+
)
|
|
1999
|
+
|
|
2000
|
+
|
|
2001
|
+
_DESIGN_PREP_CONTRACT = "implementation-design-prep-v1"
|
|
2002
|
+
_DESIGN_PREP_REQUEST_STATUSES = {"provisional", "blocked"}
|
|
2003
|
+
_DESIGN_PREP_TERMINAL_STATUSES = {"ready", "not-applicable"}
|
|
2004
|
+
|
|
2005
|
+
|
|
2006
|
+
def _normalize_report_contracts(raw_contracts: object) -> set[str]:
|
|
2007
|
+
if isinstance(raw_contracts, str):
|
|
2008
|
+
candidates = [raw_contracts]
|
|
2009
|
+
elif isinstance(raw_contracts, (list, tuple, set, frozenset)):
|
|
2010
|
+
candidates = raw_contracts
|
|
2011
|
+
else:
|
|
2012
|
+
return set()
|
|
2013
|
+
return {
|
|
2014
|
+
value.strip()
|
|
2015
|
+
for value in candidates
|
|
2016
|
+
if isinstance(value, str) and value.strip()
|
|
2017
|
+
}
|
|
2018
|
+
|
|
2019
|
+
|
|
2020
|
+
def _design_prep_rows(
|
|
2021
|
+
planning: dict,
|
|
2022
|
+
failures: list[str],
|
|
2023
|
+
) -> tuple[list[dict], dict[str, dict]] | None:
|
|
2024
|
+
preparation = planning.get("designPreparation")
|
|
2025
|
+
if not isinstance(preparation, dict):
|
|
2026
|
+
failures.append(
|
|
2027
|
+
"final-report data.json: implementationPlanning.designPreparation "
|
|
2028
|
+
"is malformed; expected an object"
|
|
2029
|
+
)
|
|
2030
|
+
return None
|
|
2031
|
+
raw_items = preparation.get("items")
|
|
2032
|
+
if not isinstance(raw_items, list) or any(
|
|
2033
|
+
not isinstance(item, dict) for item in raw_items
|
|
2034
|
+
):
|
|
2035
|
+
failures.append(
|
|
2036
|
+
"final-report data.json: implementationPlanning.designPreparation.items "
|
|
2037
|
+
"is malformed; expected an array of objects"
|
|
2038
|
+
)
|
|
2039
|
+
return None
|
|
2040
|
+
items = list(raw_items)
|
|
2041
|
+
items_by_id: dict[str, dict] = {}
|
|
2042
|
+
for item in items:
|
|
2043
|
+
item_id = item.get("id")
|
|
2044
|
+
if not isinstance(item_id, str) or not item_id:
|
|
2045
|
+
failures.append(
|
|
2046
|
+
"final-report data.json: designPreparation item has malformed id"
|
|
2047
|
+
)
|
|
2048
|
+
continue
|
|
2049
|
+
if item_id in items_by_id:
|
|
2050
|
+
failures.append(
|
|
2051
|
+
f"final-report data.json: duplicate designPreparation item {item_id}"
|
|
2052
|
+
)
|
|
2053
|
+
items_by_id[item_id] = item
|
|
2054
|
+
return items, items_by_id
|
|
2055
|
+
|
|
2056
|
+
|
|
2057
|
+
def _design_surface_coverage(
|
|
2058
|
+
planning: dict,
|
|
2059
|
+
failures: list[str],
|
|
2060
|
+
) -> dict[tuple[int, str], list[dict]] | None:
|
|
2061
|
+
raw_stages = planning.get("stages")
|
|
2062
|
+
if not isinstance(raw_stages, list):
|
|
2063
|
+
failures.append(
|
|
2064
|
+
"final-report data.json: implementationPlanning.stages is malformed"
|
|
2065
|
+
)
|
|
2066
|
+
return None
|
|
2067
|
+
coverage_by_key: dict[tuple[int, str], list[dict]] = {}
|
|
2068
|
+
for stage in raw_stages:
|
|
2069
|
+
if not isinstance(stage, dict) or not isinstance(stage.get("stage"), int):
|
|
2070
|
+
failures.append("final-report data.json: planning stage is malformed")
|
|
2071
|
+
continue
|
|
2072
|
+
rows = stage.get("designSurfaceCoverage", [])
|
|
2073
|
+
if not isinstance(rows, list):
|
|
2074
|
+
failures.append(
|
|
2075
|
+
f"final-report data.json: Stage {stage['stage']} "
|
|
2076
|
+
"designSurfaceCoverage is malformed"
|
|
2077
|
+
)
|
|
2078
|
+
continue
|
|
2079
|
+
for row in rows:
|
|
2080
|
+
if not isinstance(row, dict) or not isinstance(row.get("kind"), str):
|
|
2081
|
+
failures.append(
|
|
2082
|
+
f"final-report data.json: Stage {stage['stage']} has malformed "
|
|
2083
|
+
"designSurfaceCoverage row"
|
|
2084
|
+
)
|
|
2085
|
+
continue
|
|
2086
|
+
key = (stage["stage"], row["kind"])
|
|
2087
|
+
coverage_by_key.setdefault(key, []).append(row)
|
|
2088
|
+
return coverage_by_key
|
|
2089
|
+
|
|
2090
|
+
|
|
2091
|
+
def _trigger_evidence_identity(raw_evidence: object) -> set[tuple[object, object, object]]:
|
|
2092
|
+
if not isinstance(raw_evidence, (list, tuple)):
|
|
2093
|
+
return set()
|
|
2094
|
+
return {
|
|
2095
|
+
(row.get("step"), row.get("field"), row.get("match"))
|
|
2096
|
+
for row in raw_evidence
|
|
2097
|
+
if isinstance(row, dict)
|
|
2098
|
+
}
|
|
2099
|
+
|
|
2100
|
+
|
|
2101
|
+
def _validate_detector_coverage(
|
|
2102
|
+
planning: dict,
|
|
2103
|
+
coverage_by_key: dict[tuple[int, str], list[dict]],
|
|
2104
|
+
failures: list[str],
|
|
2105
|
+
) -> None:
|
|
2106
|
+
triggers = detect_design_surfaces(planning)
|
|
2107
|
+
expected_by_key = {(trigger.stage, trigger.kind): trigger for trigger in triggers}
|
|
2108
|
+
detector_kinds = {rule.kind for rule in DESIGN_SURFACE_RULES}
|
|
2109
|
+
for key, trigger in expected_by_key.items():
|
|
2110
|
+
stage, kind = key
|
|
2111
|
+
if kind == "manual-user-test":
|
|
2112
|
+
failures.append(
|
|
2113
|
+
"final-report data.json: detector must not generate "
|
|
2114
|
+
f"manual-user-test for Stage {stage}"
|
|
2115
|
+
)
|
|
2116
|
+
continue
|
|
2117
|
+
rows = coverage_by_key.get(key, [])
|
|
2118
|
+
if len(rows) != 1:
|
|
2119
|
+
failures.append(
|
|
2120
|
+
f"final-report data.json: detector trigger Stage {stage} kind "
|
|
2121
|
+
f"{kind} requires exactly one designSurfaceCoverage row; "
|
|
2122
|
+
f"found {len(rows)}"
|
|
2123
|
+
)
|
|
2124
|
+
continue
|
|
2125
|
+
expected_evidence = {
|
|
2126
|
+
(evidence.step, evidence.field, evidence.match)
|
|
2127
|
+
for evidence in trigger.evidence
|
|
2128
|
+
}
|
|
2129
|
+
actual_evidence = _trigger_evidence_identity(rows[0].get("triggerEvidence"))
|
|
2130
|
+
if actual_evidence != expected_evidence:
|
|
2131
|
+
failures.append(
|
|
2132
|
+
f"final-report data.json: Stage {stage} {kind} triggerEvidence "
|
|
2133
|
+
"does not match detector output"
|
|
2134
|
+
)
|
|
2135
|
+
for stage, kind in coverage_by_key:
|
|
2136
|
+
if kind in detector_kinds and (stage, kind) not in expected_by_key:
|
|
2137
|
+
failures.append(
|
|
2138
|
+
f"final-report data.json: Stage {stage} {kind} coverage has no "
|
|
2139
|
+
"matching detector trigger"
|
|
2140
|
+
)
|
|
2141
|
+
|
|
2142
|
+
|
|
2143
|
+
def _coverage_prep_ids(row: dict) -> list[str]:
|
|
2144
|
+
if isinstance(row.get("prepItemId"), str):
|
|
2145
|
+
return [row["prepItemId"]]
|
|
2146
|
+
raw_ids = row.get("prepItemIds")
|
|
2147
|
+
if isinstance(raw_ids, list):
|
|
2148
|
+
return [item_id for item_id in raw_ids if isinstance(item_id, str)]
|
|
2149
|
+
return []
|
|
2150
|
+
|
|
2151
|
+
|
|
2152
|
+
def _validate_prep_references(
|
|
2153
|
+
items: list[dict],
|
|
2154
|
+
items_by_id: dict[str, dict],
|
|
2155
|
+
coverage_by_key: dict[tuple[int, str], list[dict]],
|
|
2156
|
+
failures: list[str],
|
|
2157
|
+
) -> None:
|
|
2158
|
+
referenced: set[tuple[str, int, str]] = set()
|
|
2159
|
+
for (stage, kind), rows in coverage_by_key.items():
|
|
2160
|
+
for row in rows:
|
|
2161
|
+
if row.get("disposition") != "prep-item":
|
|
2162
|
+
continue
|
|
2163
|
+
for item_id in _coverage_prep_ids(row):
|
|
2164
|
+
item = items_by_id.get(item_id)
|
|
2165
|
+
if item is None:
|
|
2166
|
+
failures.append(
|
|
2167
|
+
f"final-report data.json: coverage references missing {item_id}"
|
|
2168
|
+
)
|
|
2169
|
+
continue
|
|
2170
|
+
if stage not in (item.get("stageRefs") or []):
|
|
2171
|
+
failures.append(
|
|
2172
|
+
f"final-report data.json: {item_id}.stageRefs does not "
|
|
2173
|
+
f"include coverage Stage {stage}"
|
|
2174
|
+
)
|
|
2175
|
+
if item.get("kind") != kind:
|
|
2176
|
+
failures.append(
|
|
2177
|
+
f"final-report data.json: {item_id}.kind does not match "
|
|
2178
|
+
f"coverage kind {kind}"
|
|
2179
|
+
)
|
|
2180
|
+
referenced.add((item_id, stage, kind))
|
|
2181
|
+
for item in items:
|
|
2182
|
+
item_id = item.get("id")
|
|
2183
|
+
kind = item.get("kind")
|
|
2184
|
+
stage_refs = item.get("stageRefs")
|
|
2185
|
+
if not isinstance(item_id, str) or not isinstance(kind, str):
|
|
2186
|
+
continue
|
|
2187
|
+
if not isinstance(stage_refs, list):
|
|
2188
|
+
failures.append(
|
|
2189
|
+
f"final-report data.json: {item_id}.stageRefs is malformed"
|
|
2190
|
+
)
|
|
2191
|
+
continue
|
|
2192
|
+
for stage in stage_refs:
|
|
2193
|
+
if (item_id, stage, kind) not in referenced:
|
|
2194
|
+
failures.append(
|
|
2195
|
+
f"final-report data.json: {item_id} Stage {stage} kind {kind} "
|
|
2196
|
+
"has no matching prep-item coverage reference"
|
|
2197
|
+
)
|
|
2198
|
+
|
|
2199
|
+
|
|
2200
|
+
def _validate_design_prep_states(items: list[dict], failures: list[str]) -> None:
|
|
2201
|
+
for item in items:
|
|
2202
|
+
item_id = str(item.get("id") or "<missing>")
|
|
2203
|
+
stage_refs = item.get("stageRefs") or []
|
|
2204
|
+
review_at = item.get("reviewAt")
|
|
2205
|
+
if isinstance(review_at, dict) and "stage" in review_at:
|
|
2206
|
+
if review_at.get("stage") not in stage_refs:
|
|
2207
|
+
failures.append(
|
|
2208
|
+
f"final-report data.json: {item_id}.reviewAt.stage must belong "
|
|
2209
|
+
"to stageRefs"
|
|
2210
|
+
)
|
|
2211
|
+
status = item.get("status")
|
|
2212
|
+
if status == "blocked" and (
|
|
2213
|
+
not isinstance(item.get("humanConfirmation"), dict)
|
|
2214
|
+
or item["humanConfirmation"].get("required") is not True
|
|
2215
|
+
):
|
|
2216
|
+
failures.append(
|
|
2217
|
+
f"final-report data.json: blocked {item_id} requires "
|
|
2218
|
+
"humanConfirmation.required=true"
|
|
2219
|
+
)
|
|
2220
|
+
if status in _DESIGN_PREP_REQUEST_STATUSES and not isinstance(
|
|
2221
|
+
item.get("requestPath"), str
|
|
2222
|
+
):
|
|
2223
|
+
failures.append(
|
|
2224
|
+
f"final-report data.json: {status} {item_id} requires requestPath"
|
|
2225
|
+
)
|
|
2226
|
+
if status in _DESIGN_PREP_TERMINAL_STATUSES and "requestPath" in item:
|
|
2227
|
+
failures.append(
|
|
2228
|
+
f"final-report data.json: terminal {item_id} must not carry requestPath"
|
|
2229
|
+
)
|
|
2230
|
+
|
|
2231
|
+
|
|
2232
|
+
def _validate_design_prep_requests(
|
|
2233
|
+
data: dict,
|
|
2234
|
+
report_path: Path,
|
|
2235
|
+
items: list[dict],
|
|
2236
|
+
failures: list[str],
|
|
2237
|
+
) -> None:
|
|
2238
|
+
data_path = _data_path_for(report_path)
|
|
2239
|
+
planning_seq = _design_prep_planning_seq(data_path)
|
|
2240
|
+
report_language = _design_prep_report_language(data)
|
|
2241
|
+
for item in items:
|
|
2242
|
+
if item.get("status") not in _DESIGN_PREP_REQUEST_STATUSES:
|
|
2243
|
+
continue
|
|
2244
|
+
item_id = str(item.get("id") or "<missing>")
|
|
2245
|
+
target, expected = _render_design_prep_request(
|
|
2246
|
+
data_path=data_path,
|
|
2247
|
+
planning_seq=planning_seq,
|
|
2248
|
+
report_language=report_language,
|
|
2249
|
+
item=item,
|
|
2250
|
+
)
|
|
2251
|
+
if not target.is_file():
|
|
2252
|
+
failures.append(
|
|
2253
|
+
f"final-report data.json: design-prep request is missing for {item_id}: "
|
|
2254
|
+
f"{target}"
|
|
2255
|
+
)
|
|
2256
|
+
continue
|
|
2257
|
+
try:
|
|
2258
|
+
actual = target.read_bytes()
|
|
2259
|
+
except OSError as exc:
|
|
2260
|
+
failures.append(
|
|
2261
|
+
f"final-report data.json: cannot read design-prep request for "
|
|
2262
|
+
f"{item_id}: {exc}"
|
|
2263
|
+
)
|
|
2264
|
+
continue
|
|
2265
|
+
if actual != expected:
|
|
2266
|
+
failures.append(
|
|
2267
|
+
f"final-report data.json: design-prep request content or "
|
|
2268
|
+
f"fingerprint is stale for {item_id}: {target}"
|
|
2269
|
+
)
|
|
2270
|
+
|
|
2271
|
+
|
|
2272
|
+
def _validate_design_prep_contract(
|
|
2273
|
+
data: dict,
|
|
2274
|
+
report_path: Path | None,
|
|
2275
|
+
report_contracts: set[str],
|
|
2276
|
+
failures: list[str],
|
|
2277
|
+
) -> list[str]:
|
|
2278
|
+
warnings: list[str] = []
|
|
2279
|
+
planning = data.get("implementationPlanning")
|
|
2280
|
+
if not isinstance(planning, dict):
|
|
2281
|
+
if _DESIGN_PREP_CONTRACT in report_contracts:
|
|
2282
|
+
failures.append(
|
|
2283
|
+
"final-report data.json: implementationPlanning is malformed"
|
|
2284
|
+
)
|
|
2285
|
+
return warnings
|
|
2286
|
+
preparation = planning.get("designPreparation")
|
|
2287
|
+
if not isinstance(preparation, dict):
|
|
2288
|
+
if _DESIGN_PREP_CONTRACT in report_contracts:
|
|
2289
|
+
failures.append(
|
|
2290
|
+
"final-report data.json: marker implementation-design-prep-v1 "
|
|
2291
|
+
"requires implementationPlanning.designPreparation"
|
|
2292
|
+
)
|
|
2293
|
+
else:
|
|
2294
|
+
warnings.append("legacy-unassessed")
|
|
2295
|
+
return warnings
|
|
2296
|
+
if _DESIGN_PREP_CONTRACT not in report_contracts:
|
|
2297
|
+
return warnings
|
|
2298
|
+
try:
|
|
2299
|
+
parsed = _design_prep_rows(planning, failures)
|
|
2300
|
+
coverage_by_key = _design_surface_coverage(planning, failures)
|
|
2301
|
+
if parsed is None or coverage_by_key is None:
|
|
2302
|
+
return warnings
|
|
2303
|
+
items, items_by_id = parsed
|
|
2304
|
+
_validate_detector_coverage(planning, coverage_by_key, failures)
|
|
2305
|
+
_validate_prep_references(items, items_by_id, coverage_by_key, failures)
|
|
2306
|
+
_validate_design_prep_states(items, failures)
|
|
2307
|
+
if report_path is not None:
|
|
2308
|
+
_validate_design_prep_requests(data, report_path, items, failures)
|
|
2309
|
+
except (DesignSurfaceError, DesignPrepError, KeyError, TypeError, ValueError) as exc:
|
|
2310
|
+
failures.append(
|
|
2311
|
+
"final-report data.json: design-preparation contract is malformed: "
|
|
2312
|
+
f"{exc}"
|
|
2313
|
+
)
|
|
2314
|
+
except Exception as exc: # noqa: BLE001
|
|
2315
|
+
failures.append(
|
|
2316
|
+
"final-report data.json: design-preparation validation failed closed "
|
|
2317
|
+
f"on malformed input: {exc}"
|
|
2318
|
+
)
|
|
2319
|
+
return warnings
|
|
1904
2320
|
|
|
1905
2321
|
|
|
1906
2322
|
# A `subject` this short or shaped like a bare `P-Opt-1` id is a placeholder,
|
|
@@ -1986,6 +2402,93 @@ def _validate_plan_body_clarification_matching(data: dict, failures: list[str])
|
|
|
1986
2402
|
)
|
|
1987
2403
|
|
|
1988
2404
|
|
|
2405
|
+
def _validate_self_fix_before_clarification(data: dict, failures: list[str]) -> None:
|
|
2406
|
+
"""A planner-fixable defect MUST pass through a self-fix round before it is
|
|
2407
|
+
promoted to a `## 1. Clarification Items` row. Closes the hole where the
|
|
2408
|
+
lead dumps a fixable plan defect (abbreviated path, prose command,
|
|
2409
|
+
placeholder, coverage remap) onto the user instead of having report-writer
|
|
2410
|
+
correct it (plan-body-verification.md "Self-fix round").
|
|
2411
|
+
"""
|
|
2412
|
+
ip = data.get("implementationPlanning")
|
|
2413
|
+
if not isinstance(ip, dict):
|
|
2414
|
+
return
|
|
2415
|
+
pbv = ip.get("planBodyVerification")
|
|
2416
|
+
if not isinstance(pbv, dict):
|
|
2417
|
+
return
|
|
2418
|
+
round_count = pbv.get("roundCount")
|
|
2419
|
+
if not isinstance(round_count, int) or round_count < 1:
|
|
2420
|
+
return
|
|
2421
|
+
if pbv.get("selfFixRoundApplied") is True:
|
|
2422
|
+
return
|
|
2423
|
+
for item in pbv.get("planItems") or []:
|
|
2424
|
+
if not isinstance(item, dict):
|
|
2425
|
+
continue
|
|
2426
|
+
if _classify_plan_item_gate(item) != "majority-disagree":
|
|
2427
|
+
continue
|
|
2428
|
+
disagrees = [
|
|
2429
|
+
v for v in (item.get("verdicts") or [])
|
|
2430
|
+
if isinstance(v, dict) and str(v.get("verdict") or "").upper() == "DISAGREE"
|
|
2431
|
+
]
|
|
2432
|
+
fixable = [v for v in disagrees if v.get("fixability") == "planner-fixable"]
|
|
2433
|
+
if disagrees and len(fixable) * 2 > len(disagrees):
|
|
2434
|
+
failures.append(
|
|
2435
|
+
"final-report data.json: plan item "
|
|
2436
|
+
f"`{item.get('id') or '<unknown>'}` is majority-disagree with a "
|
|
2437
|
+
"planner-fixable majority but `selfFixRoundApplied` is not true. A "
|
|
2438
|
+
"planner-fixable defect MUST be corrected by a report-writer self-fix "
|
|
2439
|
+
"round before it becomes a clarification row "
|
|
2440
|
+
"(plan-body-verification.md Self-fix round)."
|
|
2441
|
+
)
|
|
2442
|
+
|
|
2443
|
+
|
|
2444
|
+
# Allowed `fixability` values, mirroring the schema enum
|
|
2445
|
+
# (schemas/final-report-v1.0.schema.json planItems[].verdicts[].fixability).
|
|
2446
|
+
_FIXABILITY_VALUES = frozenset({"planner-fixable", "needs-user-input"})
|
|
2447
|
+
|
|
2448
|
+
|
|
2449
|
+
def _validate_disagree_has_fixability(data: dict, failures: list[str]) -> None:
|
|
2450
|
+
"""Every `DISAGREE` verdict MUST carry a valid `fixability`
|
|
2451
|
+
(`planner-fixable` / `needs-user-input`). The schema enum only constrains a
|
|
2452
|
+
*present* value; it does not require the field, so a DISAGREE with a missing
|
|
2453
|
+
or mislabelled fixability is schema-valid. That silently degrades
|
|
2454
|
+
`_validate_self_fix_before_clarification`, which counts a non-`planner-fixable`
|
|
2455
|
+
DISAGREE as non-fixable and thus lets a genuinely planner-fixable defect
|
|
2456
|
+
skip the self-fix round and land on the user. This is the enforcement point
|
|
2457
|
+
for the "fixability is DISAGREE-only 필수" MUST in plan-body-verification.md.
|
|
2458
|
+
"""
|
|
2459
|
+
ip = data.get("implementationPlanning")
|
|
2460
|
+
if not isinstance(ip, dict):
|
|
2461
|
+
return
|
|
2462
|
+
pbv = ip.get("planBodyVerification")
|
|
2463
|
+
if not isinstance(pbv, dict):
|
|
2464
|
+
return
|
|
2465
|
+
round_count = pbv.get("roundCount")
|
|
2466
|
+
if not isinstance(round_count, int) or round_count < 1:
|
|
2467
|
+
return
|
|
2468
|
+
for item in pbv.get("planItems") or []:
|
|
2469
|
+
if not isinstance(item, dict):
|
|
2470
|
+
continue
|
|
2471
|
+
item_id = item.get("id") or "<unknown>"
|
|
2472
|
+
for verdict in item.get("verdicts") or []:
|
|
2473
|
+
if not isinstance(verdict, dict):
|
|
2474
|
+
continue
|
|
2475
|
+
if str(verdict.get("verdict") or "").upper() != "DISAGREE":
|
|
2476
|
+
continue
|
|
2477
|
+
fixability = verdict.get("fixability")
|
|
2478
|
+
if fixability not in _FIXABILITY_VALUES:
|
|
2479
|
+
worker = verdict.get("worker") or "<worker>"
|
|
2480
|
+
allowed = " / ".join(sorted(_FIXABILITY_VALUES))
|
|
2481
|
+
failures.append(
|
|
2482
|
+
f"final-report data.json: plan item `{item_id}` has a "
|
|
2483
|
+
f"`DISAGREE` verdict from `{worker}` with "
|
|
2484
|
+
f"fixability `{fixability}` — a DISAGREE MUST declare a "
|
|
2485
|
+
f"fixability of {allowed}. A missing/invalid value is "
|
|
2486
|
+
"counted as non-fixable and lets a planner-fixable defect "
|
|
2487
|
+
"skip the self-fix round (plan-body-verification.md "
|
|
2488
|
+
"\"fixability (DISAGREE 전용, 필수)\")."
|
|
2489
|
+
)
|
|
2490
|
+
|
|
2491
|
+
|
|
1989
2492
|
_COVERED_BY_ANCHOR_RE = re.compile(r"option|stage|step", re.IGNORECASE)
|
|
1990
2493
|
_COVERED_BY_STAGE_REF_RE = re.compile(r"stage\s*(\d+)", re.IGNORECASE)
|
|
1991
2494
|
_COVERED_BY_VAGUE = {"recommended option", "the recommended option", "recommended"}
|
|
@@ -2838,7 +3341,14 @@ def main() -> int:
|
|
|
2838
3341
|
# contain every required section. Substring checks below are a
|
|
2839
3342
|
# safety net for hand-edited or pre-v1.0 reports.
|
|
2840
3343
|
task_type = effective_run_task_type(run_manifest, task_manifest)
|
|
2841
|
-
|
|
3344
|
+
report_contracts = _normalize_report_contracts(
|
|
3345
|
+
run_manifest.get("reportContracts")
|
|
3346
|
+
)
|
|
3347
|
+
validate_final_report_data(
|
|
3348
|
+
report_path,
|
|
3349
|
+
failures,
|
|
3350
|
+
report_contracts=report_contracts,
|
|
3351
|
+
)
|
|
2842
3352
|
_validate_fix_cycle(
|
|
2843
3353
|
run_manifest, _load_final_report_data(report_path), failures
|
|
2844
3354
|
)
|
|
@@ -2905,7 +3415,7 @@ def main() -> int:
|
|
|
2905
3415
|
write_json(task_manifest_path, task_manifest)
|
|
2906
3416
|
|
|
2907
3417
|
# Best-effort: regenerate discovery/task-catalog.json so downstream
|
|
2908
|
-
# tools (okstra-schedule, FleetView listings, etc.) don't read a stale
|
|
3418
|
+
# tools (okstra-schedule-gen, FleetView listings, etc.) don't read a stale
|
|
2909
3419
|
# snapshot frozen at instruction-set generation time.
|
|
2910
3420
|
catalog_ok, catalog_msg = _refresh_task_catalog(project_root, task_manifest)
|
|
2911
3421
|
if catalog_ok:
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
2
|
"""
|
|
3
3
|
Validate that an okstra schedule Markdown file conforms to the Section Contract
|
|
4
|
-
defined in skills/okstra-schedule/SKILL.md.
|
|
4
|
+
defined in skills/okstra-schedule-gen/SKILL.md.
|
|
5
5
|
|
|
6
6
|
Usage:
|
|
7
7
|
python3 validators/validate-schedule.py <path-to-schedule.md>
|
|
8
8
|
|
|
9
9
|
Exits 0 if compliant, 1 with a list of violations otherwise. Intended to be
|
|
10
|
-
called by the okstra-schedule skill (self-validation step) and by humans /
|
|
10
|
+
called by the okstra-schedule-gen skill (self-validation step) and by humans /
|
|
11
11
|
hooks before committing a schedule.
|
|
12
12
|
"""
|
|
13
13
|
|
|
@@ -442,9 +442,13 @@ def validate(path: Path) -> list[str]:
|
|
|
442
442
|
block = text[block_start:block_end]
|
|
443
443
|
heading_line = text[block_start : text.find("\n", block_start)].strip()
|
|
444
444
|
|
|
445
|
-
# Skip blocks marked as NEEDS-OKSTRA-RUN
|
|
446
|
-
# intentionally partial.
|
|
447
|
-
if
|
|
445
|
+
# Skip blocks marked as NEEDS-OKSTRA-RUN, PARSE-ERROR, or NEEDS-PLANNING
|
|
446
|
+
# — these are intentionally partial.
|
|
447
|
+
if (
|
|
448
|
+
"[NEEDS-OKSTRA-RUN]" in block
|
|
449
|
+
or "[PARSE-ERROR" in block
|
|
450
|
+
or "[NEEDS-PLANNING]" in block
|
|
451
|
+
):
|
|
448
452
|
continue
|
|
449
453
|
|
|
450
454
|
last_pos = -1
|
|
@@ -654,13 +658,13 @@ def main(argv: list[str]) -> int:
|
|
|
654
658
|
path = Path(argv[1])
|
|
655
659
|
violations = validate(path)
|
|
656
660
|
if not violations:
|
|
657
|
-
print(f"OK: {path} conforms to okstra-schedule Section Contract")
|
|
661
|
+
print(f"OK: {path} conforms to okstra-schedule-gen Section Contract")
|
|
658
662
|
return 0
|
|
659
663
|
print(f"FAIL: {path}", file=sys.stderr)
|
|
660
664
|
for v in violations:
|
|
661
665
|
print(f" - {v}", file=sys.stderr)
|
|
662
666
|
print(
|
|
663
|
-
"\nFix per skills/okstra-schedule/SKILL.md "
|
|
667
|
+
"\nFix per skills/okstra-schedule-gen/SKILL.md "
|
|
664
668
|
"(Section Contract).",
|
|
665
669
|
file=sys.stderr,
|
|
666
670
|
)
|