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.
- package/docs/architecture.md +7 -1
- package/docs/cli.md +10 -2
- package/docs/project-structure-overview.md +16 -9
- package/docs/task-process/implementation-planning.md +11 -5
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/agents/workers/antigravity-worker.md +3 -1
- package/runtime/agents/workers/claude-worker.md +1 -1
- package/runtime/agents/workers/codex-worker.md +3 -1
- package/runtime/agents/workers/report-writer-worker.md +8 -4
- package/runtime/prompts/lead/adapters/claude-code.md +2 -2
- package/runtime/prompts/lead/convergence.md +44 -21
- package/runtime/prompts/lead/okstra-lead-contract.md +9 -2
- package/runtime/prompts/lead/plan-body-verification.md +55 -12
- package/runtime/prompts/lead/report-writer.md +16 -15
- package/runtime/prompts/lead/team-contract.md +8 -6
- package/runtime/prompts/profiles/implementation-planning.md +10 -2
- package/runtime/python/okstra_ctl/codex_dispatch.py +5 -1
- package/runtime/python/okstra_ctl/convergence.py +121 -1
- package/runtime/python/okstra_ctl/convergence_engine.py +903 -13
- package/runtime/python/okstra_ctl/convergence_migration.py +9 -1
- package/runtime/python/okstra_ctl/dispatch_core.py +13 -0
- package/runtime/python/okstra_ctl/plan_items.py +203 -0
- package/runtime/python/okstra_ctl/plan_items_cli.py +81 -0
- package/runtime/python/okstra_ctl/report_finalize.py +6 -4
- package/runtime/python/okstra_ctl/worker_artifact_paths.py +23 -0
- package/runtime/python/okstra_ctl/worker_liveness.py +4 -4
- package/runtime/python/okstra_ctl/worker_prompt_body.py +1 -1
- package/runtime/python/okstra_ctl/worker_prompt_contract.py +2 -1
- package/runtime/python/okstra_ctl/worker_prompt_headers.py +14 -0
- package/runtime/schemas/convergence-critic-results-v1.0.schema.json +57 -0
- package/runtime/schemas/convergence-groups-v1.0.schema.json +109 -0
- package/runtime/schemas/convergence-round-results-v1.0.schema.json +55 -0
- package/runtime/schemas/final-report-v1.0.schema.json +21 -2
- package/runtime/templates/report-writer-prompt-preamble.md +5 -3
- package/runtime/templates/reports/final-report.template.md +1 -1
- package/runtime/templates/worker-prompt-preamble.md +8 -7
- package/runtime/validators/validate-run.py +154 -118
- package/runtime/validators/validate_session_conformance.py +66 -23
- package/src/cli-registry.mjs +7 -0
- package/src/commands/execute/convergence.mjs +6 -1
- package/src/commands/execute/plan-items.mjs +9 -0
- 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
|
|
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 `**
|
|
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
|
|
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.
|
|
34
|
-
5.
|
|
35
|
-
6. `**Worker
|
|
36
|
-
7. `**
|
|
37
|
-
8. `**Errors
|
|
38
|
-
9. `**
|
|
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(
|
|
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`,
|
|
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
|
-
"""
|
|
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
|
-
|
|
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:
|
|
2232
|
-
f"
|
|
2158
|
+
"final-report data.json: deterministic plan-item extraction failed: "
|
|
2159
|
+
f"{exc}"
|
|
2233
2160
|
)
|
|
2234
2161
|
return
|
|
2235
|
-
|
|
2236
|
-
|
|
2237
|
-
|
|
2238
|
-
|
|
2239
|
-
|
|
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
|
-
|
|
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
|
-
"
|
|
2246
|
-
+ ", ".join(
|
|
2179
|
+
"deterministically extracted verdict item ID(s): "
|
|
2180
|
+
+ ", ".join(sorted(missing))
|
|
2247
2181
|
)
|
|
2248
|
-
|
|
2249
|
-
if unexpected_prep_ids:
|
|
2182
|
+
if unexpected:
|
|
2250
2183
|
failures.append(
|
|
2251
2184
|
"final-report data.json: planBodyVerification.planItems contains "
|
|
2252
|
-
"unexpected
|
|
2253
|
-
"
|
|
2254
|
-
|
|
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
|
|
2265
|
-
+ ", ".join(
|
|
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`
|
|
2966
|
-
#
|
|
2967
|
-
#
|
|
2968
|
-
|
|
2969
|
-
|
|
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"
|
|
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
|
|
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
|
|
566
|
-
|
|
586
|
+
def _check_worker_liveness(
|
|
587
|
+
project_root: Path, team_state: Mapping[str, Any], errors: list[str]
|
|
567
588
|
) -> None:
|
|
568
|
-
"""
|
|
569
|
-
|
|
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
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
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
|
-
|
|
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:
|