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.
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/agents/SKILL.md +3 -3
- package/runtime/agents/workers/report-writer-worker.md +45 -67
- package/runtime/bin/okstra-render-final-report.py +101 -0
- package/runtime/bin/okstra-render-report-views.py +17 -10
- package/runtime/bin/okstra-token-usage.py +3 -1
- package/runtime/python/okstra_ctl/final_report_schema.py +253 -0
- package/runtime/python/okstra_ctl/render_final_report.py +201 -0
- package/runtime/python/okstra_ctl/report_views.py +108 -305
- package/runtime/python/okstra_token_usage/__init__.py +5 -1
- package/runtime/python/okstra_token_usage/cli.py +66 -36
- package/runtime/python/okstra_token_usage/report.py +148 -65
- package/runtime/python/okstra_vendor/__init__.py +37 -0
- package/runtime/python/okstra_vendor/jinja2/__init__.py +38 -0
- package/runtime/python/okstra_vendor/jinja2/_identifier.py +6 -0
- package/runtime/python/okstra_vendor/jinja2/async_utils.py +99 -0
- package/runtime/python/okstra_vendor/jinja2/bccache.py +408 -0
- package/runtime/python/okstra_vendor/jinja2/compiler.py +1998 -0
- package/runtime/python/okstra_vendor/jinja2/constants.py +20 -0
- package/runtime/python/okstra_vendor/jinja2/debug.py +191 -0
- package/runtime/python/okstra_vendor/jinja2/defaults.py +48 -0
- package/runtime/python/okstra_vendor/jinja2/environment.py +1672 -0
- package/runtime/python/okstra_vendor/jinja2/exceptions.py +166 -0
- package/runtime/python/okstra_vendor/jinja2/ext.py +870 -0
- package/runtime/python/okstra_vendor/jinja2/filters.py +1873 -0
- package/runtime/python/okstra_vendor/jinja2/idtracking.py +318 -0
- package/runtime/python/okstra_vendor/jinja2/lexer.py +868 -0
- package/runtime/python/okstra_vendor/jinja2/loaders.py +693 -0
- package/runtime/python/okstra_vendor/jinja2/meta.py +112 -0
- package/runtime/python/okstra_vendor/jinja2/nativetypes.py +130 -0
- package/runtime/python/okstra_vendor/jinja2/nodes.py +1206 -0
- package/runtime/python/okstra_vendor/jinja2/optimizer.py +48 -0
- package/runtime/python/okstra_vendor/jinja2/parser.py +1049 -0
- package/runtime/python/okstra_vendor/jinja2/py.typed +0 -0
- package/runtime/python/okstra_vendor/jinja2/runtime.py +1062 -0
- package/runtime/python/okstra_vendor/jinja2/sandbox.py +436 -0
- package/runtime/python/okstra_vendor/jinja2/tests.py +256 -0
- package/runtime/python/okstra_vendor/jinja2/utils.py +766 -0
- package/runtime/python/okstra_vendor/jinja2/visitor.py +92 -0
- package/runtime/python/okstra_vendor/markupsafe/__init__.py +396 -0
- package/runtime/python/okstra_vendor/markupsafe/_native.py +8 -0
- package/runtime/python/okstra_vendor/markupsafe/py.typed +0 -0
- package/runtime/schemas/final-report-v1.0.schema.json +1391 -0
- package/runtime/skills/okstra-report-writer/SKILL.md +29 -28
- package/runtime/templates/reports/final-report.template.md +370 -411
- package/runtime/templates/reports/report.css +12 -6
- package/runtime/validators/lib/fixtures.sh +7 -7
- package/runtime/validators/validate-report-views.py +24 -153
- package/runtime/validators/validate-run.py +102 -19
- 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"))
|