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
@@ -118,13 +118,19 @@ th, td {
118
118
  word-break: keep-all;
119
119
  overflow-wrap: anywhere;
120
120
  min-width: 5ch;
121
- }
122
- /* Short single-token cells (<= 20 plain chars) keep on one line so
123
- * status / ID / kind columns do not get squeezed into narrow stacks
124
- * by long-form neighbours. Class is applied by report_views._emit_table. */
125
- td.td-tight, th.td-tight {
121
+ /* No max-width: cells that carry prose (statement / source / scope)
122
+ * absorb the remaining horizontal space within `main`'s 120ch. */
123
+ }
124
+ /* Narrow columns header AND every body cell of this column fit
125
+ * within 30 plain chars (per `_narrow_columns()` in report_views.py).
126
+ * Examples: ID, Ticket ID, Kind, Status, Priority, Auto-spawn?,
127
+ * 에이전트, 역할, 모델, 상태. Pinning `width: 5%` makes these
128
+ * columns shrink toward the suggestion while wide neighbours absorb
129
+ * the remainder, so a long-prose row never squashes ID-style stacks
130
+ * into one-character ladders. */
131
+ td.td-narrow, th.td-narrow {
132
+ width: 5%;
126
133
  white-space: nowrap;
127
- width: 1%; /* shrink-to-fit under auto-layout */
128
134
  }
129
135
  thead th {
130
136
  position: sticky;
@@ -366,21 +366,21 @@ report_lines.extend(
366
366
  report_path.parent.mkdir(parents=True, exist_ok=True)
367
367
  report_path.write_text("\n".join(report_lines) + "\n")
368
368
 
369
- # Phase 7 step 1.5 (BLOCKING) — render the slim/html sibling artifacts
370
- # next to the final-report so validate-run.py's new report-views hook
371
- # passes. The workflow validator's fixture predates that step; we
372
- # materialise both files in-place using the same single-reference-point
373
- # helper the CLI uses.
369
+ # Phase 7 step 1.5 (BLOCKING) — render the html sibling artifact next
370
+ # to the final-report so validate-run.py's report-views hook passes.
371
+ # The workflow validator's fixture predates that step; we materialise
372
+ # the html file in-place using the same single-reference-point helper
373
+ # the CLI uses.
374
374
  import os
375
375
  WORKSPACE_ROOT = os.environ.get("OKSTRA_WORKSPACE_ROOT_FOR_FIXTURE", "")
376
376
  if WORKSPACE_ROOT:
377
377
  import sys as _sys
378
378
  _sys.path.insert(0, str(Path(WORKSPACE_ROOT) / "scripts"))
379
379
  try:
380
- from okstra_ctl.report_views import RunMeta, render_both_views
380
+ from okstra_ctl.report_views import RunMeta, render_html_view
381
381
  css = (Path(WORKSPACE_ROOT) / "templates" / "reports" / "report.css").read_text(encoding="utf-8")
382
382
  js = (Path(WORKSPACE_ROOT) / "templates" / "reports" / "report.js").read_text(encoding="utf-8")
383
- render_both_views(
383
+ render_html_view(
384
384
  report_path,
385
385
  run_meta=RunMeta(
386
386
  task_key=str(task_manifest.get("taskKey", "validation/fixture")),
@@ -1,20 +1,17 @@
1
1
  #!/usr/bin/env python3
2
- """Validate the two derived final-report views produced by Phase 7 step
3
- 1.5 (``scripts/okstra-render-report-views.py``).
2
+ """Validate the self-contained HTML view produced by Phase 7 step 1.5
3
+ (``scripts/okstra-render-report-views.py``).
4
4
 
5
5
  Checks, for a given final-report MD path:
6
- 1. ``*.slim.md`` and ``*.html`` siblings both exist.
7
- 2. Slim preserves every phase-required substring from ``validate-run.py``
8
- byte-identically (drift fence vs the report_views module).
9
- 3. HTML body (``<main>`` slice) contains the same plain-text
10
- substrings (after html.unescape and the markdown ``#`` prefix is
11
- stripped).
12
- 4. HTML's §4.6 / §4.7 / §4.8 deliverable regions contain no
6
+ 1. ``*.html`` sibling exists.
7
+ 2. HTML's ``source-sha256`` in run-meta matches the current MD body —
8
+ stale html detection.
9
+ 3. HTML's §4.6 / §4.7 / §4.8 deliverable regions contain no
13
10
  ``<textarea>`` / ``<input>`` / ``<select>`` (form-attach is
14
11
  restricted to §5 clarification rows).
15
- 5. HTML has no external URLs in ``<script src=>`` / ``<link href=>`` /
12
+ 4. HTML has no external URLs in ``<script src=>`` / ``<link href=>`` /
16
13
  ``<img src=>`` — self-contained guarantee.
17
- 6. Every Response ID in HTML matches the §5 Clarification Items table
14
+ 5. Every Response ID in HTML matches the §5 Clarification Items table
18
15
  of the source MD (1:1).
19
16
 
20
17
  Exit codes: 0 on success, 1 on any failure. Failures are printed one
@@ -23,12 +20,9 @@ per line to stderr.
23
20
  from __future__ import annotations
24
21
 
25
22
  import argparse
26
- import html as html_lib
27
- import importlib.util
28
23
  import re
29
24
  import sys
30
25
  from pathlib import Path
31
- from typing import Iterable
32
26
 
33
27
  REPO_ROOT = Path(__file__).resolve().parents[1]
34
28
  SCRIPTS_DIR = REPO_ROOT / "scripts"
@@ -37,31 +31,17 @@ if str(SCRIPTS_DIR) not in sys.path:
37
31
 
38
32
  from okstra_ctl.clarification_items import parse_clarification_items # noqa: E402
39
33
  from okstra_ctl.report_views import ( # noqa: E402
40
- _strip_leading_digest_comment,
41
34
  extract_html_digest,
42
- extract_slim_digest,
43
- preserved_substrings,
44
- slim_markdown,
45
35
  source_digest,
46
36
  )
47
37
 
48
38
 
49
- def _load_validate_run() -> object:
50
- """Dynamically load validate-run.py — it's a stand-alone script
51
- with a hyphenated name, not an importable module. We snapshot the
52
- substring lists from it so drift between the two files surfaces
53
- here rather than silently in production."""
54
- path = REPO_ROOT / "validators" / "validate-run.py"
55
- spec = importlib.util.spec_from_file_location("_okstra_validate_run", path)
56
- assert spec and spec.loader, f"cannot load {path}"
57
- mod = importlib.util.module_from_spec(spec)
58
- spec.loader.exec_module(mod)
59
- return mod
60
-
39
+ _EXTERNAL_URL_RE = re.compile(
40
+ r'<(?:script|link|img|iframe|source|video|audio)\s[^>]*?(?:src|href)\s*=\s*["\']https?://',
41
+ re.IGNORECASE,
42
+ )
61
43
 
62
- def _detect_task_type(md: str) -> str | None:
63
- m = re.search(r"^- Task Type:\s*(\S+)", md, re.MULTILINE)
64
- return m.group(1).strip() if m else None
44
+ _RESPONSE_ID_ATTR_RE = re.compile(r'data-response-id="(C-\d+)"')
65
45
 
66
46
 
67
47
  def _main_body(html_text: str) -> str:
@@ -72,25 +52,6 @@ def _main_body(html_text: str) -> str:
72
52
  return html_text[start + len("<main>"): end]
73
53
 
74
54
 
75
- def _strip_md_prefix(sub: str) -> str:
76
- s = sub
77
- while s.startswith("#"):
78
- s = s[1:]
79
- return s.strip()
80
-
81
-
82
- _EXTERNAL_URL_RE = re.compile(
83
- r'<(?:script|link|img|iframe|source|video|audio)\s[^>]*?(?:src|href)\s*=\s*["\']https?://',
84
- re.IGNORECASE,
85
- )
86
-
87
- _RESPONSE_ID_ATTR_RE = re.compile(r'data-response-id="(C-\d+)"')
88
-
89
- _NO_FORM_HEADING_RE = re.compile(
90
- r"<h[23][^>]*>\s*(?:4\.6|4\.7|4\.8)[\s\S]*?</h[23]>"
91
- )
92
-
93
-
94
55
  def _no_form_sections(html_body: str) -> list[str]:
95
56
  """Return a list of strings, each being the rendered chunk of a
96
57
  no-form section (4.6 / 4.7 / 4.8) up to the next h2/h3. Used to
@@ -118,50 +79,21 @@ def validate(report_path: Path) -> list[str]:
118
79
  return [f"final-report not found: {report_path}"]
119
80
 
120
81
  md = report_path.read_text(encoding="utf-8")
121
- task_type = _detect_task_type(md)
122
- slim_path = report_path.with_name(report_path.stem + ".slim.md")
123
82
  html_path = report_path.with_name(report_path.stem + ".html")
124
83
 
125
- # (1) sibling artifacts exist
126
- if not slim_path.is_file():
127
- failures.append(f"missing slim artifact: {slim_path}")
84
+ # (1) sibling artifact exists
128
85
  if not html_path.is_file():
129
- failures.append(f"missing html artifact: {html_path}")
130
- if failures:
131
- return failures
86
+ return [f"missing html artifact: {html_path}"]
132
87
 
133
- slim = slim_path.read_text(encoding="utf-8")
134
88
  html_text = html_path.read_text(encoding="utf-8")
135
89
  html_body = _main_body(html_text)
136
- html_body_text = html_lib.unescape(html_body)
137
90
 
138
- # (2a) source-digest staleness — both derived views must carry the
139
- # sha256 of the artifact derived from the *current* MD. A mismatch
140
- # means the views were rendered against an older MD body and Phase
141
- # 7 step 1.5 was not re-run.
142
- # - HTML digest is over the MD body (after stripping any stale
143
- # leading digest comment), so the expected value is the digest
144
- # of the current MD itself.
145
- # - slim digest is over the slim BODY (so any byte-difference
146
- # between expected and actual slim shows up as a digest
147
- # mismatch). We recompute the expected slim and extract its
148
- # embedded digest for comparison — equivalent to but cheaper
149
- # than diffing the full body.
150
- expected_md_digest = source_digest(_strip_leading_digest_comment(md))
151
- expected_slim_digest = extract_slim_digest(
152
- slim_markdown(md, task_type=task_type)
153
- )
154
- slim_digest = extract_slim_digest(slim)
91
+ # (2) source-digest staleness — html's run-meta source-sha256 must
92
+ # match the current MD's digest. Mismatch means the html was
93
+ # rendered against an older MD body and Phase 7 step 1.5 was not
94
+ # re-run.
95
+ expected_md_digest = source_digest(md)
155
96
  html_digest = extract_html_digest(html_text)
156
- if slim_digest is None:
157
- failures.append(
158
- "slim missing source-sha256 header — re-render with okstra render-views"
159
- )
160
- elif slim_digest != expected_slim_digest:
161
- failures.append(
162
- f"stale slim: body sha256 {slim_digest[:12]}… does not match "
163
- f"current MD's rendered slim {str(expected_slim_digest)[:12]}…"
164
- )
165
97
  if html_digest is None:
166
98
  failures.append(
167
99
  "html missing source-sha256 in run-meta — re-render with okstra render-views"
@@ -172,31 +104,7 @@ def validate(report_path: Path) -> list[str]:
172
104
  f"current MD {expected_md_digest[:12]}…"
173
105
  )
174
106
 
175
- # (2b) drift fence: report_views.preserved_substrings(task_type)
176
- # must agree with validate-run.py's tuples for the same task_type.
177
- vr = _load_validate_run()
178
- drift = _diff_substring_lists(task_type, vr)
179
- failures.extend(drift)
180
-
181
- # (3) slim preserves every required substring byte-identically
182
- for sub in preserved_substrings(task_type):
183
- if sub in md and sub not in slim:
184
- failures.append(f"slim dropped required substring: {sub!r}")
185
- # Also forward-check against validate-run.py's tuples so a
186
- # future addition there fails this validator immediately.
187
- for sub in _validate_run_substrings(task_type, vr):
188
- if sub in md and sub not in slim:
189
- failures.append(f"slim dropped validate-run substring: {sub!r}")
190
-
191
- # (4) html preserves the same set (modulo md prefix + html.escape)
192
- for sub in preserved_substrings(task_type):
193
- if sub not in md:
194
- continue
195
- needle = _strip_md_prefix(sub)
196
- if needle not in html_body_text:
197
- failures.append(f"html body dropped substring: {needle!r}")
198
-
199
- # (5) deliverable sections contain no form controls
107
+ # (3) deliverable sections contain no form controls
200
108
  for chunk in _no_form_sections(html_body):
201
109
  if "<textarea" in chunk or "<input" in chunk or "<select" in chunk:
202
110
  failures.append(
@@ -204,11 +112,11 @@ def validate(report_path: Path) -> list[str]:
204
112
  )
205
113
  break
206
114
 
207
- # (6) no external URLs in <script src> / <link href> / etc.
115
+ # (4) no external URLs in <script src> / <link href> / etc.
208
116
  if _EXTERNAL_URL_RE.search(html_text):
209
117
  failures.append("html contains external URL in script/link/img — must be self-contained")
210
118
 
211
- # (7) Response ID parity: HTML form rows ↔ §5 C-* rows in MD.
119
+ # (5) Response ID parity: HTML form rows ↔ §5 C-* rows in MD.
212
120
  # Bidirectional — catches both "MD has C-* the HTML lost" AND
213
121
  # "HTML has stale C-* that the current MD no longer declares".
214
122
  md_ids = _md_response_ids(md)
@@ -221,43 +129,6 @@ def validate(report_path: Path) -> list[str]:
221
129
  return failures
222
130
 
223
131
 
224
- def _validate_run_substrings(task_type: str | None, vr) -> Iterable[str]:
225
- if task_type == "implementation":
226
- return getattr(vr, "IMPLEMENTATION_REQUIRED_SECTIONS", ())
227
- if task_type == "final-verification":
228
- return getattr(vr, "FINAL_VERIFICATION_REQUIRED_SECTIONS", ())
229
- return ()
230
-
231
-
232
- def _diff_substring_lists(task_type: str | None, vr) -> list[str]:
233
- """Surface drift between report_views and validate-run substring
234
- tuples. The two MUST stay in lock-step."""
235
- failures: list[str] = []
236
- from okstra_ctl.report_views import (
237
- PRESERVED_SUBSTRINGS_IMPLEMENTATION,
238
- PRESERVED_SUBSTRINGS_FINAL_VERIFICATION,
239
- )
240
- impl_vr = set(getattr(vr, "IMPLEMENTATION_REQUIRED_SECTIONS", ()))
241
- impl_rv = set(PRESERVED_SUBSTRINGS_IMPLEMENTATION)
242
- if impl_vr != impl_rv:
243
- missing_in_views = impl_vr - impl_rv
244
- extra_in_views = impl_rv - impl_vr
245
- failures.append(
246
- "drift: PRESERVED_SUBSTRINGS_IMPLEMENTATION vs validate-run "
247
- f"IMPLEMENTATION_REQUIRED_SECTIONS — missing in views {missing_in_views or '∅'}, "
248
- f"extra in views {extra_in_views or '∅'}"
249
- )
250
- fv_vr = set(getattr(vr, "FINAL_VERIFICATION_REQUIRED_SECTIONS", ()))
251
- fv_rv = set(PRESERVED_SUBSTRINGS_FINAL_VERIFICATION)
252
- if fv_vr != fv_rv:
253
- failures.append(
254
- "drift: PRESERVED_SUBSTRINGS_FINAL_VERIFICATION vs validate-run "
255
- f"FINAL_VERIFICATION_REQUIRED_SECTIONS — missing in views {fv_vr - fv_rv or '∅'}, "
256
- f"extra in views {fv_rv - fv_vr or '∅'}"
257
- )
258
- return failures
259
-
260
-
261
132
  def _md_response_ids(md: str) -> list[str]:
262
133
  items = parse_clarification_items(md) or []
263
134
  return sorted({it.row_id for it in items if re.fullmatch(r"C-\d+", it.row_id)})
@@ -265,7 +136,7 @@ def _md_response_ids(md: str) -> list[str]:
265
136
 
266
137
  def main(argv: list[str] | None = None) -> int:
267
138
  parser = argparse.ArgumentParser(
268
- description="Validate slim/html derived views of an okstra final-report."
139
+ description="Validate the self-contained HTML view of an okstra final-report."
269
140
  )
270
141
  parser.add_argument(
271
142
  "report_path",
@@ -10,6 +10,25 @@ import sys
10
10
  from datetime import datetime, timezone
11
11
  from pathlib import Path
12
12
 
13
+ # Make the in-tree ``okstra_ctl`` package importable when this validator
14
+ # runs from the repo (the installed runtime already has ~/.okstra/lib/python
15
+ # on PYTHONPATH so the import is a no-op there).
16
+ _VALIDATORS_DIR = Path(__file__).resolve().parent
17
+ _SCRIPTS_DIR = _VALIDATORS_DIR.parent / "scripts"
18
+ if _SCRIPTS_DIR.is_dir() and str(_SCRIPTS_DIR) not in sys.path:
19
+ sys.path.insert(0, str(_SCRIPTS_DIR))
20
+
21
+ try:
22
+ from okstra_ctl.final_report_schema import (
23
+ SchemaError,
24
+ load_schema,
25
+ validate as schema_validate,
26
+ )
27
+ except ImportError: # pragma: no cover — runtime guarantees this import
28
+ SchemaError = None # type: ignore[assignment]
29
+ load_schema = None # type: ignore[assignment]
30
+ schema_validate = None # type: ignore[assignment]
31
+
13
32
  TERMINAL_STATUSES = {"completed", "timeout", "error", "not-run"}
14
33
  ATTEMPTED_STATUSES = {"completed", "timeout", "error"}
15
34
 
@@ -558,7 +577,7 @@ def _scan_token_usage_summary(content: str, failures: list[str]) -> None:
558
577
  "Token Usage Summary cell contains sentinel value "
559
578
  f"`{stripped}` on row labelled `{label_cell or '<unlabeled>'}` — "
560
579
  "leave the `{{...}}` placeholder verbatim until "
561
- "`okstra-token-usage.py --substitute-final-report` runs "
580
+ "`okstra-token-usage.py --substitute-data` runs "
562
581
  "in Phase 7."
563
582
  )
564
583
  continue
@@ -567,7 +586,7 @@ def _scan_token_usage_summary(content: str, failures: list[str]) -> None:
567
586
  f"Token Usage Summary row `{label_cell or '<unlabeled>'}` has "
568
587
  f"a zero value `{stripped}` — no okstra run consumes zero "
569
588
  "tokens. Re-run `python3 scripts/okstra-token-usage.py "
570
- "<team-state> --write --summary --substitute-final-report "
589
+ "<team-state> --write --summary --substitute-data "
571
590
  "<report-path>` to repopulate from session jsonls. The "
572
591
  "Codex/Gemini CLI row is the only place `$0.00` is "
573
592
  "allowed (when no CLI work was billed)."
@@ -664,7 +683,7 @@ def validate_report(
664
683
  if placeholder in content:
665
684
  failures.append(
666
685
  f"final report contains unsubstituted token placeholder `{placeholder}` — "
667
- "run `okstra-token-usage.py ... --substitute-final-report <report-path>` during Phase 7"
686
+ "run `okstra-token-usage.py ... --substitute-data <report-path>` during Phase 7"
668
687
  )
669
688
 
670
689
  # Catch the "workers typed `0` / `pending` instead of the placeholder"
@@ -794,7 +813,7 @@ def validate_team_state_usage(team_state: dict, failures: list[str]) -> None:
794
813
  failures.append(
795
814
  "team-state.usageSummary is empty — Phase 7 token-usage collection was skipped. "
796
815
  "Run `python3 scripts/okstra-token-usage.py <team-state> --write --summary "
797
- "--substitute-final-report <final-report>`."
816
+ "--substitute-data <final-report>`."
798
817
  )
799
818
  return
800
819
  # Reject zero-valued usage when the collector flagged any source as
@@ -973,11 +992,69 @@ def _validate_verdict_card_consistency(content: str, failures: list[str]) -> Non
973
992
  )
974
993
 
975
994
 
995
+ def _data_path_for(report_path: Path) -> Path:
996
+ """Derive the final-report data.json sibling from the markdown path.
997
+ ``foo.md`` → ``foo.data.json``. The data.json is the canonical JSON
998
+ SSOT; the markdown is rendered from it.
999
+ """
1000
+ name = report_path.name
1001
+ if name.endswith(".md"):
1002
+ return report_path.with_name(name[:-3] + ".data.json")
1003
+ return report_path.with_suffix(".data.json")
1004
+
1005
+
1006
+ def validate_final_report_data(report_path: Path, failures: list[str]) -> None:
1007
+ """Validate the final-report data.json against the v1.0 schema.
1008
+
1009
+ The data.json is the source-of-truth that the renderer reads to
1010
+ produce the markdown. If schema validation passes here, the rendered
1011
+ markdown is guaranteed to contain every section / row the contract
1012
+ requires (the template loops over the data). The downstream
1013
+ substring checks in ``validate_phase_boundary`` are kept as a safety
1014
+ net but are expected to be redundant.
1015
+
1016
+ Missing data.json is reported as a single failure rather than a
1017
+ cascade of substring failures — that points the writer at the right
1018
+ fix (write the data.json) instead of futilely editing the markdown.
1019
+ """
1020
+ if schema_validate is None or load_schema is None:
1021
+ # Module-load fallback path; should never fire in a real install.
1022
+ failures.append(
1023
+ "validate-run: okstra_ctl.final_report_schema is not importable — "
1024
+ "install may be incomplete (scripts/ not on PYTHONPATH)."
1025
+ )
1026
+ return
1027
+
1028
+ data_path = _data_path_for(report_path)
1029
+ if not data_path.is_file():
1030
+ failures.append(
1031
+ f"final-report data.json is missing at {data_path} — the renderer "
1032
+ "needs this file as its single source of truth. The markdown "
1033
+ "alone is no longer a valid run artifact."
1034
+ )
1035
+ return
1036
+
1037
+ try:
1038
+ schema = load_schema()
1039
+ except SchemaError as exc:
1040
+ failures.append(f"final-report schema could not be loaded: {exc}")
1041
+ return
1042
+
1043
+ try:
1044
+ data = json.loads(data_path.read_text(encoding="utf-8"))
1045
+ except json.JSONDecodeError as exc:
1046
+ failures.append(f"final-report data.json is not valid JSON: {exc}")
1047
+ return
1048
+
1049
+ errors = schema_validate(data, schema)
1050
+ for err in errors:
1051
+ failures.append(f"final-report data.json: {err}")
1052
+
1053
+
976
1054
  def validate_report_views(report_path: Path, failures: list[str]) -> None:
977
- """Enforce Phase 7 step 1.5 (BLOCKING) — the slim AI copy and the
978
- self-contained HTML view must exist next to the final-report MD,
979
- and both must satisfy the contract checked by
980
- ``validators/validate-report-views.py``.
1055
+ """Enforce Phase 7 step 1.5 (BLOCKING) — the self-contained HTML
1056
+ view must exist next to the final-report MD and satisfy the
1057
+ contract checked by ``validators/validate-report-views.py``.
981
1058
 
982
1059
  Delegated to that script as a subprocess so the contract surface
983
1060
  stays in one place. Failures from the delegate are folded back as
@@ -1192,8 +1269,8 @@ def _import_token_usage():
1192
1269
  sys.path.insert(0, str(candidate))
1193
1270
  break
1194
1271
  from okstra_token_usage.collect import collect # noqa: E402
1195
- from okstra_token_usage.report import substitute_final_report # noqa: E402
1196
- return collect, substitute_final_report
1272
+ from okstra_token_usage.report import populate_and_render # noqa: E402
1273
+ return collect, populate_and_render
1197
1274
 
1198
1275
 
1199
1276
  def _needs_token_autofix(team_state: dict, report_path: Path) -> bool:
@@ -1286,7 +1363,7 @@ def attempt_token_usage_autofix(
1286
1363
  if not _needs_token_autofix(team_state, report_path):
1287
1364
  return "skipped", []
1288
1365
  try:
1289
- collect, substitute_final_report = _import_token_usage()
1366
+ collect, populate_and_render = _import_token_usage()
1290
1367
  except Exception as exc: # noqa: BLE001
1291
1368
  return "import-failed", [f"okstra_token_usage import failed: {exc}"]
1292
1369
  try:
@@ -1307,8 +1384,9 @@ def attempt_token_usage_autofix(
1307
1384
  team_state_path.write_text(
1308
1385
  json.dumps(updated, indent=2, ensure_ascii=False) + "\n"
1309
1386
  )
1387
+ data_path = _data_path_for(report_path)
1310
1388
  try:
1311
- replaced = substitute_final_report(report_path, updated)
1389
+ replaced, _bytes = populate_and_render(data_path, updated)
1312
1390
  except Exception as exc: # noqa: BLE001
1313
1391
  # `SubstituteRefusedError` (or any unexpected substitution
1314
1392
  # failure) — report it as an accuracy failure so the validator
@@ -1319,8 +1397,8 @@ def attempt_token_usage_autofix(
1319
1397
  ]
1320
1398
 
1321
1399
  # Phase 7 step 1.5 is BLOCKING and the autofix just mutated the
1322
- # source MD — any pre-existing slim/html sibling is now stale by
1323
- # construction. Re-render the derived views in lock-step so the
1400
+ # source MD — any pre-existing html sibling is now stale by
1401
+ # construction. Re-render the html view in lock-step so the
1324
1402
  # downstream report-views validator does not trip over the
1325
1403
  # autofix's own side effect.
1326
1404
  rerender_note = _rerender_report_views_after_autofix(report_path)
@@ -1339,9 +1417,9 @@ def attempt_token_usage_autofix(
1339
1417
 
1340
1418
 
1341
1419
  def _rerender_report_views_after_autofix(report_path: Path) -> str:
1342
- """Re-render ``*.slim.md`` and ``*.html`` siblings against the
1343
- just-substituted MD. Returns a short status note for the autofix
1344
- message (empty on no-op, descriptive on failure).
1420
+ """Re-render the ``*.html`` sibling against the just-substituted MD.
1421
+ Returns a short status note for the autofix message (empty on no-op,
1422
+ descriptive on failure).
1345
1423
  """
1346
1424
  if not report_path.is_file():
1347
1425
  return ""
@@ -1351,7 +1429,7 @@ def _rerender_report_views_after_autofix(report_path: Path) -> str:
1351
1429
  scripts_dir = Path(__file__).resolve().parent.parent / "scripts"
1352
1430
  if str(scripts_dir) not in sys.path:
1353
1431
  sys.path.insert(0, str(scripts_dir))
1354
- from okstra_ctl.report_views import RunMeta, render_both_views
1432
+ from okstra_ctl.report_views import RunMeta, render_html_view
1355
1433
  templates_dir = (
1356
1434
  Path(__file__).resolve().parent.parent / "templates" / "reports"
1357
1435
  )
@@ -1374,7 +1452,7 @@ def _rerender_report_views_after_autofix(report_path: Path) -> str:
1374
1452
  source_report=report_path.name,
1375
1453
  )
1376
1454
  try:
1377
- render_both_views(report_path, run_meta=meta, css=css, js=js)
1455
+ render_html_view(report_path, run_meta=meta, css=css, js=js)
1378
1456
  except Exception as exc: # noqa: BLE001
1379
1457
  return f"report-views re-render failed: {exc}"
1380
1458
  return "report-views re-rendered"
@@ -1445,6 +1523,11 @@ def main() -> int:
1445
1523
  failures.extend(autofix_messages)
1446
1524
  contract = extract_contract(run_manifest, task_manifest, failures)
1447
1525
  validate_team_state(team_state, project_root, contract, failures)
1526
+ # Schema validation runs BEFORE markdown substring checks: if the
1527
+ # data.json is well-formed, the rendered markdown is guaranteed to
1528
+ # contain every required section. Substring checks below are a
1529
+ # safety net for hand-edited or pre-v1.0 reports.
1530
+ validate_final_report_data(report_path, failures)
1448
1531
  validate_report(report_path, contract["required_agent_status_entries"], failures)
1449
1532
  validate_team_state_usage(team_state, failures)
1450
1533
 
package/src/install.mjs CHANGED
@@ -15,7 +15,7 @@ const SETTINGS_TEMPLATE_SRC_REL = ["templates", "reports", "settings.template.js
15
15
  // Destination under ~/.okstra/. Project-local .claude/settings.local.json symlinks here.
16
16
  const SETTINGS_TEMPLATE_DST_REL = ["templates", "settings.local.json"];
17
17
 
18
- const PYTHON_PACKAGES = ["okstra_project", "okstra_ctl", "okstra_token_usage", "lib"];
18
+ const PYTHON_PACKAGES = ["okstra_project", "okstra_ctl", "okstra_token_usage", "okstra_vendor", "lib"];
19
19
  const BIN_ENTRYPOINTS = [
20
20
  "okstra.sh",
21
21
  "okstra-codex-exec.sh",
@@ -25,6 +25,7 @@ const BIN_ENTRYPOINTS = [
25
25
  "okstra-token-usage.py",
26
26
  "okstra-error-log.py",
27
27
  "okstra-render-report-views.py",
28
+ "okstra-render-final-report.py",
28
29
  "okstra-wrapper-status.py",
29
30
  ];
30
31
 
@@ -41,6 +42,7 @@ Usage:
41
42
  Effect (copy mode):
42
43
  ${"$HOME"}/.okstra/lib/python <- runtime/python
43
44
  ${"$HOME"}/.okstra/bin <- runtime/bin
45
+ ${"$HOME"}/.okstra/templates <- runtime/templates (report.css / report.js / *.template.md)
44
46
  ${"$HOME"}/.okstra/templates/settings.local.json <- runtime/templates/reports/settings.template.json
45
47
  ${"$HOME"}/.claude/skills/<name> <- runtime/skills/<name> (per skill)
46
48
  ${"$HOME"}/.claude/agents/<worker>.md <- runtime/agents/workers/<worker>.md
@@ -551,10 +553,22 @@ export async function runInstall(args) {
551
553
  paths.bin,
552
554
  { refresh: opts.refresh, dryRun: opts.dryRun, mode: 0o755 },
553
555
  );
556
+ // templates/ tree — report.css / report.js / *.template.md are consumed at
557
+ // runtime by okstra-render-report-views.py and final-report assembly. They
558
+ // are NOT covered by installSettingsTemplate (which only handles the
559
+ // editable settings.local.json sidecar), so without this step copy-mode
560
+ // installs miss every asset other than that single file. See
561
+ // okstra-render-report-views.py _TEMPLATES_DIRS for the lookup path.
562
+ const templatesResult = await copyTreeIfChanged(
563
+ join(runtimeRoot, "templates"),
564
+ join(paths.home, "templates"),
565
+ { refresh: opts.refresh, dryRun: opts.dryRun, mode: 0o644 },
566
+ );
554
567
 
555
568
  if (!opts.quiet) {
556
569
  summarise("python", pythonResult, paths.pythonpath);
557
570
  summarise("bin", binResult, paths.bin);
571
+ summarise("templates", templatesResult, join(paths.home, "templates"));
558
572
  }
559
573
 
560
574
  if (pythonResult.missingSource && binResult.missingSource) {
@@ -562,6 +576,11 @@ export async function runInstall(args) {
562
576
  "warning: runtime/{python,bin} are both empty. Runtime sync (build step) has not been performed.\n",
563
577
  );
564
578
  }
579
+ if (templatesResult.missingSource) {
580
+ process.stderr.write(
581
+ "warning: runtime/templates is empty. report.css / report.js will be missing — re-run the build step.\n",
582
+ );
583
+ }
565
584
 
566
585
  const skillResult = await installSkillsCopy(runtimeRoot, opts);
567
586
  await writeSkillsManifest(paths.home, skillResult.installed, { dryRun: opts.dryRun });