okstra 0.30.3 → 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 (69) hide show
  1. package/docs/kr/architecture.md +2 -2
  2. package/docs/kr/cli.md +2 -2
  3. package/package.json +1 -1
  4. package/runtime/BUILD.json +2 -2
  5. package/runtime/agents/SKILL.md +7 -5
  6. package/runtime/agents/workers/claude-worker.md +1 -1
  7. package/runtime/agents/workers/codex-worker.md +23 -6
  8. package/runtime/agents/workers/gemini-worker.md +23 -6
  9. package/runtime/agents/workers/report-writer-worker.md +45 -66
  10. package/runtime/bin/okstra-codex-exec.sh +31 -0
  11. package/runtime/bin/okstra-gemini-exec.sh +26 -0
  12. package/runtime/bin/okstra-render-final-report.py +101 -0
  13. package/runtime/bin/okstra-render-report-views.py +17 -10
  14. package/runtime/bin/okstra-token-usage.py +3 -1
  15. package/runtime/python/lib/okstra/globals.sh +1 -1
  16. package/runtime/python/lib/okstra/usage.sh +2 -2
  17. package/runtime/python/okstra_ctl/final_report_schema.py +253 -0
  18. package/runtime/python/okstra_ctl/models.py +2 -0
  19. package/runtime/python/okstra_ctl/render_final_report.py +201 -0
  20. package/runtime/python/okstra_ctl/report_views.py +276 -297
  21. package/runtime/python/okstra_ctl/run.py +1 -1
  22. package/runtime/python/okstra_ctl/wizard.py +53 -14
  23. package/runtime/python/okstra_ctl/workers.py +45 -11
  24. package/runtime/python/okstra_token_usage/__init__.py +5 -1
  25. package/runtime/python/okstra_token_usage/cli.py +66 -36
  26. package/runtime/python/okstra_token_usage/pricing.py +1 -0
  27. package/runtime/python/okstra_token_usage/report.py +148 -65
  28. package/runtime/python/okstra_vendor/__init__.py +37 -0
  29. package/runtime/python/okstra_vendor/jinja2/__init__.py +38 -0
  30. package/runtime/python/okstra_vendor/jinja2/_identifier.py +6 -0
  31. package/runtime/python/okstra_vendor/jinja2/async_utils.py +99 -0
  32. package/runtime/python/okstra_vendor/jinja2/bccache.py +408 -0
  33. package/runtime/python/okstra_vendor/jinja2/compiler.py +1998 -0
  34. package/runtime/python/okstra_vendor/jinja2/constants.py +20 -0
  35. package/runtime/python/okstra_vendor/jinja2/debug.py +191 -0
  36. package/runtime/python/okstra_vendor/jinja2/defaults.py +48 -0
  37. package/runtime/python/okstra_vendor/jinja2/environment.py +1672 -0
  38. package/runtime/python/okstra_vendor/jinja2/exceptions.py +166 -0
  39. package/runtime/python/okstra_vendor/jinja2/ext.py +870 -0
  40. package/runtime/python/okstra_vendor/jinja2/filters.py +1873 -0
  41. package/runtime/python/okstra_vendor/jinja2/idtracking.py +318 -0
  42. package/runtime/python/okstra_vendor/jinja2/lexer.py +868 -0
  43. package/runtime/python/okstra_vendor/jinja2/loaders.py +693 -0
  44. package/runtime/python/okstra_vendor/jinja2/meta.py +112 -0
  45. package/runtime/python/okstra_vendor/jinja2/nativetypes.py +130 -0
  46. package/runtime/python/okstra_vendor/jinja2/nodes.py +1206 -0
  47. package/runtime/python/okstra_vendor/jinja2/optimizer.py +48 -0
  48. package/runtime/python/okstra_vendor/jinja2/parser.py +1049 -0
  49. package/runtime/python/okstra_vendor/jinja2/py.typed +0 -0
  50. package/runtime/python/okstra_vendor/jinja2/runtime.py +1062 -0
  51. package/runtime/python/okstra_vendor/jinja2/sandbox.py +436 -0
  52. package/runtime/python/okstra_vendor/jinja2/tests.py +256 -0
  53. package/runtime/python/okstra_vendor/jinja2/utils.py +766 -0
  54. package/runtime/python/okstra_vendor/jinja2/visitor.py +92 -0
  55. package/runtime/python/okstra_vendor/markupsafe/__init__.py +396 -0
  56. package/runtime/python/okstra_vendor/markupsafe/_native.py +8 -0
  57. package/runtime/python/okstra_vendor/markupsafe/py.typed +0 -0
  58. package/runtime/schemas/final-report-v1.0.schema.json +1391 -0
  59. package/runtime/skills/okstra-report-writer/SKILL.md +31 -30
  60. package/runtime/skills/okstra-run/SKILL.md +6 -4
  61. package/runtime/skills/okstra-team-contract/SKILL.md +27 -3
  62. package/runtime/templates/reports/final-report.template.md +370 -405
  63. package/runtime/templates/reports/report.css +57 -4
  64. package/runtime/templates/reports/report.js +63 -7
  65. package/runtime/templates/reports/settings.template.json +1 -0
  66. package/runtime/validators/lib/fixtures.sh +7 -7
  67. package/runtime/validators/validate-report-views.py +24 -153
  68. package/runtime/validators/validate-run.py +102 -19
  69. package/src/install.mjs +21 -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
  )
@@ -154,7 +154,7 @@ GEMINI_WORKER_MODEL_EXECUTION_VALUE=""
154
154
  REPORT_WRITER_MODEL_DISPLAY=""
155
155
  REPORT_WRITER_MODEL_EXECUTION_VALUE=""
156
156
  DEFAULT_WORKERS="claude,codex,report-writer"
157
- DEFAULT_LEAD_MODEL_NAME="${OKSTRA_DEFAULT_LEAD_MODEL:-opus}"
157
+ DEFAULT_LEAD_MODEL_NAME="${OKSTRA_DEFAULT_LEAD_MODEL:-opus-4-6}"
158
158
  DEFAULT_CLAUDE_WORKER_MODEL_NAME="${OKSTRA_DEFAULT_CLAUDE_MODEL:-sonnet}"
159
159
  DEFAULT_CODEX_WORKER_MODEL_NAME="${OKSTRA_DEFAULT_CODEX_MODEL:-gpt-5.5}"
160
160
  DEFAULT_GEMINI_WORKER_MODEL_NAME="${OKSTRA_DEFAULT_GEMINI_MODEL:-auto}"
@@ -71,7 +71,7 @@ options:
71
71
  --yes Skip interactive prompting and confirmation. Requires all required arguments.
72
72
  --workers Comma-separated worker list for this run. Default: claude,codex,report-writer
73
73
  (Gemini worker is optional; add `gemini` explicitly, e.g. --workers claude,codex,gemini,report-writer)
74
- --lead-model Model for Claude lead. Default: OKSTRA_DEFAULT_LEAD_MODEL or opus
74
+ --lead-model Model for Claude lead. Default: OKSTRA_DEFAULT_LEAD_MODEL or opus-4-6
75
75
  --claude-model Model for Claude worker. Default: OKSTRA_DEFAULT_CLAUDE_MODEL or sonnet
76
76
  --codex-model Model for Codex worker. Default: OKSTRA_DEFAULT_CODEX_MODEL or gpt-5.5
77
77
  --gemini-model Model for Gemini worker. Default: OKSTRA_DEFAULT_GEMINI_MODEL or auto
@@ -98,7 +98,7 @@ options:
98
98
  -h, --help Show this help.
99
99
 
100
100
  model defaults:
101
- Claude lead: OKSTRA_DEFAULT_LEAD_MODEL or opus
101
+ Claude lead: OKSTRA_DEFAULT_LEAD_MODEL or opus-4-6
102
102
  Report writer worker: OKSTRA_DEFAULT_REPORT_WRITER_MODEL or Claude lead default
103
103
  Claude worker: OKSTRA_DEFAULT_CLAUDE_MODEL or sonnet
104
104
  Codex worker: OKSTRA_DEFAULT_CODEX_MODEL or gpt-5.5
@@ -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)
@@ -11,6 +11,8 @@ CLAUDE_MAPPING = {
11
11
  "opus": ("opus", "opus"),
12
12
  "opus-4-7": ("opus-4-7", "claude-opus-4-7"),
13
13
  "claude-opus-4-7": ("opus-4-7", "claude-opus-4-7"),
14
+ "opus-4-6": ("opus-4-6", "claude-opus-4-6"),
15
+ "claude-opus-4-6": ("opus-4-6", "claude-opus-4-6"),
14
16
  "sonnet": ("sonnet", "sonnet"),
15
17
  "sonnet-4-6": ("sonnet-4-6", "claude-sonnet-4-6"),
16
18
  "claude-sonnet-4-6": ("sonnet-4-6", "claude-sonnet-4-6"),
@@ -0,0 +1,201 @@
1
+ """Render `final-report-<task-type>-<seq>.md` from its JSON SSOT.
2
+
3
+ The JSON SSOT lives next to the rendered markdown as
4
+ ``final-report-<task-type>-<seq>.data.json``. The schema for that file is
5
+ ``schemas/final-report-v1.0.schema.json``. Report-writer-worker writes the
6
+ data.json in Phase 6; this renderer + the Jinja2 template at
7
+ ``templates/reports/final-report.template.md`` deterministically produce
8
+ the canonical user-facing markdown.
9
+
10
+ Why this exists: prior to v0.32, report-writer-worker wrote the markdown
11
+ directly. Free-form authoring led to silent contract violations — missing
12
+ columns in the Execution Status table, omitted §7 phase-continuation
13
+ rows, invented ``## Index`` sections. Routing everything through one
14
+ template + schema cuts those failure modes to zero.
15
+
16
+ Phase 7 mutation flow: ``okstra-token-usage.py --substitute-data`` fills
17
+ the ``tokenUsage`` and ``executionStatus[].totalTokens`` etc. cells in
18
+ data.json, then re-invokes this renderer so the markdown stays in sync.
19
+ The markdown is never hand-edited.
20
+ """
21
+ from __future__ import annotations
22
+
23
+ import json
24
+ import os
25
+ import sys
26
+ from pathlib import Path
27
+ from typing import Any
28
+
29
+ # Vendored jinja2 must be importable. The installer (``src/install.mjs``)
30
+ # drops ``okstra_vendor/`` under ``~/.okstra/lib/python/``; in-repo runs
31
+ # rely on ``scripts/`` being on PYTHONPATH. Either way the package
32
+ # registers ``markupsafe`` and ``jinja2`` aliases in ``sys.modules`` so
33
+ # downstream ``from jinja2 import ...`` resolves to the vendored copy.
34
+ import okstra_vendor # noqa: F401 — side effect: sys.modules aliases
35
+ from jinja2 import ChainableUndefined, Environment, FileSystemLoader
36
+ from jinja2 import select_autoescape # noqa: F401 — kept for future HTML use
37
+
38
+
39
+ DEFAULT_TEMPLATE_REL = ("templates", "reports", "final-report.template.md")
40
+
41
+
42
+ class RenderError(RuntimeError):
43
+ """Raised when the data.json cannot be rendered. Wraps jinja2 errors
44
+ and IO errors with a single user-facing message so the CLI / Phase 6
45
+ surface one consistent failure shape.
46
+ """
47
+
48
+
49
+ def _format_int(value: Any) -> str:
50
+ if value is None:
51
+ return "--"
52
+ try:
53
+ return f"{int(value):,}"
54
+ except (TypeError, ValueError):
55
+ return "--"
56
+
57
+
58
+ def _format_usd(value: Any) -> str:
59
+ if value is None:
60
+ return "--"
61
+ try:
62
+ return f"${float(value):.2f}"
63
+ except (TypeError, ValueError):
64
+ return "--"
65
+
66
+
67
+ def _format_duration_ms(value: Any) -> str:
68
+ if value is None:
69
+ return "--"
70
+ try:
71
+ ms = int(value)
72
+ except (TypeError, ValueError):
73
+ return "--"
74
+ total_seconds = ms // 1000
75
+ minutes, seconds = divmod(total_seconds, 60)
76
+ return f"{minutes}m {seconds:02d}s"
77
+
78
+
79
+ def _yaml_scalar(value: Any) -> str:
80
+ """Serialize a scalar for the YAML frontmatter block.
81
+
82
+ Strings: wrap in double quotes and escape embedded double-quote /
83
+ backslash so the YAML parser at consumer side (Obsidian / okstra
84
+ runtime) does not choke on accidental colons or square brackets in
85
+ the value. Bools / numbers / None: render as-is.
86
+ """
87
+ if value is None:
88
+ return ""
89
+ if isinstance(value, bool):
90
+ return "true" if value else "false"
91
+ if isinstance(value, (int, float)):
92
+ return str(value)
93
+ text = str(value)
94
+ escaped = text.replace("\\", "\\\\").replace('"', '\\"')
95
+ return f'"{escaped}"'
96
+
97
+
98
+ def _yaml_inline_list(values: list[str]) -> str:
99
+ """Render a list of strings as a YAML flow-style sequence on one line."""
100
+ return "[" + ", ".join(_yaml_scalar(v) for v in values) + "]"
101
+
102
+
103
+ def _build_environment(template_dir: Path) -> Environment:
104
+ # ChainableUndefined lets optional fields (e.g.
105
+ # ``clarificationCarryIn``, ``ticketCoverage.omit``) silently evaluate
106
+ # to false in `{% if %}` tests instead of raising. Strict presence
107
+ # checking is the schema validator's job, not the renderer's — and
108
+ # the renderer can't tell "the writer forgot this required field"
109
+ # from "this field is intentionally absent on this task-type".
110
+ env = Environment(
111
+ loader=FileSystemLoader(str(template_dir)),
112
+ undefined=ChainableUndefined,
113
+ trim_blocks=True,
114
+ lstrip_blocks=True,
115
+ keep_trailing_newline=True,
116
+ )
117
+ env.filters["format_int"] = _format_int
118
+ env.filters["format_usd"] = _format_usd
119
+ env.filters["format_duration_ms"] = _format_duration_ms
120
+ env.filters["yaml_scalar"] = _yaml_scalar
121
+ env.filters["yaml_inline_list"] = _yaml_inline_list
122
+ return env
123
+
124
+
125
+ def render(data: dict, *, template_path: Path) -> str:
126
+ """Render ``data`` through the Jinja2 ``template_path`` and return the
127
+ final markdown as a string. Caller writes it to disk.
128
+
129
+ Raises ``RenderError`` on any template / IO / data structural failure
130
+ so the surface is one exception type rather than the broad jinja2
131
+ hierarchy.
132
+ """
133
+ if not template_path.is_file():
134
+ raise RenderError(f"template not found: {template_path}")
135
+
136
+ env = _build_environment(template_path.parent)
137
+ try:
138
+ template = env.get_template(template_path.name)
139
+ return template.render(**data)
140
+ except Exception as exc: # jinja2.TemplateError, KeyError, etc.
141
+ raise RenderError(
142
+ f"render failed for template {template_path.name}: {exc}"
143
+ ) from exc
144
+
145
+
146
+ def find_default_template(start: Path | None = None) -> Path:
147
+ """Locate the bundled final-report template.
148
+
149
+ Resolution order:
150
+ 1. ``$OKSTRA_HOME/templates/reports/final-report.template.md`` (installed runtime).
151
+ 2. ``<repo>/templates/reports/final-report.template.md`` (in-repo dev runs).
152
+ Repo root is detected by walking up from this file until a
153
+ ``templates/reports/final-report.template.md`` exists.
154
+
155
+ Raises ``RenderError`` if neither path is present.
156
+ """
157
+ okstra_home = os.environ.get("OKSTRA_HOME")
158
+ if okstra_home:
159
+ candidate = Path(okstra_home).joinpath(*DEFAULT_TEMPLATE_REL)
160
+ if candidate.is_file():
161
+ return candidate
162
+
163
+ here = Path(start or __file__).resolve()
164
+ for parent in [here, *here.parents]:
165
+ candidate = parent.joinpath(*DEFAULT_TEMPLATE_REL)
166
+ if candidate.is_file():
167
+ return candidate
168
+
169
+ raise RenderError(
170
+ "could not locate final-report.template.md. Set OKSTRA_HOME or "
171
+ "run from a checkout that contains templates/reports/."
172
+ )
173
+
174
+
175
+ def render_to_file(
176
+ data_path: Path,
177
+ output_path: Path,
178
+ *,
179
+ template_path: Path | None = None,
180
+ ) -> int:
181
+ """Read ``data_path`` (JSON), render through Jinja2, write to
182
+ ``output_path``. Returns the number of bytes written.
183
+
184
+ The output is written atomically (write-then-rename) so a partial
185
+ write never produces a half-corrupt final-report on disk.
186
+ """
187
+ if not data_path.is_file():
188
+ raise RenderError(f"data file not found: {data_path}")
189
+ try:
190
+ data = json.loads(data_path.read_text(encoding="utf-8"))
191
+ except json.JSONDecodeError as exc:
192
+ raise RenderError(f"invalid JSON in {data_path}: {exc}") from exc
193
+
194
+ resolved_template = template_path or find_default_template(data_path)
195
+ rendered = render(data, template_path=resolved_template)
196
+
197
+ output_path.parent.mkdir(parents=True, exist_ok=True)
198
+ tmp = output_path.with_suffix(output_path.suffix + f".tmp.{os.getpid()}")
199
+ tmp.write_text(rendered, encoding="utf-8")
200
+ tmp.replace(output_path)
201
+ return len(rendered.encode("utf-8"))