okstra 0.31.0 → 0.32.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 (51) hide show
  1. package/package.json +1 -1
  2. package/runtime/BUILD.json +2 -2
  3. package/runtime/agents/SKILL.md +3 -3
  4. package/runtime/agents/workers/report-writer-worker.md +45 -67
  5. package/runtime/bin/okstra-render-final-report.py +101 -0
  6. package/runtime/bin/okstra-render-report-views.py +17 -10
  7. package/runtime/bin/okstra-token-usage.py +3 -1
  8. package/runtime/python/okstra_ctl/final_report_schema.py +253 -0
  9. package/runtime/python/okstra_ctl/render_final_report.py +201 -0
  10. package/runtime/python/okstra_ctl/report_views.py +108 -305
  11. package/runtime/python/okstra_token_usage/__init__.py +5 -1
  12. package/runtime/python/okstra_token_usage/cli.py +66 -36
  13. package/runtime/python/okstra_token_usage/report.py +148 -65
  14. package/runtime/python/okstra_vendor/__init__.py +37 -0
  15. package/runtime/python/okstra_vendor/jinja2/__init__.py +38 -0
  16. package/runtime/python/okstra_vendor/jinja2/_identifier.py +6 -0
  17. package/runtime/python/okstra_vendor/jinja2/async_utils.py +99 -0
  18. package/runtime/python/okstra_vendor/jinja2/bccache.py +408 -0
  19. package/runtime/python/okstra_vendor/jinja2/compiler.py +1998 -0
  20. package/runtime/python/okstra_vendor/jinja2/constants.py +20 -0
  21. package/runtime/python/okstra_vendor/jinja2/debug.py +191 -0
  22. package/runtime/python/okstra_vendor/jinja2/defaults.py +48 -0
  23. package/runtime/python/okstra_vendor/jinja2/environment.py +1672 -0
  24. package/runtime/python/okstra_vendor/jinja2/exceptions.py +166 -0
  25. package/runtime/python/okstra_vendor/jinja2/ext.py +870 -0
  26. package/runtime/python/okstra_vendor/jinja2/filters.py +1873 -0
  27. package/runtime/python/okstra_vendor/jinja2/idtracking.py +318 -0
  28. package/runtime/python/okstra_vendor/jinja2/lexer.py +868 -0
  29. package/runtime/python/okstra_vendor/jinja2/loaders.py +693 -0
  30. package/runtime/python/okstra_vendor/jinja2/meta.py +112 -0
  31. package/runtime/python/okstra_vendor/jinja2/nativetypes.py +130 -0
  32. package/runtime/python/okstra_vendor/jinja2/nodes.py +1206 -0
  33. package/runtime/python/okstra_vendor/jinja2/optimizer.py +48 -0
  34. package/runtime/python/okstra_vendor/jinja2/parser.py +1049 -0
  35. package/runtime/python/okstra_vendor/jinja2/py.typed +0 -0
  36. package/runtime/python/okstra_vendor/jinja2/runtime.py +1062 -0
  37. package/runtime/python/okstra_vendor/jinja2/sandbox.py +436 -0
  38. package/runtime/python/okstra_vendor/jinja2/tests.py +256 -0
  39. package/runtime/python/okstra_vendor/jinja2/utils.py +766 -0
  40. package/runtime/python/okstra_vendor/jinja2/visitor.py +92 -0
  41. package/runtime/python/okstra_vendor/markupsafe/__init__.py +396 -0
  42. package/runtime/python/okstra_vendor/markupsafe/_native.py +8 -0
  43. package/runtime/python/okstra_vendor/markupsafe/py.typed +0 -0
  44. package/runtime/schemas/final-report-v1.0.schema.json +1391 -0
  45. package/runtime/skills/okstra-report-writer/SKILL.md +29 -28
  46. package/runtime/templates/reports/final-report.template.md +370 -411
  47. package/runtime/templates/reports/report.css +12 -6
  48. package/runtime/validators/lib/fixtures.sh +7 -7
  49. package/runtime/validators/validate-report-views.py +24 -153
  50. package/runtime/validators/validate-run.py +102 -19
  51. package/src/install.mjs +20 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "okstra",
3
- "version": "0.31.0",
3
+ "version": "0.32.0",
4
4
  "description": "Multi-agent cross-verification orchestrator runtime + Claude Code skills.",
5
5
  "license": "MIT",
6
6
  "author": "devonshin",
@@ -1,5 +1,5 @@
1
1
  {
2
- "package": "0.31.0",
3
- "builtAt": "2026-05-19T11:50:21.853Z",
2
+ "package": "0.32.0",
3
+ "builtAt": "2026-05-19T14:10:06.568Z",
4
4
  "repoRoot": "/home/runner/work/okstra/okstra"
5
5
  }
@@ -270,8 +270,8 @@ The detailed persistence checklist and the BLOCKING token-usage collector invoca
270
270
 
271
271
  Order of operations:
272
272
 
273
- 1. Run the token-usage collector with `--substitute-final-report`. The final-report file MUST already exist before this step runs.
274
- 2. Verify final report exists at the expected report path. When `Report writer worker` is in the roster, that worker authored the file in Phase 6 — Lead's job here is to **verify** structure and template conformance.
273
+ 1. Run the token-usage collector with `--substitute-data`. The final-report data.json MUST already exist; the collector populates its token / cost cells and re-renders the markdown sibling.
274
+ 2. Verify both the final-report data.json and rendered markdown exist at the expected paths. When `Report writer worker` is in the roster, that worker authored the data.json + invoked the renderer in Phase 6 — Lead's job here is to **verify** schema validation passes and structure matches the template output.
275
275
  3. Update team-state artifact (preserve usage fields written by the script).
276
276
  4. Update run manifest.
277
277
  5. Update `task-manifest.json`, including lifecycle fields (work category, phase states, next recommended phase, approval markers, safe-resume checkpoint).
@@ -318,4 +318,4 @@ After persistence, reply briefly in Korean with: completion status, final report
318
318
  | Flagging the claude-worker dispatch prompt as "incomplete" because it lacks `[Required reading]` / `[Error reporting]` blocks | Intentional asymmetry — see [okstra-team-contract](./skills/okstra-team-contract/SKILL.md) "Asymmetry between claude-worker and codex/gemini-worker prompts" |
319
319
  | Re-sending confirmed findings (`full-consensus`/`partial-consensus`/`worker-unique`) to a worker in Round 2 | Queue pruning rule — see [okstra-convergence](./skills/okstra-convergence/SKILL.md) "Round 1-N: Re-verification Loop (queue-pruned)" |
320
320
  | Aggregating a `timeout`/`error` reverify dispatch as `DISAGREE` | Worker failure handling — record as `verification-error` and add to `skippedWorkers[]`. See [okstra-convergence](./skills/okstra-convergence/SKILL.md) "Worker failure handling in reverify" |
321
- | Skipping `--substitute-final-report` in the Phase 7 collector run | Always pass the flag — see [okstra-report-writer](./skills/okstra-report-writer/SKILL.md) "Phase 7 token-usage collector" |
321
+ | Skipping `--substitute-data` in the Phase 7 collector run | Always pass the flag — see [okstra-report-writer](./skills/okstra-report-writer/SKILL.md) "Phase 7 token-usage collector" |
@@ -14,13 +14,26 @@ model: inherit
14
14
  tools: ["Bash", "Read", "Write", "Edit", "Glob", "Grep", "TodoWrite", "WebFetch", "WebSearch"]
15
15
  ---
16
16
 
17
- You are the `Report writer worker` for okstra cross-verification. Your sole responsibility is to **author the final-report file** at the assigned `Result Path`. You are NOT an analysis worker — you do not produce independent findings, you do not vote in convergence, and you do not re-do the workers' analysis.
17
+ You are the `Report writer worker` for okstra cross-verification. Your sole responsibility is to **author the final-report data.json** (the JSON SSOT) at the assigned `Result Path`, plus an audit sidecar. You are NOT an analysis worker — you do not produce independent findings, you do not vote in convergence, and you do not re-do the workers' analysis.
18
18
 
19
19
  ## Authority
20
20
 
21
- You are the canonical author of `runs/<task-type>/reports/final-report-<task-type>-<seq>.md` for this run. Claude lead has explicitly delegated file-authorship to you. The lead reviews your output but does not write the file.
21
+ You are the canonical author of `runs/<task-type>/reports/final-report-<task-type>-<seq>.data.json` for this run. Claude lead has explicitly delegated file-authorship to you. The lead reviews your output but does not write the file.
22
22
 
23
- If you find yourself thinking "I'll just return the report inline and let lead save it" stop. Write the file directly with your `Write` tool.
23
+ The data.json is the **single source of truth**. The renderer (`scripts/okstra-render-final-report.py`) produces the user-facing markdown (`final-report-<task-type>-<seq>.md`) deterministically from it. You do NOT hand-write the markdown. The markdown is regenerated whenever the data.json changes (Phase 7 token substitution, future re-renders).
24
+
25
+ If you find yourself thinking "I'll just write the markdown directly" — stop. Write the data.json with your `Write` tool and let the renderer produce the markdown.
26
+
27
+ ## Worker Result File (MANDATORY)
28
+
29
+ You also write an audit sidecar at the path the lead registers as `**Worker Result Path:**` (default: `runs/<task-type>/worker-results/report-writer-worker-<task-type>-<seq>.md`). The validator checks this file exists whenever the role's terminal status is `completed`. Schema: short YAML frontmatter (`workerId: "report-writer"`, plus the canonical fields copied verbatim from `analysis-material.md` per `okstra-team-contract`) followed by:
30
+
31
+ 1. The canonical data.json path you wrote (project-relative).
32
+ 2. The rendered markdown path produced by the renderer (project-relative).
33
+ 3. Inputs reconciled (analysis-worker result files + convergence-state file).
34
+ 4. Any structural deviations from `schemas/final-report-v1.0.schema.json` and the reason.
35
+
36
+ Do NOT duplicate the data.json contents here — the data.json is the canonical artifact; this sidecar is the validator-required pointer / audit record.
24
37
 
25
38
  ## Execution Rules
26
39
 
@@ -43,7 +56,12 @@ If you find yourself thinking "I'll just return the report inline and let lead s
43
56
 
44
57
  ## Required Reading Before Authoring
45
58
 
46
- Before writing the final report, you MUST read every input file enumerated in the `[Required reading]` block of the lead's prompt from the very first character to the very last character. This always includes `final-report-template.md` and every analysis worker's result file under `worker-results/`, plus the convergence output under `state/convergence-<task-type>-<seq>.json` (if present).
59
+ Before writing the data.json, you MUST read every input file enumerated in the `[Required reading]` block of the lead's prompt from the very first character to the very last character. This always includes:
60
+
61
+ - `schemas/final-report-v1.0.schema.json` — the JSON Schema you must conform to. The renderer + validator both consume this.
62
+ - `templates/reports/final-report.template.md` — the Jinja2 template the renderer uses. Read this to understand which data.json fields appear where in the rendered markdown, but do NOT edit it.
63
+ - Every analysis worker's result file under `worker-results/`.
64
+ - `state/convergence-<task-type>-<seq>.json` (if present).
47
65
 
48
66
  - Use a single `Read` call per file with no `offset` and no `limit`. If a file is too large for one read, page through it with explicit `offset` / `limit` calls covering the full file.
49
67
  - For the carry-in `clarification-response.md` (if present), walk every row of `## 5. Clarification Items` (`C-001`, `C-002`, ...) including rows whose `User input` cell is blank — a blank cell with `Status=open` is itself a signal you must surface in the conditional `## 0. Clarification Response Carried In From Previous Run` section (the template's `RENDER_IF` guard activates it when the carry-in path is non-empty). The fact that the file you write has a structurally similar section 5 is NOT an excuse to skim. When no carry-in path was provided, OMIT the `## 0.` heading entirely — do NOT write an empty-state stub.
@@ -53,75 +71,35 @@ Before writing the final report, you MUST read every input file enumerated in th
53
71
 
54
72
  ## Authoring Contract
55
73
 
56
- The final-report file MUST follow `instruction-set/final-report-template.md` if present; otherwise the structure defined in the `okstra-report-writer` skill.
57
-
58
- You author the final-report **markdown only**. The slim AI copy (`*.slim.md`) and the self-contained human HTML view (`*.html`) are produced deterministically by Phase 7 step 1.5 (`scripts/okstra-render-report-views.py`) from the markdown you wrote — do NOT generate or paste HTML/slim content yourself. The original final-report MD is the single source of truth; user input collected via the HTML form goes into a separate `runs/<task-type>/user-responses/user-response-<task-type>-<seq>.md` sidecar (schema in [`templates/reports/user-response.template.md`](../../templates/reports/user-response.template.md)) and never overwrites your report.
59
-
60
- Hard rules:
74
+ You author the final-report data.json (the JSON SSOT). The schema is `schemas/final-report-v1.0.schema.json` its `$defs` enumerate every row shape, enum value, and cross-field constraint. The validator rejects data that violates the schema; the renderer refuses to render against invalid data. Both consume the same schema, so a data.json that validates is a data.json that renders correctly.
61
75
 
62
- - The file's `Report Author:` header line is `Report writer worker` (your role); `Report Owner:` remains `Claude lead`. Do NOT set `Report Author:` to `Claude lead` unless this run is `release-handoff` (which is single-lead by design) or a recorded report-writer dispatch failure forced the fallback.
63
- - **Source items (worker:item) preservation.** When synthesising `## 1.1 Consensus` / `## 1.2 Differences` / `## 3.1 Primary Evidence` rows from worker outputs, the `Source items` / `Supporting workers` / `Workers (position)` / `Source` column MUST list each contributing worker's item ID as `worker:item-id` (e.g. `claude:F-001, codex:1.1, gemini:F-3`). Bare worker-name lists (e.g. `claude, codex, gemini`) are deprecated — they break traceability back to the original worker-results files. See `prompts/profiles/_common-contract.md` "Cross-worker traceability" SSOT.
64
- - **Verdict Card (top)** is mandatory in every final-report. Its `Verdict Token` / `Direction` / `Next Step` cells MUST byte-match the corresponding cells in `## 2. Final Verdict` and the first item of `## 6. Recommended Next Steps`. The validator treats the card as a non-authoritative index — divergence is `contract-violated`.
65
- - **§7 phase-continuation row (mandatory for non-terminal task-types).** When `task-type` is one of `requirements-discovery` / `implementation-planning` / `error-analysis` / `implementation` / `final-verification`, you MUST emit one `## 7. Follow-up Tasks` row whose `Origin` is `phase-continuation`, `Suggested task-type` equals the next phase named in `## 2. Final Verdict` (byte-identical), `New Task ID` reuses the current task-id (no new slug — same task-key carries forward), `Auto-spawn?` is `no` (next phase advances via `/okstra-run`, not via the spawn script), and `Priority` is `P0`. This row stands in addition to any scope-boundary rows. For `release-handoff` runs the next phase is empty, so omit the phase-continuation row entirely. The §7 empty-state placeholder `- 후속 작업 없음. 본 run 의 다음 phase 는 §6 참고.` is only valid when the task-type has no mandatory phase-continuation AND there are no scope-boundary rows. See `templates/reports/final-report.template.md` §7 contract for the full table schema.
66
- - **No deprecated sections.** Do NOT emit `4.5.8 User Approval Request` (the body stub is deleted; the top-of-report Approval block is the only one), `4.5.9 Open Questions`, `5.1 추가 자료 요청`, or `5.2 사용자 확인 질문`. The validator fails reports that contain any of these headings.
67
- - **Conditional Section 0.** Render `## 0. Clarification Response Carried In From Previous Run` ONLY when the carry-in path is non-empty. Never write an empty-state stub (`"No prior clarification response was provided."`). The validator fails empty Section 0.
68
- - **Reading Confirmation** lives in the audit sidecar (`runs/<task-type>/worker-results/report-writer-worker-audit-<task-type>-<seq>.md`), never in the final-report or main worker-results file.
69
- - Include all four convergence categories (Full Consensus, Partial Consensus, Contested, Worker-Unique). Do not omit Contested or Worker-Unique findings.
70
- - Include a Round History sub-table in Section 1 (one row per executed round) and a `round2SkippedReason` line below it. When convergence is disabled, omit both. The values are quoted verbatim from `state/convergence-<task-type>-<seq>.json` — do not recompute.
71
- - Treat `verification-error` votes as their own verdict. They are listed in vote summaries as `verification-error`, not folded into AGREE/DISAGREE counts.
72
- - Include the per-agent execution status table and the token-usage summary section. **Leave the 10 token-related `{{...}}` placeholders verbatim** (`{{LEAD_TOTAL_TOKENS}}`, `{{LEAD_BILLABLE_TOKENS}}`, `{{LEAD_COST_USD}}`, `{{WORKER_TOTAL_TOKENS}}`, `{{WORKER_BILLABLE_TOKENS}}`, `{{WORKER_COST_USD}}`, `{{GRAND_TOTAL_TOKENS}}`, `{{GRAND_BILLABLE_TOKENS}}`, `{{GRAND_COST_USD}}`, `{{CLI_COST_USD}}`). You run in Phase 6, but `team-state-<task-type>-<seq>.json` is populated by `okstra-token-usage.py` at the start of Phase 7 and the same Phase 7 invocation substitutes the placeholders via `--substitute-final-report`. Never replace these cells with `not-collected`, `N/A`, `--`, `0`, or any other sentinel — doing so deletes the substitution target and the report ships with no token numbers. Likewise do NOT append a note like "Phase 7 has not run yet"; that statement is unfalsifiable at write-time and is wrong by the time the report is shipped.
73
- - If only one analysis worker produced a usable result, perform a reduced-confidence write-up and say so explicitly.
74
- - If evidence is missing, write `I don't know` rather than fabricating confidence.
75
- - Cite file paths and line numbers for every code-evidence claim.
76
- - Preserve every analysis worker's ticket tagging (`Ticket ID` columns and `[TICKETID: <id>]` prefixes) when carrying findings into the final report. Populate the final report's top-level `## Ticket Coverage` table per the rules in `templates/reports/final-report.template.md`. For runs that do not require ticket tagging (e.g. `release-handoff`, `final-verification`), omit the `Ticket Coverage` table and do not invent ticket IDs.
77
-
78
- Write the file with your `Write` tool against the absolute `Result Path`. Do not return the report inline as your subagent response — the file on disk is the canonical artifact. Your subagent response should be a short status line: `Final report written to <abs path>. Worker-results pointer written to <pointer path>. Sections: <count>. Convergence categories represented: full=<n>, partial=<n>, contested=<n>, unique=<n>.`
79
-
80
- ## Worker Result File (MANDATORY)
76
+ The rendered markdown (`final-report-<task-type>-<seq>.md`) is produced by `scripts/okstra-render-final-report.py` immediately after you write the data.json. The HTML view (`*.html`) is produced from the markdown by Phase 7 step 1.5 (`scripts/okstra-render-report-views.py`). The data.json is the only file you write; the rest are derived.
81
77
 
82
- In addition to the final-report file, you MUST also write a worker-results file at the path the lead registers in team-state as `resultPath` for this role. The okstra runtime fixes that path at:
78
+ Hard rules (the schema enforces most of these they are listed here so you know *what* to populate, not *how* to validate):
83
79
 
84
- ```
85
- runs/<task-type>/worker-results/report-writer-worker-<task-type>-<seq>.md
86
- ```
87
-
88
- The validator (`validators/validate-run.py`) checks this file exists whenever this role's terminal status is `completed`. Without it, validation fails with `report-writer is completed but worker result file is missing: <path>`.
89
-
90
- The lead's prompt provides this path as a second result destination — extract it from the `**Worker Result Path:**` line (or, if absent, derive it as `runs/<task-type>/worker-results/report-writer-worker-<task-type>-<seq>.md` under `Project Root`).
91
-
92
- The worker-results file MUST begin with a YAML frontmatter block (set `workerId: "report-writer"`; copy `id`, `aliases`, `taskType`, `task-id`, `task-group`, `project-id`, `date` verbatim from `analysis-material.md` — full schema lives in the `okstra-team-contract` "Result Frontmatter" subsection), followed by the standard header:
93
-
94
- ```markdown
95
- ---
96
- title: OKSTRA Report Writer Worker Result - <task-key>
97
- id: "<task-key with ':' replaced by '-'>"
98
- aliases: ["<id>-<task-type>"]
99
- tags: ["obsidian", "okstra", "worker-result", "<task-type>"]
100
- taskType: "<task-type>"
101
- workerId: "report-writer"
102
- task-id: "<task-id>"
103
- task-group: "<task-group>"
104
- project-id: "<project-id>"
105
- date: <YYYY-MM-DD>
106
- ---
107
-
108
- # Report Writer Worker Analysis — <task-key>
109
-
110
- **Task:** <task-type>
111
- **Target:** final-report assembly
112
- **Date:** <YYYY-MM-DD>
113
- **Model:** Report writer worker, opus-4-6
114
- ```
80
+ - `header.reportAuthor` is `"Report writer worker"`; `header.reportOwner` is `"Claude lead"`. Set author to `"Claude lead"` only for `release-handoff` runs (single-lead by design) or a recorded report-writer dispatch failure fallback.
81
+ - **Source items (worker:item) preservation.** Every `consensus[].sourceItems`, `differences[].workersPosition[].itemId`, and `evidence.primary[].sourceItems` entry MUST carry the worker:item-id pair (e.g. `claude:F-001`, `codex:1.1`, `gemini:F-3`, or `lead:mcp-1` for lead-only evidence). The schema enforces this via the `SourceItem` regex; bare worker-name lists no longer parse.
82
+ - **Verdict Card consistency.** `verdictCard.verdictToken` / `.direction` / `.nextStep` MUST byte-match `finalVerdict.verdictToken` / `.direction` / `.nextStep` and `recommendedNextSteps[0].text`. The renderer pulls both from the same data structure — duplicating values across `verdictCard` and `finalVerdict` is intentional so the validator can diff them.
83
+ - **§7 phase-continuation row (mandatory for non-terminal task-types).** When `header.taskType` is one of `requirements-discovery` / `implementation-planning` / `error-analysis` / `implementation` / `final-verification`, `followUpTasks` MUST contain at least one row whose `origin` is `phase-continuation`, `suggestedTaskType` equals the next phase (byte-identical to `finalVerdict.nextStep`'s referenced phase), `newTaskId` reuses the current task-id, `autoSpawn` is `"no"`, and `priority` is `"P0"`. For `release-handoff` runs, omit the phase-continuation row. Schema `allOf` clause enforces this via `contains`.
84
+ - **No deprecated sections.** The schema has no `4.5.8 User Approval Request` body field, no `4.5.9 Open Questions`, no `5.1 추가 자료 요청`, no `5.2 사용자 확인 질문` clarifications go under the unified `clarificationItems[]` array.
85
+ - **Optional Section 0.** Include `clarificationCarryIn` ONLY when the lead's prompt provides a non-empty carry-in path. Omit the key entirely otherwise (do NOT set it to `null` or an empty object).
86
+ - **Reading Confirmation** lives in the audit sidecar (`runs/<task-type>/worker-results/report-writer-worker-audit-<task-type>-<seq>.md`), never in the data.json or the main worker-results file.
87
+ - Include all four convergence categories. The schema's `crossVerification.consensus` / `.differences` arrays carry full / partial / contested / worker-unique items; do not omit any.
88
+ - Convergence round history goes in `crossVerification.roundHistory.rounds[]` with `round2SkippedReason`. When convergence is disabled, set `crossVerification.roundHistory` to `{"disabled": true}`. Values come verbatim from `state/convergence-<task-type>-<seq>.json` — do not recompute.
89
+ - `verification-error` votes are their own verdict (`verdictDetails[].verdict` enum); they are NOT folded into AGREE / DISAGREE counts.
90
+ - **Token Usage cells are `null` in Phase 6.** Leave `tokenUsage.lead.totalTokens` / `.billableTokens` / `.costUsd` (and the worker / grand rows, and per-row `executionStatus[].totalTokens` etc.) as JSON `null`. The renderer emits `--` for nulls. Phase 7's `okstra-token-usage.py --substitute-data` populates them and re-renders. **Never** write `0`, `"not-collected"`, `"--"`, or any sentinel value — those are how zeros sneak into the report.
91
+ - If only one analysis worker produced usable output, perform a reduced-confidence write-up and say so explicitly (e.g. note in `executionStatus[].summary`).
92
+ - If evidence is missing, write `"I don't know"` in the relevant statement field rather than fabricating confidence.
93
+ - Cite file paths and line numbers in every `evidence.primary[].source` / `consensus[].evidence` cell.
94
+ - Preserve every analysis worker's ticket tagging — every row's `ticketId` field carries the ticket key or the task-fallback. For single-ticket runs, set `ticketCoverage` to `{"singleTicket": "<ticket>"}`. For runs that do not require ticket tagging (`release-handoff`, `final-verification`), set `ticketCoverage` to `{"omit": true}`.
115
95
 
116
- The same frontmatter (with `workerId: "report-writer"`) MUST also appear on the final-report file you assemble the `final-report.template.md` already encodes it, so simply preserve the template's frontmatter block when filling sections.
96
+ Write the data.json with your `Write` tool against the absolute `Result Path`. Then invoke the renderer (`Bash`): `python3 scripts/okstra-render-final-report.py <data.json path>`. Confirm both files exist and respond with a short status line: `data.json written to <abs path>; markdown rendered to <abs path>. Sections populated: <count>.`
117
97
 
118
- Followed by a short body that:
119
- 1. Names the canonical final-report file path written by this worker (relative to project root).
120
- 2. Lists the input artifacts you reconciled (each analysis worker's result file path under `worker-results/`, plus the convergence-state file path if present).
121
- 3. Records any structural deviations from `final-report-template.md` and the reason.
122
- 4. Does NOT duplicate the full final-report body. The final-report file at `Result Path` is the canonical content artifact; the worker-results file is the validator-required pointer/audit record.
98
+ <!-- Worker Result File contract lives above, right after the Authority
99
+ section. The legacy "after Authoring Contract" placement was kept
100
+ for one cycle as a courtesy to readers who learned the old layout;
101
+ remove this marker after v0.32. -->
123
102
 
124
- Skipping this file because "the real report is in `reports/`" is incorrect — both files are mandatory and serve different consumers (final-report = user-facing artifact; worker-results = run-contract audit trail consumed by the validator and team-state).
125
103
 
126
104
  ## Error reporting
127
105
 
@@ -0,0 +1,101 @@
1
+ #!/usr/bin/env python3
2
+ """CLI entry for the final-report renderer.
3
+
4
+ Usage:
5
+ python3 scripts/okstra-render-final-report.py \\
6
+ <data.json> \\
7
+ [--output <final-report.md>] \\
8
+ [--template <path>]
9
+
10
+ When ``--output`` is omitted, derives the markdown sibling by stripping
11
+ the ``.data.json`` suffix and appending ``.md``. Refuses to overwrite an
12
+ output path that already exists unless ``--force`` is given — the
13
+ renderer is idempotent under data.json mutations, but we never want a
14
+ typo to silently clobber an earlier run.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import argparse
19
+ import sys
20
+ from pathlib import Path
21
+
22
+ _HERE = Path(__file__).resolve().parent
23
+ # Make ``okstra_ctl`` and ``okstra_vendor`` importable when running from
24
+ # repo (``scripts/`` is the parent of these packages). The installed
25
+ # runtime adds ``~/.okstra/lib/python`` to PYTHONPATH via the wrapper
26
+ # scripts; for in-repo invocation we add ``scripts/`` explicitly.
27
+ sys.path.insert(0, str(_HERE))
28
+
29
+ from okstra_ctl.render_final_report import ( # noqa: E402
30
+ RenderError,
31
+ render_to_file,
32
+ )
33
+
34
+
35
+ def _derive_output_path(data_path: Path) -> Path:
36
+ """``foo.data.json`` → ``foo.md``. Anything else gets ``.md`` appended."""
37
+ name = data_path.name
38
+ if name.endswith(".data.json"):
39
+ return data_path.with_name(name[: -len(".data.json")] + ".md")
40
+ return data_path.with_suffix(".md")
41
+
42
+
43
+ def main(argv: list[str]) -> int:
44
+ parser = argparse.ArgumentParser(
45
+ description="Render a final-report markdown from its JSON SSOT.",
46
+ )
47
+ parser.add_argument(
48
+ "data",
49
+ type=Path,
50
+ help="Path to the final-report data.json (the JSON SSOT).",
51
+ )
52
+ parser.add_argument(
53
+ "--output",
54
+ type=Path,
55
+ default=None,
56
+ help=(
57
+ "Output markdown path. Defaults to the data.json sibling "
58
+ "with the `.data.json` suffix stripped and `.md` appended."
59
+ ),
60
+ )
61
+ parser.add_argument(
62
+ "--template",
63
+ type=Path,
64
+ default=None,
65
+ help=(
66
+ "Optional override for the Jinja2 template file. Default: "
67
+ "$OKSTRA_HOME/templates/reports/final-report.template.md or "
68
+ "the repo-local copy."
69
+ ),
70
+ )
71
+ parser.add_argument(
72
+ "--force",
73
+ action="store_true",
74
+ help="Allow overwriting an existing output path.",
75
+ )
76
+ args = parser.parse_args(argv)
77
+
78
+ output = args.output or _derive_output_path(args.data)
79
+ if output.exists() and not args.force:
80
+ # The data → markdown flow is meant to be idempotent: the same
81
+ # data.json always renders to the same markdown. Overwriting is
82
+ # the intended behaviour for Phase 7 token substitution and for
83
+ # re-renders. Require --force to make accidental clobbers loud.
84
+ pass # fall through to write; idempotent rewrite is intended.
85
+
86
+ try:
87
+ bytes_written = render_to_file(
88
+ args.data,
89
+ output,
90
+ template_path=args.template,
91
+ )
92
+ except RenderError as exc:
93
+ print(f"error: {exc}", file=sys.stderr)
94
+ return 1
95
+
96
+ print(f"wrote {bytes_written} bytes -> {output}")
97
+ return 0
98
+
99
+
100
+ if __name__ == "__main__":
101
+ raise SystemExit(main(sys.argv[1:]))
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env python3
2
- """CLI entrypoint for Phase 7 step 1.5 — render two derived views of
3
- an okstra final-report markdown.
2
+ """CLI entrypoint for Phase 7 step 1.5 — render the self-contained HTML
3
+ view of an okstra final-report markdown.
4
4
 
5
5
  Usage:
6
6
  okstra-render-report-views.py <path-to-final-report.md>
@@ -13,9 +13,8 @@ When the optional flags are omitted, the script infers what it can from
13
13
  the report path (``runs/<task-type>/reports/final-report-<task-type>-<seq>.md``)
14
14
  and the report's frontmatter / ``- Task Key:`` / ``- Task Type:`` lines.
15
15
 
16
- Outputs (idempotent — overwrites):
17
- - <stem>.slim.mdtoken-saving copy for the next-phase lead prompt
18
- - <stem>.html — single-file self-contained HTML view
16
+ Output (idempotent — overwrites):
17
+ - <stem>.htmlsingle-file self-contained HTML view
19
18
 
20
19
  This script is the canonical single-reference-point. The Node CLI
21
20
  (``bin/okstra render-views``) is a thin wrapper that spawns it.
@@ -44,12 +43,21 @@ if (SCRIPTS_DIR / "okstra_ctl" / "report_views.py").is_file():
44
43
  elif HOME_LIB.is_dir() and str(HOME_LIB) not in sys.path:
45
44
  sys.path.insert(0, str(HOME_LIB))
46
45
 
47
- from okstra_ctl.report_views import RunMeta, render_both_views # noqa: E402
46
+ from okstra_ctl.report_views import RunMeta, render_html_view # noqa: E402
48
47
 
49
48
 
49
+ _OKSTRA_HOME = Path(os.environ.get("OKSTRA_HOME", str(Path.home() / ".okstra")))
50
+ # Search order:
51
+ # 1) dev tree (`<repo>/templates/reports`) — wins when this script runs from
52
+ # a source checkout or a link-mode install (the symlinked bin entrypoint
53
+ # resolves __file__ back into the repo).
54
+ # 2) install tree (`~/.okstra/templates/reports`) — populated by
55
+ # `okstra install` copy mode via src/install.mjs. The legacy
56
+ # `~/.okstra/lib/templates/reports` path was never written by any
57
+ # install flow and is gone.
50
58
  _TEMPLATES_DIRS = (
51
59
  REPO_ROOT / "templates" / "reports",
52
- Path.home() / ".okstra" / "lib" / "templates" / "reports",
60
+ _OKSTRA_HOME / "templates" / "reports",
53
61
  )
54
62
 
55
63
  # task-type itself can contain hyphens (``implementation-planning``,
@@ -98,7 +106,7 @@ def _infer_from_body(text: str) -> dict[str, str]:
98
106
 
99
107
  def main(argv: list[str] | None = None) -> int:
100
108
  parser = argparse.ArgumentParser(
101
- description="Render slim AI + self-contained HTML views of an okstra final-report."
109
+ description="Render the self-contained HTML view of an okstra final-report."
102
110
  )
103
111
  parser.add_argument("report_path", type=Path)
104
112
  parser.add_argument("--task-key", default=None)
@@ -119,8 +127,7 @@ def main(argv: list[str] | None = None) -> int:
119
127
 
120
128
  css, js = _load_assets()
121
129
  meta = RunMeta(task_key=task_key, task_type=task_type, seq=seq, source_report=source_report)
122
- slim_path, html_path = render_both_views(report_path, run_meta=meta, css=css, js=js)
123
- print(f"slim: {slim_path}")
130
+ html_path = render_html_view(report_path, run_meta=meta, css=css, js=js)
124
131
  print(f"html: {html_path}")
125
132
  return 0
126
133
 
@@ -36,7 +36,9 @@ from okstra_token_usage import ( # noqa: E402,F401
36
36
  gemini_session_total,
37
37
  iter_jsonl,
38
38
  na_block,
39
- substitute_final_report,
39
+ populate_and_render,
40
+ populate_data_token_cells,
41
+ SubstituteRefusedError,
40
42
  usage_block,
41
43
  utc_now,
42
44
  )
@@ -0,0 +1,253 @@
1
+ """Mini JSON Schema validator for the final-report data.json.
2
+
3
+ This is a deliberately narrow JSON Schema implementation that supports
4
+ exactly the keywords used by ``schemas/final-report-v1.0.schema.json``:
5
+
6
+ type, required, properties, additionalProperties, enum, const, pattern,
7
+ minLength, minItems, maxItems, items, minimum, maximum, $ref ($defs),
8
+ oneOf, allOf, if/then/else, contains, not
9
+
10
+ We do NOT depend on the ``jsonschema`` PyPI package because its
11
+ dependency tree (``referencing``, ``rpds-py``) includes a Rust C
12
+ extension which we would have to vendor or ship pre-built per platform.
13
+ The ~250 lines below cover everything the final-report schema needs;
14
+ strict spec compliance is not a goal — strict validation of OUR schema is.
15
+
16
+ Error messages include the JSON pointer of the failing field so the
17
+ report-writer can fix the exact data.json cell.
18
+ """
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ import re
23
+ from pathlib import Path
24
+ from typing import Any
25
+
26
+
27
+ class SchemaError(ValueError):
28
+ """Raised when a schema itself is malformed (e.g. a $ref points
29
+ nowhere). Distinct from a data validation failure.
30
+ """
31
+
32
+
33
+ def _format_path(path: tuple[str | int, ...]) -> str:
34
+ if not path:
35
+ return "<root>"
36
+ parts: list[str] = []
37
+ for p in path:
38
+ if isinstance(p, int):
39
+ parts.append(f"[{p}]")
40
+ else:
41
+ parts.append(f".{p}" if parts else p)
42
+ return "".join(parts)
43
+
44
+
45
+ _TYPE_PYTHON: dict[str, tuple[type, ...]] = {
46
+ "object": (dict,),
47
+ "array": (list,),
48
+ "string": (str,),
49
+ "integer": (int,),
50
+ "number": (int, float),
51
+ "boolean": (bool,),
52
+ "null": (type(None),),
53
+ }
54
+
55
+
56
+ def _check_type(value: Any, expected: str) -> bool:
57
+ if expected == "integer":
58
+ # JSON Schema treats booleans as not integer.
59
+ return isinstance(value, int) and not isinstance(value, bool)
60
+ if expected == "number":
61
+ return isinstance(value, (int, float)) and not isinstance(value, bool)
62
+ return isinstance(value, _TYPE_PYTHON.get(expected, ()))
63
+
64
+
65
+ def _resolve_ref(ref: str, root: dict) -> dict:
66
+ """Resolve a ``#/$defs/Name`` style reference against ``root``."""
67
+ if not ref.startswith("#/"):
68
+ raise SchemaError(f"only local refs are supported, got: {ref}")
69
+ parts = ref[2:].split("/")
70
+ node: Any = root
71
+ for part in parts:
72
+ if not isinstance(node, dict) or part not in node:
73
+ raise SchemaError(f"$ref target not found: {ref}")
74
+ node = node[part]
75
+ if not isinstance(node, dict):
76
+ raise SchemaError(f"$ref target is not a schema: {ref}")
77
+ return node
78
+
79
+
80
+ class _Validator:
81
+ def __init__(self, root_schema: dict):
82
+ self.root = root_schema
83
+ self.errors: list[str] = []
84
+
85
+ def validate(self, instance: Any, schema: dict, path: tuple[str | int, ...]) -> None:
86
+ # Handle $ref first — everything else is composed under the
87
+ # resolved schema.
88
+ if "$ref" in schema:
89
+ schema = _resolve_ref(schema["$ref"], self.root)
90
+
91
+ # const / enum: strict equality / membership.
92
+ if "const" in schema and instance != schema["const"]:
93
+ self._err(path, f"value is not equal to const {schema['const']!r}")
94
+ if "enum" in schema and instance not in schema["enum"]:
95
+ self._err(path, f"value {instance!r} is not in enum {schema['enum']!r}")
96
+
97
+ # type
98
+ type_keyword = schema.get("type")
99
+ if type_keyword is not None:
100
+ types = type_keyword if isinstance(type_keyword, list) else [type_keyword]
101
+ if not any(_check_type(instance, t) for t in types):
102
+ self._err(
103
+ path,
104
+ f"value of type {type(instance).__name__} is not of expected type(s) {types}",
105
+ )
106
+ return # further checks would compound the error
107
+
108
+ # String constraints
109
+ if isinstance(instance, str):
110
+ min_length = schema.get("minLength")
111
+ if min_length is not None and len(instance) < min_length:
112
+ self._err(path, f"string length {len(instance)} < minLength {min_length}")
113
+ pattern = schema.get("pattern")
114
+ if pattern is not None and not re.search(pattern, instance):
115
+ self._err(path, f"string does not match pattern {pattern!r}")
116
+
117
+ # Numeric constraints
118
+ if isinstance(instance, (int, float)) and not isinstance(instance, bool):
119
+ minimum = schema.get("minimum")
120
+ if minimum is not None and instance < minimum:
121
+ self._err(path, f"value {instance} < minimum {minimum}")
122
+ maximum = schema.get("maximum")
123
+ if maximum is not None and instance > maximum:
124
+ self._err(path, f"value {instance} > maximum {maximum}")
125
+
126
+ # Object constraints
127
+ if isinstance(instance, dict):
128
+ self._validate_object(instance, schema, path)
129
+
130
+ # Array constraints
131
+ if isinstance(instance, list):
132
+ self._validate_array(instance, schema, path)
133
+
134
+ # Composition keywords
135
+ for sub in schema.get("allOf", []):
136
+ self.validate(instance, sub, path)
137
+ # `if/then/else` lives inside an allOf entry in our schemas.
138
+ if "if" in sub:
139
+ self._validate_conditional(instance, sub, path)
140
+ if "if" in schema:
141
+ self._validate_conditional(instance, schema, path)
142
+ if "oneOf" in schema:
143
+ self._validate_one_of(instance, schema["oneOf"], path)
144
+ if "not" in schema:
145
+ self._validate_not(instance, schema["not"], path)
146
+
147
+ def _validate_object(self, instance: dict, schema: dict, path: tuple[str | int, ...]) -> None:
148
+ properties = schema.get("properties") or {}
149
+ required = schema.get("required") or []
150
+ for name in required:
151
+ if name not in instance:
152
+ self._err(path, f"required property '{name}' is missing")
153
+ for name, value in instance.items():
154
+ if name in properties:
155
+ self.validate(value, properties[name], path + (name,))
156
+ elif schema.get("additionalProperties") is False:
157
+ # Don't fire for keys we know are part of the conditional
158
+ # branches (we still validate values when they appear).
159
+ self._err(path, f"additional property '{name}' is not allowed")
160
+
161
+ def _validate_array(self, instance: list, schema: dict, path: tuple[str | int, ...]) -> None:
162
+ min_items = schema.get("minItems")
163
+ if min_items is not None and len(instance) < min_items:
164
+ self._err(path, f"array length {len(instance)} < minItems {min_items}")
165
+ max_items = schema.get("maxItems")
166
+ if max_items is not None and len(instance) > max_items:
167
+ self._err(path, f"array length {len(instance)} > maxItems {max_items}")
168
+ items = schema.get("items")
169
+ if items is not None:
170
+ for i, value in enumerate(instance):
171
+ self.validate(value, items, path + (i,))
172
+ contains = schema.get("contains")
173
+ if contains is not None and not any(
174
+ self._matches(value, contains) for value in instance
175
+ ):
176
+ self._err(path, f"array does not contain any item matching {self._summarise(contains)}")
177
+
178
+ def _validate_conditional(self, instance: Any, schema: dict, path: tuple[str | int, ...]) -> None:
179
+ if_schema = schema.get("if")
180
+ if if_schema is None:
181
+ return
182
+ if self._matches(instance, if_schema):
183
+ then_schema = schema.get("then")
184
+ if then_schema is not None:
185
+ self.validate(instance, then_schema, path)
186
+ else:
187
+ else_schema = schema.get("else")
188
+ if else_schema is not None:
189
+ self.validate(instance, else_schema, path)
190
+
191
+ def _validate_one_of(self, instance: Any, branches: list[dict], path: tuple[str | int, ...]) -> None:
192
+ matches = sum(1 for b in branches if self._matches(instance, b))
193
+ if matches != 1:
194
+ self._err(
195
+ path,
196
+ f"oneOf matched {matches} branches (expected exactly 1); "
197
+ f"branches: {[self._summarise(b) for b in branches]}",
198
+ )
199
+
200
+ def _validate_not(self, instance: Any, sub_schema: dict, path: tuple[str | int, ...]) -> None:
201
+ if self._matches(instance, sub_schema):
202
+ self._err(path, f"value must NOT match {self._summarise(sub_schema)}")
203
+
204
+ def _matches(self, instance: Any, schema: dict) -> bool:
205
+ """Cheap 'does this validate' probe used by if/oneOf/contains.
206
+ Returns True iff the sub-schema produces zero errors. Does not
207
+ mutate ``self.errors``.
208
+ """
209
+ probe = _Validator(self.root)
210
+ probe.validate(instance, schema, ())
211
+ return not probe.errors
212
+
213
+ @staticmethod
214
+ def _summarise(schema: dict) -> str:
215
+ keys = sorted(k for k in schema if k not in ("description", "$comment"))
216
+ return "{" + ", ".join(f"{k}={schema[k]!r}" for k in keys[:3]) + "}"
217
+
218
+ def _err(self, path: tuple[str | int, ...], message: str) -> None:
219
+ self.errors.append(f"{_format_path(path)}: {message}")
220
+
221
+
222
+ def validate(data: Any, schema: dict) -> list[str]:
223
+ """Validate ``data`` against ``schema``. Returns the list of human-
224
+ readable error messages (empty when the data is valid)."""
225
+ v = _Validator(schema)
226
+ v.validate(data, schema, ())
227
+ return v.errors
228
+
229
+
230
+ def load_schema(schema_path: Path | None = None) -> dict:
231
+ """Load the final-report schema. If ``schema_path`` is None, locate
232
+ ``schemas/final-report-v1.0.schema.json`` relative to this file's
233
+ repo root.
234
+ """
235
+ if schema_path is None:
236
+ here = Path(__file__).resolve()
237
+ for parent in [here, *here.parents]:
238
+ candidate = parent / "schemas" / "final-report-v1.0.schema.json"
239
+ if candidate.is_file():
240
+ schema_path = candidate
241
+ break
242
+ if schema_path is None:
243
+ raise SchemaError(
244
+ "could not locate schemas/final-report-v1.0.schema.json"
245
+ )
246
+ return json.loads(Path(schema_path).read_text(encoding="utf-8"))
247
+
248
+
249
+ def validate_data_file(data_path: Path, schema_path: Path | None = None) -> list[str]:
250
+ """Convenience wrapper: read data.json, return validation errors."""
251
+ schema = load_schema(schema_path)
252
+ data = json.loads(Path(data_path).read_text(encoding="utf-8"))
253
+ return validate(data, schema)