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.
- package/docs/kr/architecture.md +2 -2
- package/docs/kr/cli.md +2 -2
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/agents/SKILL.md +7 -5
- package/runtime/agents/workers/claude-worker.md +1 -1
- package/runtime/agents/workers/codex-worker.md +23 -6
- package/runtime/agents/workers/gemini-worker.md +23 -6
- package/runtime/agents/workers/report-writer-worker.md +45 -66
- package/runtime/bin/okstra-codex-exec.sh +31 -0
- package/runtime/bin/okstra-gemini-exec.sh +26 -0
- 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/lib/okstra/globals.sh +1 -1
- package/runtime/python/lib/okstra/usage.sh +2 -2
- package/runtime/python/okstra_ctl/final_report_schema.py +253 -0
- package/runtime/python/okstra_ctl/models.py +2 -0
- package/runtime/python/okstra_ctl/render_final_report.py +201 -0
- package/runtime/python/okstra_ctl/report_views.py +276 -297
- package/runtime/python/okstra_ctl/run.py +1 -1
- package/runtime/python/okstra_ctl/wizard.py +53 -14
- package/runtime/python/okstra_ctl/workers.py +45 -11
- 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/pricing.py +1 -0
- 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 +31 -30
- package/runtime/skills/okstra-run/SKILL.md +6 -4
- package/runtime/skills/okstra-team-contract/SKILL.md +27 -3
- package/runtime/templates/reports/final-report.template.md +370 -405
- package/runtime/templates/reports/report.css +57 -4
- package/runtime/templates/reports/report.js +63 -7
- package/runtime/templates/reports/settings.template.json +1 -0
- 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 +21 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
"""CLI entrypoint for Phase 7 step 1.5 — render
|
|
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
|
-
|
|
17
|
-
- <stem>.
|
|
18
|
-
- <stem>.html — single-file self-contained HTML view
|
|
16
|
+
Output (idempotent — overwrites):
|
|
17
|
+
- <stem>.html — single-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,
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
|
@@ -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"))
|