okstra 0.158.1 → 0.160.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 (84) hide show
  1. package/README.md +1 -1
  2. package/docs/architecture/storage-model.md +2 -0
  3. package/docs/architecture.md +1 -1
  4. package/docs/cli.md +8 -3
  5. package/docs/for-ai/README.md +2 -2
  6. package/docs/for-ai/skills/okstra-inspect.md +3 -0
  7. package/docs/for-ai/skills/okstra-run.md +2 -1
  8. package/docs/for-ai/skills/okstra-user-response.md +5 -5
  9. package/docs/project-structure-overview.md +5 -1
  10. package/docs/task-process/implementation.md +28 -0
  11. package/package.json +1 -1
  12. package/runtime/BUILD.json +2 -2
  13. package/runtime/agents/workers/report-writer-worker.md +1 -1
  14. package/runtime/bin/okstra-claude-exec.sh +4 -1
  15. package/runtime/prompts/host-orchestration/README.md +18 -0
  16. package/runtime/prompts/host-orchestration/implementation.md +57 -0
  17. package/runtime/prompts/launch.template.md +10 -1
  18. package/runtime/prompts/lead/adapters/claude-code.md +1 -1
  19. package/runtime/prompts/lead/context-loader.md +5 -2
  20. package/runtime/prompts/lead/convergence.md +3 -1
  21. package/runtime/prompts/lead/plan-body-verification.md +21 -2
  22. package/runtime/prompts/lead/report-writer.md +1 -1
  23. package/runtime/prompts/lead/team-contract.md +2 -1
  24. package/runtime/prompts/profiles/_clarification-recommendation.md +11 -1
  25. package/runtime/prompts/profiles/_common-contract.md +3 -1
  26. package/runtime/prompts/profiles/implementation-planning.md +2 -0
  27. package/runtime/prompts/profiles/requirements-discovery.md +1 -1
  28. package/runtime/prompts/wizard/prompts.ko.json +3 -0
  29. package/runtime/python/okstra_ctl/clarification_items.py +9 -0
  30. package/runtime/python/okstra_ctl/codex_dispatch.py +6 -6
  31. package/runtime/python/okstra_ctl/convergence.py +168 -11
  32. package/runtime/python/okstra_ctl/dispatch_core.py +4 -2
  33. package/runtime/python/okstra_ctl/error_issue.py +640 -0
  34. package/runtime/python/okstra_ctl/error_report.py +56 -0
  35. package/runtime/python/okstra_ctl/error_zip.py +23 -10
  36. package/runtime/python/okstra_ctl/incremental_scope.py +159 -19
  37. package/runtime/python/okstra_ctl/initial_prompt_materialization.py +18 -5
  38. package/runtime/python/okstra_ctl/issue_signals.py +186 -0
  39. package/runtime/python/okstra_ctl/paths.py +38 -0
  40. package/runtime/python/okstra_ctl/plan_items_cli.py +167 -3
  41. package/runtime/python/okstra_ctl/profile_show.py +134 -0
  42. package/runtime/python/okstra_ctl/recap.py +63 -0
  43. package/runtime/python/okstra_ctl/render_final_report.py +11 -62
  44. package/runtime/python/okstra_ctl/report_html/filters.py +6 -1
  45. package/runtime/python/okstra_ctl/report_html/render.py +9 -8
  46. package/runtime/python/okstra_ctl/report_html/run_usage.py +110 -0
  47. package/runtime/python/okstra_ctl/report_html/view_models/error_analysis.py +69 -16
  48. package/runtime/python/okstra_ctl/report_html/visualizations.py +107 -14
  49. package/runtime/python/okstra_ctl/report_translation.py +4 -0
  50. package/runtime/python/okstra_ctl/report_views.py +7 -3
  51. package/runtime/python/okstra_ctl/run.py +41 -2
  52. package/runtime/python/okstra_ctl/run_audit.py +477 -0
  53. package/runtime/python/okstra_ctl/usage_cells.py +47 -0
  54. package/runtime/python/okstra_ctl/user_response.py +25 -10
  55. package/runtime/python/okstra_ctl/verdict_blocks.py +183 -0
  56. package/runtime/python/okstra_ctl/wizard.py +64 -10
  57. package/runtime/python/okstra_ctl/worker_audit_check.py +44 -0
  58. package/runtime/python/okstra_ctl/worker_audit_ledger.py +207 -0
  59. package/runtime/python/okstra_ctl/worker_heartbeat.py +9 -3
  60. package/runtime/python/okstra_ctl/worker_liveness.py +81 -9
  61. package/runtime/schemas/final-report-v1.0.schema.json +14 -0
  62. package/runtime/schemas/final-report-v2.0.schema.json +56 -2
  63. package/runtime/skills/okstra-inspect/SKILL.md +3 -1
  64. package/runtime/skills/okstra-inspect/facets/error-issue.md +77 -0
  65. package/runtime/skills/okstra-inspect/facets/run-audit.md +34 -0
  66. package/runtime/skills/okstra-run/SKILL.md +28 -10
  67. package/runtime/skills/okstra-user-response/SKILL.md +18 -18
  68. package/runtime/templates/reports/final-report.template.md +4 -0
  69. package/runtime/templates/reports/html/assets/base.css +14 -1
  70. package/runtime/templates/reports/html/base.template.html +42 -0
  71. package/runtime/templates/reports/html/i18n/en.json +30 -1
  72. package/runtime/templates/reports/html/i18n/ko.json +30 -1
  73. package/runtime/templates/reports/html/macros/forms.html +15 -0
  74. package/runtime/templates/reports/html/macros/visualizations.html +3 -2
  75. package/runtime/templates/reports/html/tasks/implementation-planning.template.html +1 -0
  76. package/runtime/templates/reports/i18n/en.json +2 -0
  77. package/runtime/validators/validate-run.py +331 -208
  78. package/runtime/validators/validate_session_conformance.py +102 -32
  79. package/src/cli-registry.mjs +34 -0
  80. package/src/commands/execute/incremental-scope.mjs +10 -0
  81. package/src/commands/execute/worker-audit-check.mjs +35 -0
  82. package/src/commands/inspect/error-issue.mjs +27 -0
  83. package/src/commands/inspect/profile-show.mjs +29 -0
  84. package/src/commands/inspect/run-audit.mjs +26 -0
@@ -1,4 +1,9 @@
1
- """CLI adapter for deterministic implementation-planning item extraction."""
1
+ """CLI adapter for deterministic implementation-planning item extraction.
2
+
3
+ `extract` / `validate` own the `P-*` queue; `collect-verdicts` / `apply-verdicts`
4
+ own the round's votes on that queue. Both halves exist so the round's fidelity
5
+ does not depend on a parser the lead re-writes each time.
6
+ """
2
7
  from __future__ import annotations
3
8
 
4
9
  import argparse
@@ -10,6 +15,7 @@ from typing import Any
10
15
 
11
16
  from .convergence_store import write_json_atomic
12
17
  from .plan_items import PlanItemContractError, extract_plan_items
18
+ from .verdict_blocks import VerdictBlock, VerdictBlockError, parse_verdict_blocks
13
19
 
14
20
 
15
21
  def _load_json_object(path: Path) -> dict[str, Any]:
@@ -49,6 +55,21 @@ def _parser() -> argparse.ArgumentParser:
49
55
  validate = commands.add_parser("validate")
50
56
  validate.add_argument("--data", type=Path, required=True)
51
57
  validate.add_argument("--items", type=Path, required=True)
58
+ collect = commands.add_parser(
59
+ "collect-verdicts",
60
+ help="read this round's worker responses into a verdicts envelope",
61
+ )
62
+ collect.add_argument("--result", action="append", default=[], required=True,
63
+ metavar="<worker-id>=<path>",
64
+ help="one worker's plan-verify result file (repeatable)")
65
+ collect.add_argument("--items", type=Path, required=True)
66
+ collect.add_argument("--output", type=Path, required=True)
67
+ apply_verdicts = commands.add_parser(
68
+ "apply-verdicts",
69
+ help="overwrite planBodyVerification.planItems[].verdicts in data.json",
70
+ )
71
+ apply_verdicts.add_argument("--data", type=Path, required=True)
72
+ apply_verdicts.add_argument("--verdicts", type=Path, required=True)
52
73
  return parser
53
74
 
54
75
 
@@ -66,11 +87,154 @@ def _validate(args: argparse.Namespace) -> dict[str, Any]:
66
87
  return {"ok": True, "operation": "validate", "path": str(args.items)}
67
88
 
68
89
 
90
+ def _split_result_arg(raw: str) -> tuple[str, Path]:
91
+ worker, separator, path = raw.partition("=")
92
+ if not separator or not worker.strip() or not path.strip():
93
+ raise PlanItemContractError(
94
+ f"--result must be <worker-id>=<path>, got: {raw}"
95
+ )
96
+ return worker.strip(), Path(path.strip())
97
+
98
+
99
+ def _assigned_item_ids(items_path: Path) -> list[str]:
100
+ envelope = _load_json_object(items_path)
101
+ items = envelope.get("items")
102
+ if not isinstance(items, list):
103
+ raise PlanItemContractError(f"items envelope has no `items` array: {items_path}")
104
+ ids: list[str] = []
105
+ for item in items:
106
+ item_id = item.get("id") if isinstance(item, Mapping) else None
107
+ if not isinstance(item_id, str) or not item_id:
108
+ raise PlanItemContractError(f"every item needs an `id`: {items_path}")
109
+ ids.append(item_id)
110
+ return ids
111
+
112
+
113
+ def _verdict_row(worker: str, block: VerdictBlock) -> dict[str, Any]:
114
+ """One `planItems[].verdicts[]` row. Optional fields stay absent when empty
115
+ so the recorded table shows what the worker actually said."""
116
+ row: dict[str, Any] = {"worker": worker, "verdict": block.verdict}
117
+ for key, value in (
118
+ ("breakageKind", block.breakage_kind),
119
+ ("fixability", block.fixability),
120
+ ("note", block.note),
121
+ ("priorDissent", block.prior_dissent),
122
+ ):
123
+ if value:
124
+ row[key] = value
125
+ return row
126
+
127
+
128
+ def _worker_blocks(
129
+ raw_results: list[str], assigned: set[str]
130
+ ) -> list[tuple[str, dict[str, VerdictBlock]]]:
131
+ """Each worker's parsed response, refusing any queue mismatch.
132
+
133
+ A missing vote and an invented item are both silent in a hand-written
134
+ parser; each is a round scored on a table that does not match the queue.
135
+ """
136
+ collected: list[tuple[str, dict[str, VerdictBlock]]] = []
137
+ for raw in raw_results:
138
+ worker, path = _split_result_arg(raw)
139
+ try:
140
+ blocks = parse_verdict_blocks(path.read_text(encoding="utf-8"))
141
+ except (OSError, UnicodeError) as exc:
142
+ raise PlanItemContractError(f"cannot read result {path}: {exc}") from exc
143
+ answered = set(blocks)
144
+ missing = sorted(assigned - answered)
145
+ if missing:
146
+ raise PlanItemContractError(
147
+ f"worker `{worker}` was assigned {len(assigned)} items but "
148
+ f"returned no verdict for {missing} — an unanswered item cannot "
149
+ f"be scored, and dropping it silently is what makes a round look "
150
+ f"complete when it is not"
151
+ )
152
+ unknown = sorted(answered - assigned)
153
+ if unknown:
154
+ raise PlanItemContractError(
155
+ f"worker `{worker}` returned verdicts for {unknown}, which are "
156
+ f"not in the persisted plan-item queue"
157
+ )
158
+ collected.append((worker, blocks))
159
+ return collected
160
+
161
+
162
+ def _collect_verdicts(args: argparse.Namespace) -> dict[str, Any]:
163
+ assigned = _assigned_item_ids(args.items)
164
+ collected = _worker_blocks(args.result, set(assigned))
165
+ envelope = {
166
+ "schemaVersion": "1.0",
167
+ "taskType": "implementation-planning",
168
+ "planItems": [
169
+ {
170
+ "id": item_id,
171
+ "verdicts": [
172
+ _verdict_row(worker, blocks[item_id])
173
+ for worker, blocks in collected
174
+ ],
175
+ }
176
+ for item_id in assigned
177
+ ],
178
+ }
179
+ write_json_atomic(args.output, envelope)
180
+ return {"ok": True, "operation": "collect-verdicts", "path": str(args.output)}
181
+
182
+
183
+ def _plan_body_items(data: dict[str, Any], data_path: Path) -> list[dict[str, Any]]:
184
+ verification = _planning(data).get("planBodyVerification")
185
+ if not isinstance(verification, Mapping):
186
+ raise PlanItemContractError(
187
+ f"implementationPlanning.planBodyVerification must be an object: {data_path}"
188
+ )
189
+ items = verification.get("planItems")
190
+ if not isinstance(items, list):
191
+ raise PlanItemContractError(
192
+ f"planBodyVerification.planItems must be an array: {data_path}"
193
+ )
194
+ return items
195
+
196
+
197
+ def _apply_verdicts(args: argparse.Namespace) -> dict[str, Any]:
198
+ data = _load_json_object(args.data)
199
+ incoming = _load_json_object(args.verdicts).get("planItems")
200
+ if not isinstance(incoming, list):
201
+ raise PlanItemContractError("verdicts envelope has no `planItems` array")
202
+ rows = {
203
+ item["id"]: item.get("verdicts", [])
204
+ for item in incoming
205
+ if isinstance(item, Mapping) and isinstance(item.get("id"), str)
206
+ }
207
+ recorded = _plan_body_items(data, args.data)
208
+ known = {item.get("id") for item in recorded if isinstance(item, Mapping)}
209
+ missing = sorted(item_id for item_id in rows if item_id not in known)
210
+ if missing:
211
+ raise PlanItemContractError(
212
+ f"the report's planBodyVerification has no row for {missing} — the "
213
+ f"gate is re-derived from that table, so a verdict with nowhere to "
214
+ f"land would be scored as if it were never cast"
215
+ )
216
+ for item in recorded:
217
+ if isinstance(item, Mapping) and item.get("id") in rows:
218
+ # Overwrite, never merge: the contract records one round at a time,
219
+ # and a merged table lets a previous round's votes keep voting.
220
+ item["verdicts"] = rows[item["id"]]
221
+ write_json_atomic(args.data, data)
222
+ return {"ok": True, "operation": "apply-verdicts", "path": str(args.data)}
223
+
224
+
225
+ _HANDLERS = {
226
+ "extract": _extract,
227
+ "validate": _validate,
228
+ "collect-verdicts": _collect_verdicts,
229
+ "apply-verdicts": _apply_verdicts,
230
+ }
231
+
232
+
69
233
  def main(argv: list[str] | None = None) -> int:
70
234
  args = _parser().parse_args(argv)
71
235
  try:
72
- result = _extract(args) if args.command == "extract" else _validate(args)
73
- except (PlanItemContractError, OSError, ValueError) as exc:
236
+ result = _HANDLERS[args.command](args)
237
+ except (PlanItemContractError, VerdictBlockError, OSError, ValueError) as exc:
74
238
  print(f"plan-items: {exc}", file=sys.stderr)
75
239
  return 2
76
240
  print(json.dumps(result, ensure_ascii=False, indent=2))
@@ -0,0 +1,134 @@
1
+ """Read-only flattened view of a phase profile (`okstra profile show`).
2
+
3
+ A profile is assembled from three places — the top-level body, its
4
+ `{{INCLUDE:}}` targets, and the lazy-read sidecars the body's own table names —
5
+ so grepping the top-level file and finding nothing does not mean the rule is
6
+ absent. This prints the whole thing, so one grep answers "does this task-type
7
+ cover X".
8
+
9
+ Read-only is the point, not a nicety: `render-bundle` would answer the same
10
+ question, but it writes a manifest and registers the run in `recent.jsonl`.
11
+ That side effect is exactly why it cannot be used to look something up.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import argparse
16
+ import os
17
+ import re
18
+ import sys
19
+ from pathlib import Path
20
+
21
+ from .paths import find_asset_root
22
+ from .run import PrepareError, _expand_profile_includes
23
+
24
+ _PROFILES_REL = ("prompts", "profiles")
25
+
26
+ # The sidecar table spells its targets as inline-code repo-relative paths, e.g.
27
+ # `prompts/profiles/_implementation-executor.md`. Reading the list from the
28
+ # profile body rather than hard-coding it here is deliberate: a list in code
29
+ # goes stale the moment a profile adds a sidecar, and does so silently.
30
+ _SIDECAR_RE = re.compile(r"`(prompts/profiles/_[\w-]+\.md)`")
31
+
32
+
33
+ def workspace_root(start: Path | None = None) -> Path:
34
+ """Locate the runtime root that carries the `prompts/profiles` tree."""
35
+ root = find_asset_root(_PROFILES_REL, start=start, is_present=Path.is_dir)
36
+ if root is not None:
37
+ return root
38
+
39
+ raise PrepareError(
40
+ "could not locate prompts/profiles. Set OKSTRA_HOME or run from a "
41
+ "checkout that contains prompts/profiles/."
42
+ )
43
+
44
+
45
+ def profile_path(root: Path, task_type: str) -> Path:
46
+ path = root.joinpath(*_PROFILES_REL, f"{task_type}.md")
47
+ if not path.is_file():
48
+ raise PrepareError(f"unknown task-type: {task_type} (no {path})")
49
+ return path
50
+
51
+
52
+ def sidecar_bodies(root: Path, profile_text: str) -> list[str]:
53
+ """Every lazy-read sidecar reachable from the profile body, breadth-first.
54
+
55
+ The walk runs to a fixpoint because sidecars name sidecars of their own:
56
+ `_implementation-executor.md` points at the coding-conventions preflight,
57
+ the diff-review sweep, and the completion self-check. Stopping after one
58
+ hop would rebuild the very false negative this command exists to prevent,
59
+ one level down.
60
+
61
+ Each body is expanded rather than read raw, so nested `{{INCLUDE:}}`
62
+ directives resolve and the maintainer-only HTML comments drop out — the
63
+ same treatment the lead's rendered profile gets.
64
+ """
65
+ bodies: list[str] = []
66
+ seen: set[str] = set()
67
+ pending = list(dict.fromkeys(_SIDECAR_RE.findall(profile_text)))
68
+ while pending:
69
+ relative = pending.pop(0)
70
+ if relative in seen:
71
+ continue
72
+ seen.add(relative)
73
+ path = root / relative
74
+ if not path.is_file():
75
+ continue
76
+ body = _expand_profile_includes(path)
77
+ bodies.append(f"\n\n<!-- lazy-read sidecar: {relative} -->\n\n{body}")
78
+ pending.extend(ref for ref in _SIDECAR_RE.findall(body) if ref not in seen)
79
+ return bodies
80
+
81
+
82
+ def render(root: Path, task_type: str, *, resolved: bool) -> str:
83
+ path = profile_path(root, task_type)
84
+ if not resolved:
85
+ return path.read_text(encoding="utf-8")
86
+ expanded = _expand_profile_includes(path)
87
+ return expanded + "".join(sidecar_bodies(root, expanded))
88
+
89
+
90
+ def _write_stdout(text: str) -> None:
91
+ """Write the profile, tolerating a reader that stops early.
92
+
93
+ The command's whole purpose is to be piped into `grep` or `head`, and both
94
+ close the pipe as soon as they have enough. Without this, that normal usage
95
+ ends in a BrokenPipeError traceback — and the interpreter raises a second
96
+ one when it flushes stdout at shutdown, which is why the fd is redirected
97
+ to devnull rather than merely swallowing the first exception.
98
+ """
99
+ try:
100
+ sys.stdout.write(text)
101
+ sys.stdout.flush()
102
+ except BrokenPipeError:
103
+ devnull = os.open(os.devnull, os.O_WRONLY)
104
+ try:
105
+ os.dup2(devnull, sys.stdout.fileno())
106
+ finally:
107
+ os.close(devnull)
108
+
109
+
110
+ def main(argv: list[str] | None = None) -> int:
111
+ parser = argparse.ArgumentParser(
112
+ prog="okstra profile show",
113
+ description="Print a phase profile, optionally fully resolved (read-only).",
114
+ )
115
+ parser.add_argument("command", choices=("show",))
116
+ parser.add_argument("task_type")
117
+ parser.add_argument(
118
+ "--resolved",
119
+ action="store_true",
120
+ help="expand {{INCLUDE:}} targets and append the lazy-read sidecars",
121
+ )
122
+ args = parser.parse_args(argv)
123
+
124
+ try:
125
+ text = render(workspace_root(), args.task_type, resolved=args.resolved)
126
+ except PrepareError as exc:
127
+ print(f"profile show: {exc}", file=sys.stderr)
128
+ return 2
129
+ _write_stdout(text)
130
+ return 0
131
+
132
+
133
+ if __name__ == "__main__":
134
+ raise SystemExit(main(sys.argv[1:]))
@@ -11,7 +11,9 @@ import json
11
11
  import sys
12
12
  from pathlib import Path
13
13
 
14
+ from okstra_ctl.clarification_items import sidecar_answers, user_response_sidecars
14
15
  from okstra_ctl.ids import slugify_task_segment
16
+ from okstra_ctl.incremental_scope import preview_link_availability_for_report
15
17
  from okstra_ctl.paths import task_timeline_file
16
18
  from okstra_project import read_task_key
17
19
  from okstra_ctl.run_context import dir_flock
@@ -33,6 +35,63 @@ def _load_timeline(task_root: Path) -> list[dict]:
33
35
 
34
36
 
35
37
 
38
+ _STRUCTURAL_CHANGE_NOTE = (
39
+ "An answer that overturns the selected option, restructures the Stage Map, "
40
+ 'or changes the recommended approach requires --full-reason "<what changes '
41
+ 'and how>". The back-trace resolves stages; it cannot judge whether the '
42
+ "plan's shape survived, so that call is the lead's and must be declared."
43
+ )
44
+
45
+
46
+ def _latest_planning_report(runs: list[dict], project_root: Path) -> Path | None:
47
+ """The most recent `implementation-planning` report still on disk."""
48
+ for run in reversed(runs):
49
+ if not isinstance(run, dict):
50
+ continue
51
+ if run.get("taskType") != "implementation-planning":
52
+ continue
53
+ relative = str(run.get("reportPath") or "")
54
+ if not relative:
55
+ continue
56
+ report = project_root / relative
57
+ if report.is_file():
58
+ return report
59
+ return None
60
+
61
+
62
+ def rerun_readiness(project_root: Path, runs: list[dict]) -> dict | None:
63
+ """What the next clarification re-run needs, assembled from disk.
64
+
65
+ Every field is derivable before the run starts, and each one used to live
66
+ somewhere else: the flag value in the lead prompt (read only *after* the
67
+ run begins), the answered ids in sidecars, the re-verification mode nowhere
68
+ at all until `incremental-scope --preview`. Scattering them is why the user
69
+ had to ask for each one instead of being told.
70
+
71
+ ``None`` when nothing is waiting to be carried — no planning report, or no
72
+ answered clarification beside it.
73
+ """
74
+ report = _latest_planning_report(runs, project_root)
75
+ if report is None:
76
+ return None
77
+ answered = sorted(sidecar_answers(report))
78
+ if not answered:
79
+ return None
80
+ return {
81
+ "sourceReport": project_rel(report, project_root),
82
+ "answeredClarifications": answered,
83
+ "answeredClarificationsCsv": ",".join(answered),
84
+ "sidecars": [
85
+ project_rel(sidecar, project_root)
86
+ for sidecar in user_response_sidecars(report)
87
+ ],
88
+ "reverifyPreview": preview_link_availability_for_report(
89
+ report, set(answered)
90
+ ),
91
+ "structuralChangeNote": _STRUCTURAL_CHANGE_NOTE,
92
+ }
93
+
94
+
36
95
  def assemble_recap(task_root: Path, project_root: Path) -> dict:
37
96
  runs = _load_timeline(task_root)
38
97
  transitions = []
@@ -62,6 +121,10 @@ def assemble_recap(task_root: Path, project_root: Path) -> dict:
62
121
  "runCount": len(runs),
63
122
  "transitions": transitions,
64
123
  "latestPhaseStates": latest_states,
124
+ # `null` when nothing is waiting to be carried — the key is always
125
+ # present so a consumer can tell "no re-run pending" from "this recap
126
+ # predates the block".
127
+ "rerunReadiness": rerun_readiness(project_root, runs),
65
128
  }
66
129
 
67
130
 
@@ -52,10 +52,12 @@ from okstra_ctl.final_report_schema import (
52
52
  from okstra_ctl.i18n import I18nError, SUPPORTED_LANGS, load_dictionary, make_jinja_global
53
53
  from okstra_ctl.md_table import UNESCAPED_PIPE_RE, to_cell_text
54
54
  from okstra_ctl.models import UnknownModelError, resolve_model_metadata
55
+ from okstra_ctl.paths import find_asset_root
55
56
  from okstra_ctl.report_contract import TASK_TYPE_DATA_PROPERTY, markdown_template_for
56
57
  from okstra_ctl.report_markdown import ReportSections
57
58
  from okstra_ctl.schema_excerpt import excerpt_cut_from_version
58
59
  from okstra_ctl.seeding import installed_version
60
+ from okstra_ctl.usage_cells import format_duration_ms, format_int, format_usd
59
61
 
60
62
 
61
63
  TEMPLATE_BY_SCHEMA_VERSION = {
@@ -84,43 +86,6 @@ class FinalReportRenderError(RuntimeError):
84
86
  """
85
87
 
86
88
 
87
- def _format_int(value: Any) -> str:
88
- if value is None or not isinstance(value, (str, int, float)):
89
- return "--"
90
- try:
91
- return f"{int(value):,}"
92
- except (TypeError, ValueError):
93
- return "--"
94
-
95
-
96
- def _format_usd(value: Any) -> str:
97
- if value is None or not isinstance(value, (str, int, float)):
98
- return "--"
99
- try:
100
- return f"${float(value):.2f}"
101
- except (TypeError, ValueError):
102
- return "--"
103
-
104
-
105
- def _format_duration_ms(value: Any) -> str:
106
- if value is None or not isinstance(value, (str, int, float)):
107
- return "--"
108
- try:
109
- ms = int(value)
110
- except (TypeError, ValueError):
111
- return "--"
112
- # A negative elapsed time is nonsensical (clock skew between start/end
113
- # timestamps); divmod would otherwise produce a malformed "-1m 59s".
114
- if ms < 0:
115
- return "--"
116
- total_seconds = ms // 1000
117
- hours, remainder = divmod(total_seconds, 3600)
118
- minutes, seconds = divmod(remainder, 60)
119
- if hours:
120
- return f"{hours}h {minutes:02d}m {seconds:02d}s"
121
- return f"{minutes}m {seconds:02d}s"
122
-
123
-
124
89
  def _yaml_scalar(value: Any) -> str:
125
90
  """Serialize a scalar for the YAML frontmatter block.
126
91
 
@@ -555,9 +520,9 @@ def _build_environment(template_dir: Path) -> Environment:
555
520
  lstrip_blocks=True,
556
521
  keep_trailing_newline=True,
557
522
  )
558
- env.filters["format_int"] = _format_int
559
- env.filters["format_usd"] = _format_usd
560
- env.filters["format_duration_ms"] = _format_duration_ms
523
+ env.filters["format_int"] = format_int
524
+ env.filters["format_usd"] = format_usd
525
+ env.filters["format_duration_ms"] = format_duration_ms
561
526
  env.filters["yaml_scalar"] = _yaml_scalar
562
527
  env.filters["yaml_inline_list"] = _yaml_inline_list
563
528
  env.filters["model_detail"] = _model_detail
@@ -730,17 +695,9 @@ def find_default_template(start: Path | None = None) -> Path:
730
695
 
731
696
  Raises ``FinalReportRenderError`` if neither path is present.
732
697
  """
733
- okstra_home = os.environ.get("OKSTRA_HOME")
734
- if okstra_home:
735
- candidate = Path(okstra_home).joinpath(*DEFAULT_TEMPLATE_REL)
736
- if candidate.is_file():
737
- return candidate
738
-
739
- here = Path(start or __file__).resolve()
740
- for parent in [here, *here.parents]:
741
- candidate = parent.joinpath(*DEFAULT_TEMPLATE_REL)
742
- if candidate.is_file():
743
- return candidate
698
+ root = find_asset_root(DEFAULT_TEMPLATE_REL, start=start)
699
+ if root is not None:
700
+ return root.joinpath(*DEFAULT_TEMPLATE_REL)
744
701
 
745
702
  raise FinalReportRenderError(
746
703
  "could not locate final-report.template.md. Set OKSTRA_HOME or "
@@ -760,17 +717,9 @@ def find_default_template_for_data(
760
717
  f"unsupported final-report schemaVersion: {version}"
761
718
  ) from exc
762
719
 
763
- okstra_home = os.environ.get("OKSTRA_HOME")
764
- if okstra_home:
765
- candidate = Path(okstra_home).joinpath(*relative_path)
766
- if candidate.is_file():
767
- return candidate
768
-
769
- here = Path(start or __file__).resolve()
770
- for parent in [here, *here.parents]:
771
- candidate = parent.joinpath(*relative_path)
772
- if candidate.is_file():
773
- return candidate
720
+ root = find_asset_root(relative_path, start=start)
721
+ if root is not None:
722
+ return root.joinpath(*relative_path)
774
723
 
775
724
  raise FinalReportRenderError(
776
725
  f"could not locate {relative_path[-1]}. Set OKSTRA_HOME or run from a "
@@ -9,7 +9,12 @@ from markupsafe import Markup, escape
9
9
  _INLINE_CODE = re.compile(r"`([^`]+)`")
10
10
  _SENTENCE_END = re.compile(r"(?<=[.!?])\s+")
11
11
  _SENTENCES_PER_PARAGRAPH = 2
12
- _ID_TOKEN = re.compile(r"\b[A-Z]{1,3}-\d+\b")
12
+ # `\b` after the digits would end the token only where the next character is
13
+ # non-word, and a Korean particle is a word character — `EA-001에` matched
14
+ # nothing, so every id a translated report cites mid-sentence lost its link
15
+ # while the English source kept it. The boundary a row id actually needs is
16
+ # "not part of a longer alphanumeric run", which is what these assertions say.
17
+ _ID_TOKEN = re.compile(r"(?<![A-Za-z0-9])[A-Z]{1,3}-\d+(?!\d)")
13
18
 
14
19
 
15
20
  def _link_ids(escaped: str, anchors: dict) -> str:
@@ -14,6 +14,7 @@ from ..final_report_schema import load_schema_for_data, validate
14
14
  from ..i18n import HTML_DICTIONARY_REL, load_dictionary, make_jinja_global
15
15
  from ..report_translation import overlay
16
16
  from ..report_view_artifacts import user_responses_dir_for_report
17
+ from ..usage_cells import format_duration_ms
17
18
  from .common import anchor_index
18
19
  from .filters import (
19
20
  code_evidence,
@@ -26,6 +27,7 @@ from .filters import (
26
27
  from .models import HtmlRunMeta
27
28
  from .report_index import inject_report_index
28
29
  from .router import HtmlRenderError, resolve_html_route
30
+ from .run_usage import run_usage
29
31
 
30
32
 
31
33
  def _sha256(path: Path) -> str:
@@ -47,17 +49,13 @@ def _elapsed_text(elapsed_ms: int | None) -> str | None:
47
49
  """Render a run duration, or nothing when there is none to render.
48
50
 
49
51
  A run whose team-state never recorded timestamps has no measured duration;
50
- printing "0m" would claim it finished instantly.
52
+ printing "0m" would claim it finished instantly. Measured durations go
53
+ through the same formatter as the per-agent cells below, so the header and
54
+ the run-cost table do not spell one quantity two ways.
51
55
  """
52
56
  if not elapsed_ms or elapsed_ms < 0:
53
57
  return None
54
- minutes, seconds = divmod(round(elapsed_ms / 1000), 60)
55
- hours, minutes = divmod(minutes, 60)
56
- if hours:
57
- return f"{hours}h {minutes}m"
58
- if minutes:
59
- return f"{minutes}m {seconds}s"
60
- return f"{seconds}s"
58
+ return format_duration_ms(elapsed_ms)
61
59
 
62
60
 
63
61
  def _report_meta(data: dict, run_meta: HtmlRunMeta) -> dict[str, object]:
@@ -152,6 +150,9 @@ def render_v2_html_view(
152
150
  "dataSha256": _sha256(data_path),
153
151
  "markdownSha256": _sha256(markdown_path),
154
152
  "clarificationItems": data.get("clarificationItems", []),
153
+ # Every task type ends with the same run-cost section, so it is bound
154
+ # here rather than in ten view models that would each rebuild it.
155
+ "runUsage": run_usage(data),
155
156
  "css": (root / "html/assets/base.css").read_text(encoding="utf-8"),
156
157
  "js": response_js + "\n" + base_js,
157
158
  }