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.
- package/README.md +1 -1
- package/docs/architecture/storage-model.md +2 -0
- package/docs/architecture.md +1 -1
- package/docs/cli.md +8 -3
- package/docs/for-ai/README.md +2 -2
- package/docs/for-ai/skills/okstra-inspect.md +3 -0
- package/docs/for-ai/skills/okstra-run.md +2 -1
- package/docs/for-ai/skills/okstra-user-response.md +5 -5
- package/docs/project-structure-overview.md +5 -1
- package/docs/task-process/implementation.md +28 -0
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/agents/workers/report-writer-worker.md +1 -1
- package/runtime/bin/okstra-claude-exec.sh +4 -1
- package/runtime/prompts/host-orchestration/README.md +18 -0
- package/runtime/prompts/host-orchestration/implementation.md +57 -0
- package/runtime/prompts/launch.template.md +10 -1
- package/runtime/prompts/lead/adapters/claude-code.md +1 -1
- package/runtime/prompts/lead/context-loader.md +5 -2
- package/runtime/prompts/lead/convergence.md +3 -1
- package/runtime/prompts/lead/plan-body-verification.md +21 -2
- package/runtime/prompts/lead/report-writer.md +1 -1
- package/runtime/prompts/lead/team-contract.md +2 -1
- package/runtime/prompts/profiles/_clarification-recommendation.md +11 -1
- package/runtime/prompts/profiles/_common-contract.md +3 -1
- package/runtime/prompts/profiles/implementation-planning.md +2 -0
- package/runtime/prompts/profiles/requirements-discovery.md +1 -1
- package/runtime/prompts/wizard/prompts.ko.json +3 -0
- package/runtime/python/okstra_ctl/clarification_items.py +9 -0
- package/runtime/python/okstra_ctl/codex_dispatch.py +6 -6
- package/runtime/python/okstra_ctl/convergence.py +168 -11
- package/runtime/python/okstra_ctl/dispatch_core.py +4 -2
- package/runtime/python/okstra_ctl/error_issue.py +640 -0
- package/runtime/python/okstra_ctl/error_report.py +56 -0
- package/runtime/python/okstra_ctl/error_zip.py +23 -10
- package/runtime/python/okstra_ctl/incremental_scope.py +159 -19
- package/runtime/python/okstra_ctl/initial_prompt_materialization.py +18 -5
- package/runtime/python/okstra_ctl/issue_signals.py +186 -0
- package/runtime/python/okstra_ctl/paths.py +38 -0
- package/runtime/python/okstra_ctl/plan_items_cli.py +167 -3
- package/runtime/python/okstra_ctl/profile_show.py +134 -0
- package/runtime/python/okstra_ctl/recap.py +63 -0
- package/runtime/python/okstra_ctl/render_final_report.py +11 -62
- package/runtime/python/okstra_ctl/report_html/filters.py +6 -1
- package/runtime/python/okstra_ctl/report_html/render.py +9 -8
- package/runtime/python/okstra_ctl/report_html/run_usage.py +110 -0
- package/runtime/python/okstra_ctl/report_html/view_models/error_analysis.py +69 -16
- package/runtime/python/okstra_ctl/report_html/visualizations.py +107 -14
- package/runtime/python/okstra_ctl/report_translation.py +4 -0
- package/runtime/python/okstra_ctl/report_views.py +7 -3
- package/runtime/python/okstra_ctl/run.py +41 -2
- package/runtime/python/okstra_ctl/run_audit.py +477 -0
- package/runtime/python/okstra_ctl/usage_cells.py +47 -0
- package/runtime/python/okstra_ctl/user_response.py +25 -10
- package/runtime/python/okstra_ctl/verdict_blocks.py +183 -0
- package/runtime/python/okstra_ctl/wizard.py +64 -10
- package/runtime/python/okstra_ctl/worker_audit_check.py +44 -0
- package/runtime/python/okstra_ctl/worker_audit_ledger.py +207 -0
- package/runtime/python/okstra_ctl/worker_heartbeat.py +9 -3
- package/runtime/python/okstra_ctl/worker_liveness.py +81 -9
- package/runtime/schemas/final-report-v1.0.schema.json +14 -0
- package/runtime/schemas/final-report-v2.0.schema.json +56 -2
- package/runtime/skills/okstra-inspect/SKILL.md +3 -1
- package/runtime/skills/okstra-inspect/facets/error-issue.md +77 -0
- package/runtime/skills/okstra-inspect/facets/run-audit.md +34 -0
- package/runtime/skills/okstra-run/SKILL.md +28 -10
- package/runtime/skills/okstra-user-response/SKILL.md +18 -18
- package/runtime/templates/reports/final-report.template.md +4 -0
- package/runtime/templates/reports/html/assets/base.css +14 -1
- package/runtime/templates/reports/html/base.template.html +42 -0
- package/runtime/templates/reports/html/i18n/en.json +30 -1
- package/runtime/templates/reports/html/i18n/ko.json +30 -1
- package/runtime/templates/reports/html/macros/forms.html +15 -0
- package/runtime/templates/reports/html/macros/visualizations.html +3 -2
- package/runtime/templates/reports/html/tasks/implementation-planning.template.html +1 -0
- package/runtime/templates/reports/i18n/en.json +2 -0
- package/runtime/validators/validate-run.py +331 -208
- package/runtime/validators/validate_session_conformance.py +102 -32
- package/src/cli-registry.mjs +34 -0
- package/src/commands/execute/incremental-scope.mjs +10 -0
- package/src/commands/execute/worker-audit-check.mjs +35 -0
- package/src/commands/inspect/error-issue.mjs +27 -0
- package/src/commands/inspect/profile-show.mjs +29 -0
- 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 =
|
|
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"] =
|
|
559
|
-
env.filters["format_usd"] =
|
|
560
|
-
env.filters["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
|
-
|
|
734
|
-
if
|
|
735
|
-
|
|
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
|
-
|
|
764
|
-
if
|
|
765
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|