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.
Files changed (67) hide show
  1. package/README.md +4 -2
  2. package/docs/architecture/storage-model.md +14 -0
  3. package/docs/architecture.md +38 -8
  4. package/docs/cli.md +45 -5
  5. package/docs/for-ai/README.md +2 -2
  6. package/docs/for-ai/skills/okstra-rollup.md +1 -1
  7. package/docs/for-ai/skills/{okstra-schedule.md → okstra-schedule-gen.md} +3 -3
  8. package/docs/project-structure-overview.md +4 -3
  9. package/docs/task-process/implementation-planning.md +33 -9
  10. package/docs/task-process/implementation.md +21 -2
  11. package/package.json +1 -1
  12. package/runtime/BUILD.json +2 -2
  13. package/runtime/agents/workers/claude-worker.md +1 -1
  14. package/runtime/bin/lib/okstra/usage.sh +3 -3
  15. package/runtime/prompts/launch.template.md +9 -6
  16. package/runtime/prompts/lead/adapters/claude-code.md +104 -0
  17. package/runtime/prompts/lead/adapters/codex.md +45 -0
  18. package/runtime/prompts/lead/adapters/external.md +46 -0
  19. package/runtime/prompts/lead/context-loader.md +5 -5
  20. package/runtime/prompts/lead/convergence.md +11 -28
  21. package/runtime/prompts/lead/okstra-lead-contract.md +44 -94
  22. package/runtime/prompts/lead/plan-body-verification.md +50 -5
  23. package/runtime/prompts/lead/report-writer.md +59 -54
  24. package/runtime/prompts/lead/team-contract.md +33 -106
  25. package/runtime/prompts/profiles/_common-contract.md +3 -3
  26. package/runtime/prompts/profiles/_implementation-deliverable.md +3 -1
  27. package/runtime/prompts/profiles/_implementation-executor.md +6 -1
  28. package/runtime/prompts/profiles/_implementation-verifier.md +2 -2
  29. package/runtime/prompts/profiles/final-verification.md +2 -0
  30. package/runtime/prompts/profiles/implementation-planning.md +14 -4
  31. package/runtime/prompts/profiles/requirements-discovery.md +1 -1
  32. package/runtime/prompts/wizard/prompts.ko.json +44 -0
  33. package/runtime/python/okstra_ctl/codex_dispatch.py +23 -1
  34. package/runtime/python/okstra_ctl/design_prep.py +1462 -0
  35. package/runtime/python/okstra_ctl/design_surfaces.py +243 -0
  36. package/runtime/python/okstra_ctl/final_report_schema.py +33 -1
  37. package/runtime/python/okstra_ctl/implementation_stage.py +35 -0
  38. package/runtime/python/okstra_ctl/incremental_carry.py +294 -21
  39. package/runtime/python/okstra_ctl/incremental_scope.py +51 -5
  40. package/runtime/python/okstra_ctl/lead_runtime.py +4 -0
  41. package/runtime/python/okstra_ctl/material.py +1 -1
  42. package/runtime/python/okstra_ctl/model_discovery.py +98 -0
  43. package/runtime/python/okstra_ctl/models.py +8 -3
  44. package/runtime/python/okstra_ctl/render.py +81 -194
  45. package/runtime/python/okstra_ctl/report_views.py +22 -7
  46. package/runtime/python/okstra_ctl/run.py +53 -5
  47. package/runtime/python/okstra_ctl/team_reconcile.py +3 -1
  48. package/runtime/python/okstra_ctl/user_response.py +67 -2
  49. package/runtime/python/okstra_ctl/wizard.py +283 -3
  50. package/runtime/python/okstra_token_usage/report.py +11 -0
  51. package/runtime/schemas/final-report-v1.0.schema.json +339 -0
  52. package/runtime/skills/okstra-inspect/SKILL.md +2 -2
  53. package/runtime/skills/okstra-rollup/SKILL.md +1 -1
  54. package/runtime/skills/{okstra-schedule → okstra-schedule-gen}/SKILL.md +62 -20
  55. package/runtime/skills/okstra-user-response/SKILL.md +8 -6
  56. package/runtime/templates/reports/final-report.template.md +71 -4
  57. package/runtime/templates/reports/i18n/en.json +32 -1
  58. package/runtime/templates/reports/i18n/ko.json +32 -1
  59. package/runtime/validators/validate-run.py +515 -5
  60. package/runtime/validators/validate-schedule.py +11 -7
  61. package/runtime/validators/validate_session_conformance.py +58 -33
  62. package/src/cli-registry.mjs +14 -0
  63. package/src/commands/inspect/design-prep.mjs +23 -0
  64. package/src/commands/inspect/stage-map.mjs +100 -0
  65. package/src/commands/lifecycle/install.mjs +3 -0
  66. package/src/commands/lifecycle/uninstall.mjs +8 -23
  67. 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(report_path: Path, failures: list[str]) -> None:
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(data, failures)
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(data: dict, failures: list[str]) -> None:
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
- validate_final_report_data(report_path, failures)
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 or PARSE-ERROR these are
446
- # intentionally partial.
447
- if "[NEEDS-OKSTRA-RUN]" in block or "[PARSE-ERROR" in block:
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
  )