okstra 0.31.0 → 0.32.1

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 (52) 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_ctl/wizard.py +16 -5
  12. package/runtime/python/okstra_token_usage/__init__.py +5 -1
  13. package/runtime/python/okstra_token_usage/cli.py +66 -36
  14. package/runtime/python/okstra_token_usage/report.py +148 -65
  15. package/runtime/python/okstra_vendor/__init__.py +37 -0
  16. package/runtime/python/okstra_vendor/jinja2/__init__.py +38 -0
  17. package/runtime/python/okstra_vendor/jinja2/_identifier.py +6 -0
  18. package/runtime/python/okstra_vendor/jinja2/async_utils.py +99 -0
  19. package/runtime/python/okstra_vendor/jinja2/bccache.py +408 -0
  20. package/runtime/python/okstra_vendor/jinja2/compiler.py +1998 -0
  21. package/runtime/python/okstra_vendor/jinja2/constants.py +20 -0
  22. package/runtime/python/okstra_vendor/jinja2/debug.py +191 -0
  23. package/runtime/python/okstra_vendor/jinja2/defaults.py +48 -0
  24. package/runtime/python/okstra_vendor/jinja2/environment.py +1672 -0
  25. package/runtime/python/okstra_vendor/jinja2/exceptions.py +166 -0
  26. package/runtime/python/okstra_vendor/jinja2/ext.py +870 -0
  27. package/runtime/python/okstra_vendor/jinja2/filters.py +1873 -0
  28. package/runtime/python/okstra_vendor/jinja2/idtracking.py +318 -0
  29. package/runtime/python/okstra_vendor/jinja2/lexer.py +868 -0
  30. package/runtime/python/okstra_vendor/jinja2/loaders.py +693 -0
  31. package/runtime/python/okstra_vendor/jinja2/meta.py +112 -0
  32. package/runtime/python/okstra_vendor/jinja2/nativetypes.py +130 -0
  33. package/runtime/python/okstra_vendor/jinja2/nodes.py +1206 -0
  34. package/runtime/python/okstra_vendor/jinja2/optimizer.py +48 -0
  35. package/runtime/python/okstra_vendor/jinja2/parser.py +1049 -0
  36. package/runtime/python/okstra_vendor/jinja2/py.typed +0 -0
  37. package/runtime/python/okstra_vendor/jinja2/runtime.py +1062 -0
  38. package/runtime/python/okstra_vendor/jinja2/sandbox.py +436 -0
  39. package/runtime/python/okstra_vendor/jinja2/tests.py +256 -0
  40. package/runtime/python/okstra_vendor/jinja2/utils.py +766 -0
  41. package/runtime/python/okstra_vendor/jinja2/visitor.py +92 -0
  42. package/runtime/python/okstra_vendor/markupsafe/__init__.py +396 -0
  43. package/runtime/python/okstra_vendor/markupsafe/_native.py +8 -0
  44. package/runtime/python/okstra_vendor/markupsafe/py.typed +0 -0
  45. package/runtime/schemas/final-report-v1.0.schema.json +1391 -0
  46. package/runtime/skills/okstra-report-writer/SKILL.md +29 -28
  47. package/runtime/templates/reports/final-report.template.md +370 -411
  48. package/runtime/templates/reports/report.css +12 -6
  49. package/runtime/validators/lib/fixtures.sh +7 -7
  50. package/runtime/validators/validate-report-views.py +24 -153
  51. package/runtime/validators/validate-run.py +102 -19
  52. package/src/install.mjs +20 -1
@@ -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"))