okstra 0.130.1 → 0.130.3

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 (43) hide show
  1. package/docs/architecture.md +7 -1
  2. package/docs/cli.md +10 -2
  3. package/docs/project-structure-overview.md +16 -9
  4. package/docs/task-process/implementation-planning.md +11 -5
  5. package/package.json +1 -1
  6. package/runtime/BUILD.json +2 -2
  7. package/runtime/agents/workers/antigravity-worker.md +3 -1
  8. package/runtime/agents/workers/claude-worker.md +1 -1
  9. package/runtime/agents/workers/codex-worker.md +3 -1
  10. package/runtime/agents/workers/report-writer-worker.md +8 -4
  11. package/runtime/prompts/lead/adapters/claude-code.md +2 -2
  12. package/runtime/prompts/lead/convergence.md +44 -21
  13. package/runtime/prompts/lead/okstra-lead-contract.md +9 -2
  14. package/runtime/prompts/lead/plan-body-verification.md +55 -12
  15. package/runtime/prompts/lead/report-writer.md +16 -15
  16. package/runtime/prompts/lead/team-contract.md +8 -6
  17. package/runtime/prompts/profiles/implementation-planning.md +10 -2
  18. package/runtime/python/okstra_ctl/codex_dispatch.py +5 -1
  19. package/runtime/python/okstra_ctl/convergence.py +121 -1
  20. package/runtime/python/okstra_ctl/convergence_engine.py +903 -13
  21. package/runtime/python/okstra_ctl/convergence_migration.py +9 -1
  22. package/runtime/python/okstra_ctl/dispatch_core.py +13 -0
  23. package/runtime/python/okstra_ctl/plan_items.py +203 -0
  24. package/runtime/python/okstra_ctl/plan_items_cli.py +81 -0
  25. package/runtime/python/okstra_ctl/report_finalize.py +6 -4
  26. package/runtime/python/okstra_ctl/worker_artifact_paths.py +23 -0
  27. package/runtime/python/okstra_ctl/worker_liveness.py +4 -4
  28. package/runtime/python/okstra_ctl/worker_prompt_body.py +1 -1
  29. package/runtime/python/okstra_ctl/worker_prompt_contract.py +2 -1
  30. package/runtime/python/okstra_ctl/worker_prompt_headers.py +14 -0
  31. package/runtime/schemas/convergence-critic-results-v1.0.schema.json +57 -0
  32. package/runtime/schemas/convergence-groups-v1.0.schema.json +109 -0
  33. package/runtime/schemas/convergence-round-results-v1.0.schema.json +55 -0
  34. package/runtime/schemas/final-report-v1.0.schema.json +21 -2
  35. package/runtime/templates/report-writer-prompt-preamble.md +5 -3
  36. package/runtime/templates/reports/final-report.template.md +1 -1
  37. package/runtime/templates/worker-prompt-preamble.md +8 -7
  38. package/runtime/validators/validate-run.py +154 -118
  39. package/runtime/validators/validate_session_conformance.py +66 -23
  40. package/src/cli-registry.mjs +7 -0
  41. package/src/commands/execute/convergence.mjs +6 -1
  42. package/src/commands/execute/plan-items.mjs +9 -0
  43. package/src/commands/inspect/worker-liveness.mjs +2 -2
@@ -0,0 +1,55 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://okstra.dev/schemas/convergence-round-results-v1.0.json",
4
+ "title": "OKSTRA Convergence Round Results (v1.0)",
5
+ "type": "object",
6
+ "required": ["schemaVersion", "round", "dispatches", "votesByFinding"],
7
+ "additionalProperties": false,
8
+ "properties": {
9
+ "schemaVersion": { "const": "1.0" },
10
+ "round": { "type": "integer", "minimum": 1 },
11
+ "dispatches": {
12
+ "type": "array",
13
+ "items": { "$ref": "#/$defs/DispatchResult" }
14
+ },
15
+ "votesByFinding": {
16
+ "type": "object",
17
+ "additionalProperties": {
18
+ "type": "object",
19
+ "additionalProperties": { "$ref": "#/$defs/Vote" }
20
+ }
21
+ }
22
+ },
23
+ "$defs": {
24
+ "DispatchResult": {
25
+ "type": "object",
26
+ "required": ["worker", "status", "durationMs"],
27
+ "additionalProperties": false,
28
+ "properties": {
29
+ "worker": { "type": "string", "pattern": "\\S" },
30
+ "status": { "enum": ["completed", "timeout", "error", "not-run"] },
31
+ "durationMs": { "type": "integer", "minimum": 0 }
32
+ }
33
+ },
34
+ "Vote": {
35
+ "type": "object",
36
+ "required": ["verdict", "explanation"],
37
+ "additionalProperties": false,
38
+ "properties": {
39
+ "verdict": { "enum": ["agree", "disagree", "supplement", "verification-error", "unverifiable"] },
40
+ "disagreeBasis": { "enum": ["counter-evidence", "burden-not-met", null] },
41
+ "explanation": { "type": "string", "pattern": "\\S" }
42
+ },
43
+ "allOf": [
44
+ {
45
+ "if": {
46
+ "properties": { "verdict": { "enum": ["agree", "supplement", "verification-error", "unverifiable"] } }
47
+ },
48
+ "then": {
49
+ "properties": { "disagreeBasis": { "enum": [null] } }
50
+ }
51
+ }
52
+ ]
53
+ }
54
+ }
55
+ }
@@ -2075,9 +2075,28 @@
2075
2075
  "coveredBy": { "type": "string", "minLength": 1 },
2076
2076
  "status": {
2077
2077
  "type": "string",
2078
- "pattern": "^(covered|gap|blocked C-\\d{3,})$"
2078
+ "pattern": "^(covered|gap|blocked C-\\d{3,}|documented-deviation)$"
2079
+ },
2080
+ "decisionRefs": {
2081
+ "type": "array",
2082
+ "minItems": 1,
2083
+ "uniqueItems": true,
2084
+ "items": { "type": "string", "minLength": 1 }
2085
+ },
2086
+ "approvalDisposition": {
2087
+ "type": "string",
2088
+ "pattern": "^(accepted|blocked C-\\d{3,})$"
2079
2089
  }
2080
- }
2090
+ },
2091
+ "allOf": [
2092
+ {
2093
+ "if": {
2094
+ "properties": { "status": { "const": "documented-deviation" } },
2095
+ "required": ["status"]
2096
+ },
2097
+ "then": { "required": ["decisionRefs", "approvalDisposition"] }
2098
+ }
2099
+ ]
2081
2100
  },
2082
2101
 
2083
2102
  "ReadonlyCommandRow": {
@@ -15,18 +15,20 @@ Work like a senior engineer who owns this result, not a commentator on it.
15
15
 
16
16
  Read every input enumerated by the Phase 6 dispatch end-to-end: task/analysis inputs, worker results, convergence state, the instruction-set-local `final-report-template.md`, and the task-type excerpt `final-report-schema.json`. Do not pull the full repository template or schema when the scoped instruction-set copies are provided.
17
17
 
18
- Write Reading Confirmation to the report-writer audit sidecar, not the rendered final report. Resolve `.okstra/**` paths against `**Project Root:**`.
18
+ Write Reading Confirmation to `**Audit sidecar path:**`, not the rendered final report. Resolve `.okstra/**` paths against `**Project Root:**`.
19
+
20
+ Write `- PROGRESS: <stage> <ISO-8601-UTC>` there before reading and at least every five minutes while pending. The only valid report-writer stages are `started`, `required-reading-complete`, `synthesis-start`, `data-json-write-start`, `render-start`, and `write-result-start`.
19
21
 
20
22
  ## Report authoring handoff
21
23
 
22
- - Author the data.json at `**Result Path:**` and the audit file at `**Worker Result Path:**`.
24
+ - Author the data.json at `**Result Path:**` and the audit file at `**Audit sidecar path:**`.
23
25
  - Follow the task-type schema excerpt and Phase 6 report-writer contract. Do not perform independent analysis, edit source code, or load implementation coding-preflight resources.
24
26
  - Invoke `okstra render-final-report <Result Path>` after writing data.json and verify the markdown sibling exists before returning.
25
27
  - Preserve source item IDs, convergence classifications, round history, and unresolved dissent; do not recompute them from intuition.
26
28
 
27
29
  ## Anchor headers
28
30
 
29
- The generated prompt selects this file through `**Worker Preamble Path:**` and includes `**Worker Error Contract Path:**`, error paths, and read scope. It never includes `**Coding preflight pack:**`.
31
+ The generated prompt selects this file through `**Worker Preamble Path:**` and includes `**Worker Error Contract Path:**`, `**Audit sidecar path:**`, error paths, and read scope. It never includes `**Coding preflight pack:**`.
30
32
 
31
33
  ## Return message to the lead
32
34
 
@@ -281,7 +281,7 @@ Carried-forward plan items retain their prior verdicts verbatim; each such item
281
281
  | ID | Source | Requirement | Covered by option / stage / step | Status |
282
282
  |----|--------|-------------|-----------------------------------|--------|
283
283
  {% for row in implementationPlanning.requirementCoverage -%}
284
- | {{ row.id | mdcell }} | `{{ row.source | mdcell }}` | {{ row.requirement | mdcell }} | {{ row.coveredBy | mdcell }} | `{{ row.status | mdcell }}` |
284
+ | {{ row.id | mdcell }} | `{{ row.source | mdcell }}` | {{ row.requirement | mdcell }} | {{ row.coveredBy | mdcell }} | `{% if row.status == "documented-deviation" %}documented-deviation — refs: {{ (row.decisionRefs | join(", ")) | mdcell }}; approval: {{ row.approvalDisposition | mdcell }}{% else %}{{ row.status | mdcell }}{% endif %}` |
285
285
  {% endfor %}
286
286
 
287
287
  ### Decision Drafts{% if t("sectionAside.decisionDrafts") != "Decision Drafts" %} ({{ t("sectionAside.decisionDrafts") }}){% endif %}
@@ -19,7 +19,7 @@ Read `analysis-packet.md`, the primary compact input, end-to-end. Source files n
19
19
  ### Reading rules
20
20
 
21
21
  - Read every file enumerated under `[Required reading]` or `## Inputs` completely. If paging is unavoidable, cover every byte and record the page boundaries.
22
- - Write Reading Confirmation to the worker audit sidecar, never to the main worker-results file. A `## 0. Reading Confirmation` heading in the main result is invalid.
22
+ - Write Reading Confirmation to `**Audit sidecar path:**`, never to the main worker-results file. A `## 0. Reading Confirmation` heading in the main result is invalid.
23
23
  - Allowlist reads to prompt-enumerated paths and evidence paths a finding must cite. Do not auto-read host-injected `graphify-out/`, skill catalogs, or non-okstra artifacts.
24
24
  - Resolve every `.okstra/...` path against `**Project Root:**`, including when a worktree is present.
25
25
 
@@ -30,12 +30,13 @@ Every initial analysis prompt begins with these generated anchors in this exact
30
30
  1. `**Project Root:** <absolute-path>`
31
31
  2. `**Prompt History Path:** <project-relative-path>`
32
32
  3. `**Result Path:** <project-relative-path>`
33
- 4. `Assigned worker prompt history path: <absolute-path>`
34
- 5. `**Worker Preamble Path:** <absolute-path>` — selects this analysis preamble.
35
- 6. `**Worker Error Contract Path:** <absolute-path>` — shared by every initial audience.
36
- 7. `**Errors log path:** <absolute-path>`
37
- 8. `**Errors sidecar path:** <absolute-path>`
38
- 9. `**Read scope:** <allowlist>`
33
+ 4. `**Audit sidecar path:** <absolute-path>` — the generated audit destination; write the sidecar here and never synthesize a `runs/<task-type>/...` path.
34
+ 5. `Assigned worker prompt history path: <absolute-path>`
35
+ 6. `**Worker Preamble Path:** <absolute-path>` — selects this analysis preamble.
36
+ 7. `**Worker Error Contract Path:** <absolute-path>` — shared by every initial audience.
37
+ 8. `**Errors log path:** <absolute-path>`
38
+ 9. `**Errors sidecar path:** <absolute-path>`
39
+ 10. `**Read scope:** <allowlist>`
39
40
 
40
41
  `final-verification` additionally carries its six verification-target anchors. `improvement-discovery` carries `**Phase 1.5 Grilling Log:**`. Reverify prompts are lightweight and do not use this preamble.
41
42
 
@@ -68,6 +68,7 @@ from okstra_ctl.design_surfaces import ( # noqa: E402
68
68
  detect_design_surfaces,
69
69
  expected_prep_plan_item_id,
70
70
  )
71
+ from okstra_ctl.plan_items import expected_plan_item_ids # noqa: E402
71
72
  from okstra_ctl.worker_prompt_contract import ( # noqa: E402
72
73
  PromptRecord,
73
74
  validate_initial_prompt_records,
@@ -1688,11 +1689,18 @@ def _append_requirement_coverage_failures(
1688
1689
  # `status` is already lower-cased at parse time, so the blocker form
1689
1690
  # arrives as `blocked c-001`; match lower-case here even though the
1690
1691
  # canonical authored form (and the remedy message) is `blocked C-NNN`.
1691
- if not re.fullmatch(r"covered|gap|blocked c-\d{3,}", status):
1692
+ if not re.fullmatch(
1693
+ r"covered|gap|blocked c-\d{3,}|"
1694
+ r"documented-deviation — refs: (?:c-\d{3,}|d-\d{4,})"
1695
+ r"(?:, (?:c-\d{3,}|d-\d{4,}))*; approval: "
1696
+ r"(?:accepted|blocked c-\d{3,})",
1697
+ status,
1698
+ ):
1692
1699
  failures.append(
1693
1700
  "implementation-planning Requirement Coverage row "
1694
1701
  f"`{row_id}` has invalid Status `{status}`; expected "
1695
- "`covered`, `gap`, or `blocked C-NNN`."
1702
+ "`covered`, `gap`, `blocked C-NNN`, or a rendered "
1703
+ "`documented-deviation` disposition."
1696
1704
  )
1697
1705
 
1698
1706
  if gate_value in ("passed", "passed-with-dissent"):
@@ -1700,6 +1708,7 @@ def _append_requirement_coverage_failures(
1700
1708
  f"{row_id} ({status})"
1701
1709
  for row_id, status in rows
1702
1710
  if status != "covered"
1711
+ and not status.endswith("; approval: accepted")
1703
1712
  ]
1704
1713
  if uncovered:
1705
1714
  failures.append(
@@ -1842,19 +1851,15 @@ def validate_final_report_data(
1842
1851
  _validate_final_verification_consistency(data, failures)
1843
1852
  elif task_type == "implementation-planning":
1844
1853
  active_report_contracts = report_contracts or set()
1845
- enforce_design_prep = _DESIGN_PREP_CONTRACT in active_report_contracts
1846
1854
  _validate_implementation_planning_cross_project(data, failures)
1847
1855
  _validate_implementation_planning_decision_drafts(data, failures)
1848
1856
  _validate_plan_body_gate_recompute(data, failures)
1849
- _validate_plan_item_extraction_completeness(
1850
- data,
1851
- failures,
1852
- enforce_design_prep=enforce_design_prep,
1853
- )
1857
+ _validate_plan_item_extraction_completeness(data, failures)
1854
1858
  _validate_plan_item_subject_substance(data, failures)
1855
1859
  _validate_plan_body_clarification_matching(data, failures)
1856
1860
  _validate_disagree_has_fixability(data, failures)
1857
1861
  _validate_self_fix_before_clarification(data, failures)
1862
+ _validate_requirement_deviations(data, failures)
1858
1863
  _validate_requirement_coverage_covered_by(data, failures)
1859
1864
  warnings = _validate_design_prep_contract(
1860
1865
  data,
@@ -2132,52 +2137,11 @@ def _validate_plan_body_gate_recompute(data: dict, failures: list[str]) -> None:
2132
2137
  )
2133
2138
 
2134
2139
 
2135
- # Each plan-body deliverable array and the P-* verdict-item prefix it must be
2136
- # extracted into for §5.5.9 cross-verification (plan-body-verification.md Plan-item
2137
- # extraction). A weak item dropped from planItems escapes the gate entirely.
2138
- _PLAN_ITEM_SOURCES = (
2139
- ("optionCandidates", "P-Opt", "Option Candidates (§4.5.1)"),
2140
- ("stepwiseExecution", "P-Step", "Stepwise Execution Order (§4.5.4)"),
2141
- ("dependencyMigrationRisk", "P-Dep", "Dependency / Migration Risk (§4.5.5)"),
2142
- ("validationChecklist", "P-Val", "Validation Checklist (§4.5.6)"),
2143
- ("rollbackStrategy", "P-Rb", "Rollback Strategy (§4.5.7)"),
2144
- ("requirementCoverage", "P-Req", "Requirement Coverage (§4.5.8)"),
2145
- )
2146
-
2147
-
2148
- def _plan_item_source_count(planning: dict, field: str) -> int:
2149
- """Row count for one plan-body deliverable array.
2150
-
2151
- `stepwiseExecution` has two homes: the top-level array is the legacy flat
2152
- summary (schema: "Legacy flat summary kept for compatibility only. New
2153
- reports use stageMap/stages") and is absent from `implementationPlanning.
2154
- required`, while a current report carries the real steps per stage. Counting
2155
- only the legacy field made this whole check a no-op for the largest
2156
- category — a modern plan could drop every `P-Step-*` item and still pass.
2157
- """
2158
- rows = [r for r in (planning.get(field) or []) if isinstance(r, dict)]
2159
- if field != "stepwiseExecution":
2160
- return len(rows)
2161
- per_stage = sum(
2162
- len([s for s in (stage.get("stepwiseExecution") or []) if isinstance(s, dict)])
2163
- for stage in (planning.get("stages") or [])
2164
- if isinstance(stage, dict)
2165
- )
2166
- return per_stage or len(rows)
2167
-
2168
-
2169
2140
  def _validate_plan_item_extraction_completeness(
2170
2141
  data: dict,
2171
2142
  failures: list[str],
2172
- *,
2173
- enforce_design_prep: bool = False,
2174
2143
  ) -> None:
2175
- """H2 when a verification round ran, every plan-body deliverable item must
2176
- appear as a matching `P-*` verdict item. Closes the omission hole where the
2177
- lead silently drops a weak item (e.g. an out-of-order rollback) from
2178
- planItems so no worker ever verifies it, yet the gate still reads passed.
2179
- Requires only that no category is *under-extracted*; over-extraction is fine.
2180
- """
2144
+ """Require the exact deterministic P-* extraction when a round ran."""
2181
2145
  ip = data.get("implementationPlanning")
2182
2146
  if not isinstance(ip, dict):
2183
2147
  return
@@ -2187,82 +2151,45 @@ def _validate_plan_item_extraction_completeness(
2187
2151
  round_count = pbv.get("roundCount")
2188
2152
  if not isinstance(round_count, int) or round_count < 1:
2189
2153
  return
2190
- extracted_ids = [
2191
- str(it.get("id") or "").upper()
2192
- for it in (pbv.get("planItems") or [])
2193
- if isinstance(it, dict)
2194
- ]
2195
- for field, prefix, label in _PLAN_ITEM_SOURCES:
2196
- source_count = _plan_item_source_count(ip, field)
2197
- if source_count == 0:
2198
- continue
2199
- extracted = len(
2200
- [i for i in extracted_ids if i.startswith(prefix.upper() + "-")]
2201
- )
2202
- if extracted < source_count:
2203
- failures.append(
2204
- f"final-report data.json: {label} has {source_count} item(s) but "
2205
- f"planBodyVerification.planItems carries only {extracted} "
2206
- f"`{prefix}-*` verdict item(s). Every plan-body item MUST be "
2207
- "extracted for cross-verification — a dropped item escapes the "
2208
- "gate (plan-body-verification.md Plan-item extraction)."
2209
- )
2210
- if enforce_design_prep:
2211
- _validate_expected_prep_plan_items(ip, extracted_ids, failures)
2212
-
2213
-
2214
- def _validate_expected_prep_plan_items(
2215
- planning: dict,
2216
- extracted_ids: list[str],
2217
- failures: list[str],
2218
- ) -> None:
2219
2154
  try:
2220
- expected_prep_ids = {}
2221
- for trigger in detect_design_surfaces(planning):
2222
- item_id = expected_prep_plan_item_id(trigger.stage, trigger.kind)
2223
- expected_prep_ids[item_id.upper()] = item_id
2224
- except DesignSurfaceError as exc:
2225
- failures.append(
2226
- f"final-report data.json: design-surface detection failed: {exc}"
2227
- )
2228
- return
2155
+ expected_sequence = expected_plan_item_ids(ip)
2229
2156
  except Exception as exc: # noqa: BLE001
2230
2157
  failures.append(
2231
- "final-report data.json: design-surface detection failed on "
2232
- f"malformed input: {exc}"
2158
+ "final-report data.json: deterministic plan-item extraction failed: "
2159
+ f"{exc}"
2233
2160
  )
2234
2161
  return
2235
- actual_prep_ids = [
2236
- item_id for item_id in extracted_ids if item_id.startswith("P-PREP-")
2237
- ]
2238
- missing_prep_ids = [
2239
- expected_prep_ids[item_id]
2240
- for item_id in sorted(set(expected_prep_ids) - set(actual_prep_ids))
2162
+
2163
+ actual_sequence = [
2164
+ str(item.get("id") or "").strip()
2165
+ for item in (pbv.get("planItems") or [])
2166
+ if isinstance(item, dict)
2241
2167
  ]
2242
- if missing_prep_ids:
2168
+ expected_ids = set(expected_sequence)
2169
+ actual_ids = set(actual_sequence)
2170
+ missing = expected_ids - actual_ids
2171
+ unexpected = actual_ids - expected_ids
2172
+ duplicate_ids = {
2173
+ item_id for item_id in actual_ids if actual_sequence.count(item_id) > 1
2174
+ }
2175
+
2176
+ if missing:
2243
2177
  failures.append(
2244
2178
  "final-report data.json: planBodyVerification.planItems is missing "
2245
- "detector-required design-prep verdict item(s): "
2246
- + ", ".join(missing_prep_ids)
2179
+ "deterministically extracted verdict item ID(s): "
2180
+ + ", ".join(sorted(missing))
2247
2181
  )
2248
- unexpected_prep_ids = sorted(set(actual_prep_ids) - set(expected_prep_ids))
2249
- if unexpected_prep_ids:
2182
+ if unexpected:
2250
2183
  failures.append(
2251
2184
  "final-report data.json: planBodyVerification.planItems contains "
2252
- "unexpected design-prep verdict item(s) not produced by the "
2253
- "detector: "
2254
- + ", ".join(unexpected_prep_ids)
2255
- )
2256
- duplicate_prep_ids = sorted(
2257
- item_id
2258
- for item_id in set(actual_prep_ids)
2259
- if actual_prep_ids.count(item_id) > 1
2260
- )
2261
- if duplicate_prep_ids:
2185
+ "unexpected verdict item ID(s): "
2186
+ + ", ".join(sorted(unexpected))
2187
+ )
2188
+ if duplicate_ids:
2262
2189
  failures.append(
2263
2190
  "final-report data.json: planBodyVerification.planItems contains "
2264
- "duplicate detector-required design-prep verdict item(s): "
2265
- + ", ".join(duplicate_prep_ids)
2191
+ "duplicate verdict item ID(s): "
2192
+ + ", ".join(sorted(duplicate_ids))
2266
2193
  )
2267
2194
 
2268
2195
 
@@ -2826,6 +2753,113 @@ def _validate_disagree_has_fixability(data: dict, failures: list[str]) -> None:
2826
2753
  _COVERED_BY_ANCHOR_RE = re.compile(r"option|stage|step", re.IGNORECASE)
2827
2754
  _COVERED_BY_STAGE_REF_RE = re.compile(r"stage\s*(\d+)", re.IGNORECASE)
2828
2755
  _COVERED_BY_VAGUE = {"recommended option", "the recommended option", "recommended"}
2756
+ _DEVIATION_DECISION_REF_RE = re.compile(r"^(C-\d{3,}|D-\d{4,})$")
2757
+ _DEVIATION_BLOCKED_DISPOSITION_RE = re.compile(r"^blocked (C-\d{3,})$")
2758
+
2759
+
2760
+ def _resolved_deviation_refs(
2761
+ row_id: str,
2762
+ refs: object,
2763
+ clarifications: dict,
2764
+ decisions: dict,
2765
+ failures: list[str],
2766
+ ) -> list[str]:
2767
+ valid_refs: list[str] = []
2768
+ for ref in refs if isinstance(refs, list) else []:
2769
+ if not isinstance(ref, str) or not _DEVIATION_DECISION_REF_RE.fullmatch(ref):
2770
+ failures.append(
2771
+ f"final-report data.json: requirementCoverage `{row_id}` has "
2772
+ f"unsupported decisionRef `{ref}`; expected C-NNN or D-NNNN."
2773
+ )
2774
+ continue
2775
+ target = (
2776
+ clarifications.get(ref)
2777
+ if ref.startswith("C-")
2778
+ else decisions.get(ref)
2779
+ )
2780
+ if target is None:
2781
+ failures.append(
2782
+ f"final-report data.json: requirementCoverage `{row_id}` "
2783
+ f"decisionRef `{ref}` does not exist in this report."
2784
+ )
2785
+ continue
2786
+ valid_refs.append(ref)
2787
+ return valid_refs
2788
+
2789
+
2790
+ def _validate_deviation_disposition(
2791
+ row_id: str,
2792
+ disposition: object,
2793
+ refs: list[str],
2794
+ clarifications: dict,
2795
+ failures: list[str],
2796
+ ) -> None:
2797
+ if disposition == "accepted":
2798
+ confirmed = any(
2799
+ ref.startswith("C-")
2800
+ and clarifications[ref].get("status") in {"answered", "resolved"}
2801
+ and bool(str(clarifications[ref].get("userInput") or "").strip())
2802
+ for ref in refs
2803
+ )
2804
+ if not confirmed:
2805
+ failures.append(
2806
+ f"final-report data.json: requirementCoverage `{row_id}` is "
2807
+ "documented-deviation with approvalDisposition `accepted`, but "
2808
+ "none of its decisionRefs is a user-confirmed clarification "
2809
+ "(`status` answered/resolved with non-empty `userInput`)."
2810
+ )
2811
+ return
2812
+ blocked = (
2813
+ _DEVIATION_BLOCKED_DISPOSITION_RE.fullmatch(disposition)
2814
+ if isinstance(disposition, str)
2815
+ else None
2816
+ )
2817
+ if blocked is None:
2818
+ return
2819
+ clarification_id = blocked.group(1)
2820
+ clarification = clarifications.get(clarification_id)
2821
+ if clarification is None:
2822
+ failures.append(
2823
+ f"final-report data.json: requirementCoverage `{row_id}` "
2824
+ f"approvalDisposition references `{clarification_id}`, which does "
2825
+ "not exist in this report."
2826
+ )
2827
+ elif (
2828
+ clarification.get("status") != "open"
2829
+ or clarification.get("blocks") != "approval"
2830
+ ):
2831
+ failures.append(
2832
+ f"final-report data.json: requirementCoverage `{row_id}` "
2833
+ f"approvalDisposition `{disposition}` must point to an open "
2834
+ "clarification with `blocks: approval`."
2835
+ )
2836
+
2837
+
2838
+ def _validate_requirement_deviations(data: dict, failures: list[str]) -> None:
2839
+ """Require documented deviations to reference real decisions and approval."""
2840
+ planning = data.get("implementationPlanning")
2841
+ if not isinstance(planning, dict):
2842
+ return
2843
+ clarifications = {
2844
+ row.get("id"): row
2845
+ for row in (data.get("clarificationItems") or [])
2846
+ if isinstance(row, dict) and row.get("id")
2847
+ }
2848
+ decisions = {
2849
+ f"D-{row.get('number')}": row
2850
+ for row in (planning.get("decisionDrafts") or [])
2851
+ if isinstance(row, dict) and row.get("number")
2852
+ }
2853
+ for row in planning.get("requirementCoverage") or []:
2854
+ if not isinstance(row, dict) or row.get("status") != "documented-deviation":
2855
+ continue
2856
+ row_id = row.get("id") or "<row>"
2857
+ refs = _resolved_deviation_refs(
2858
+ row_id, row.get("decisionRefs"), clarifications, decisions, failures
2859
+ )
2860
+ _validate_deviation_disposition(
2861
+ row_id, row.get("approvalDisposition"), refs, clarifications, failures
2862
+ )
2829
2863
 
2830
2864
 
2831
2865
  def _validate_requirement_coverage_covered_by(data: dict, failures: list[str]) -> None:
@@ -2962,11 +2996,12 @@ def _cited_stage_numbers(covered_by: str) -> set[int]:
2962
2996
 
2963
2997
  # Statuses under which a row asserts the plan does work for the requirement.
2964
2998
  # `gap` is excluded: it states the plan does NOT cover the requirement, so it
2965
- # can justify no stage. `blocked C-NNN` is included the requirement is real
2966
- # and the stage serving it is not invented scope, only pending a user answer,
2967
- # and such a row already forces a non-passing gate and `approved: false`, so
2968
- # orphaning its stage here would only push planners to delete legitimate work.
2969
- _COVERAGE_CLAIMING_STATUS_RE = re.compile(r"^(covered|blocked C-\d{3,})$")
2999
+ # can justify no stage. `blocked C-NNN` and `documented-deviation` are included:
3000
+ # each records a real requirement with a planned treatment, even when the
3001
+ # treatment awaits approval or deliberately differs from the original request.
3002
+ _COVERAGE_CLAIMING_STATUS_RE = re.compile(
3003
+ r"^(covered|blocked C-\d{3,}|documented-deviation)$"
3004
+ )
2970
3005
 
2971
3006
 
2972
3007
  def _validate_stage_has_requirement(data: dict, failures: list[str]) -> None:
@@ -3432,6 +3467,7 @@ _CONVERGENCE_INTERMEDIATE_PREFIXES = (
3432
3467
  "convergence-groups-",
3433
3468
  "convergence-work-",
3434
3469
  "convergence-round-",
3470
+ "convergence-critic-",
3435
3471
  )
3436
3472
 
3437
3473
 
@@ -25,6 +25,7 @@ import sys
25
25
  from dataclasses import dataclass, field
26
26
  from datetime import datetime
27
27
  from pathlib import Path
28
+ from typing import Any, Mapping
28
29
 
29
30
  # scripts/ (repo) and python/ (installed under ~/.okstra/lib) are not packages;
30
31
  # insert whichever exists so okstra_ctl is importable directly.
@@ -37,10 +38,21 @@ from okstra_ctl.worker_heartbeat import ( # noqa: E402
37
38
  HEARTBEAT_LINE_RE,
38
39
  HEARTBEAT_MAX_GAP_SECONDS,
39
40
  )
41
+ from okstra_ctl.wrapper_status import read_wrapper_status # noqa: E402
40
42
 
41
43
  _DISPATCHED_STATUSES = {"completed", "timeout", "error", "in-progress"}
42
44
  _WORKER_DISPATCH_MODES = {"cli-wrapper", "mixed", "tmux-pane"}
43
45
  _ATTEMPTED_STATUSES = {"completed", "timeout", "error"}
46
+ _LIVENESS_AUDIT_HEARTBEAT = "audit-heartbeat"
47
+ _LIVENESS_WRAPPER_STATUS = "wrapper-status"
48
+ _REPORT_WRITER_HEARTBEAT_STAGES = {
49
+ "started",
50
+ "required-reading-complete",
51
+ "synthesis-start",
52
+ "data-json-write-start",
53
+ "render-start",
54
+ "write-result-start",
55
+ }
44
56
 
45
57
  # lead 의 체크포인트 라인 — assistant text 블록 안에서 line-anchored 로만 인정.
46
58
  # lead 는 계약상 raw 로 emit 해야 하지만 실측(dev-9902)에서 `` `PROGRESS: ...` ``
@@ -507,18 +519,21 @@ def _parse_iso(ts: str) -> datetime | None:
507
519
  return None
508
520
 
509
521
 
510
- def _check_heartbeat_sidecar(path: Path, errors: list[str]) -> None:
522
+ def _check_heartbeat_sidecar(path: Path, worker_id: str, errors: list[str]) -> None:
511
523
  rel = path.name
524
+ if not path.is_file():
525
+ errors.append(f"registered audit sidecar missing: {path}")
526
+ return
512
527
  try:
513
528
  content = path.read_text(encoding="utf-8")
514
529
  except OSError as exc:
515
- errors.append(f"claude-worker audit sidecar unreadable: {rel} ({exc})")
530
+ errors.append(f"worker audit sidecar unreadable: {rel} ({exc})")
516
531
  return
517
532
  entries = [(m.group("stage"), m.group("ts")) for m in _HEARTBEAT_LINE_RE.finditer(content)]
518
533
  if not entries:
519
534
  errors.append(
520
535
  f"`{rel}` has no `- PROGRESS: <stage> <ISO-8601-UTC>` heartbeat lines — "
521
- "the claude-worker MUST write `started` immediately and append one "
536
+ "the in-process worker MUST write `started` immediately and append one "
522
537
  "line per stage at <= 5-minute cadence (agents/workers/claude-worker.md "
523
538
  "'Heartbeat', prompts/lead/okstra-lead-contract.md Common Mistakes)."
524
539
  )
@@ -529,6 +544,12 @@ def _check_heartbeat_sidecar(path: Path, errors: list[str]) -> None:
529
544
  f"(found `{entries[0][0]}`) — the sidecar is written BEFORE the "
530
545
  "per-file reads, with a `- PROGRESS: started <ISO>` line."
531
546
  )
547
+ if worker_id == "report-writer":
548
+ for stage, _raw_ts in entries:
549
+ if stage not in _REPORT_WRITER_HEARTBEAT_STAGES:
550
+ errors.append(
551
+ f"`{rel}`: heartbeat stage `{stage}` is not allowed for report-writer."
552
+ )
532
553
  # result 파일이 존재하면 마지막 단계 마커도 있어야 한다. timeout 으로 중단된
533
554
  # worker(result 없음)에는 요구하지 않는다 — hang 이전 구간의 cadence 만 본다.
534
555
  result_file = path.with_name(rel.replace("-audit-", "-"))
@@ -562,27 +583,49 @@ def _check_heartbeat_sidecar(path: Path, errors: list[str]) -> None:
562
583
  prev = ts
563
584
 
564
585
 
565
- def _check_heartbeat_sidecars(
566
- run_dir: Path, task_type: str, suffix: str | None, errors: list[str]
586
+ def _check_worker_liveness(
587
+ project_root: Path, team_state: Mapping[str, Any], errors: list[str]
567
588
  ) -> None:
568
- """검사 2 사이드카 **존재** validate-run.py validate_worker_results_audit
569
- 이미 강제하므로, 여기서는 존재하는 사이드카의 heartbeat 내용만 본다.
570
-
571
- 비-stage 격리 task-type(requirements-discovery / error-analysis /
572
- implementation-planning)은 `runs/<task-type>/worker-results/` 를 run 끼리
573
- 공유한다 — seq 는 파일명에만 있다. seq 를 무시하고 glob 하면 이미 폐기된
574
- 직전 run 의 audit(heartbeat 위반)까지 검사해 현재 run 을 오탐 실패시킨다.
575
- 다른 검사(progress / sidecar-read)와 동일하게 현재 run seq(suffix)로 스코핑한다."""
576
- worker_results_dir = run_dir / "worker-results"
577
- if not worker_results_dir.is_dir():
589
+ """Validate the liveness artifact selected by each dispatch record."""
590
+ dispatches = team_state.get("workerDispatches")
591
+ if not isinstance(dispatches, list):
578
592
  return
579
- pattern = (
580
- f"claude-worker-audit-{suffix}.md"
581
- if suffix
582
- else f"claude-worker-audit-{task_type}-*.md"
583
- )
584
- for path in sorted(worker_results_dir.glob(pattern)):
585
- _check_heartbeat_sidecar(path, errors)
593
+ for record in dispatches:
594
+ if not isinstance(record, Mapping):
595
+ continue
596
+ mode = record.get("livenessMode")
597
+ worker_id = record.get("workerId")
598
+ if not isinstance(worker_id, str) or not worker_id:
599
+ continue
600
+ if mode not in (_LIVENESS_AUDIT_HEARTBEAT, _LIVENESS_WRAPPER_STATUS):
601
+ errors.append(
602
+ f"worker `{worker_id}` dispatch has missing or unknown livenessMode: {mode!r}."
603
+ )
604
+ continue
605
+ if mode == _LIVENESS_AUDIT_HEARTBEAT:
606
+ raw_path = record.get("auditSidecarPath")
607
+ if not isinstance(raw_path, str) or not raw_path:
608
+ errors.append(
609
+ f"worker `{worker_id}` audit-heartbeat dispatch has no registered audit sidecar path."
610
+ )
611
+ continue
612
+ path = Path(raw_path)
613
+ if not path.is_absolute():
614
+ path = project_root / path
615
+ _check_heartbeat_sidecar(path, worker_id, errors)
616
+ elif mode == _LIVENESS_WRAPPER_STATUS:
617
+ raw_path = record.get("statusSidecarPath")
618
+ if not isinstance(raw_path, str) or not raw_path:
619
+ errors.append(
620
+ f"worker `{worker_id}` wrapper-status dispatch has no registered status sidecar path."
621
+ )
622
+ continue
623
+ path = Path(raw_path)
624
+ if not path.is_absolute():
625
+ path = project_root / path
626
+ status = read_wrapper_status(path)
627
+ if status is None:
628
+ errors.append(f"registered wrapper status sidecar unreadable: {path}")
586
629
 
587
630
 
588
631
  def _check_implementation_sidecar_reads(evidence: _LeadEvidence, errors: list[str]) -> None:
@@ -642,7 +685,7 @@ def validate_session_conformance(
642
685
 
643
686
  run_dir = report_path.parent.parent
644
687
  suffix = run_artifact_suffix(team_state_path)
645
- _check_heartbeat_sidecars(run_dir, task_type, suffix, result.errors)
688
+ _check_worker_liveness(project_root, team_state, result.errors)
646
689
 
647
690
  evidence_source, source_error = _conformance_evidence_source(team_state)
648
691
  if source_error: