okstra 0.154.1 → 0.155.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/architecture.md +1 -0
- package/docs/cli.md +1 -1
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/bin/okstra-compact-reminder.sh +34 -0
- package/runtime/bin/okstra-render-final-report.py +2 -0
- package/runtime/prompts/lead/adapters/claude-code.md +1 -0
- package/runtime/prompts/lead/convergence.md +2 -0
- package/runtime/prompts/lead/okstra-lead-contract.md +4 -1
- package/runtime/prompts/lead/plan-body-verification.md +13 -2
- package/runtime/prompts/lead/team-contract.md +40 -14
- package/runtime/python/okstra_ctl/convergence.py +31 -0
- package/runtime/python/okstra_ctl/convergence_provenance.py +185 -0
- package/runtime/python/okstra_ctl/dispatch_state.py +6 -0
- package/runtime/python/okstra_ctl/pane_reclaim.py +30 -6
- package/runtime/python/okstra_ctl/render_final_report.py +29 -0
- package/runtime/python/okstra_ctl/report_html/render.py +7 -2
- package/runtime/python/okstra_ctl/report_html/report_index.py +75 -0
- package/runtime/python/okstra_ctl/worker_liveness.py +113 -8
- package/runtime/python/okstra_ctl/worker_prompt_contract.py +32 -17
- package/runtime/skills/okstra-run/SKILL.md +2 -0
- package/runtime/templates/reports/html/assets/base.css +8 -1
- package/runtime/templates/reports/html/i18n/en.json +1 -0
- package/runtime/templates/reports/html/i18n/ko.json +1 -0
- package/runtime/templates/reports/html/macros/forms.html +1 -1
- package/runtime/templates/reports/settings.template.json +11 -0
- package/runtime/validators/validate-run.py +185 -161
- package/runtime/validators/validate_session_conformance.py +57 -0
- package/src/cli-registry.mjs +7 -0
- package/src/commands/execute/plan-verify.mjs +44 -0
- package/src/lib/python-helper.mjs +37 -11
|
@@ -870,3 +870,32 @@ def render_to_file(
|
|
|
870
870
|
tmp.write_text(rendered, encoding="utf-8")
|
|
871
871
|
tmp.replace(output_path)
|
|
872
872
|
return len(rendered.encode("utf-8"))
|
|
873
|
+
|
|
874
|
+
|
|
875
|
+
def snapshot_last_valid(data_path: Path) -> Path | None:
|
|
876
|
+
"""Keep the data.json that just rendered, as `<name>.last-valid`.
|
|
877
|
+
|
|
878
|
+
The gate blocks in this file are hand-edited between self-fix rounds, and a
|
|
879
|
+
write in the wrong shape destroys the previous round's verdicts. `.okstra`
|
|
880
|
+
is conventionally gitignored, so there is no version history to fall back
|
|
881
|
+
on — recovery has meant scraping the JSON back out of the last rendered
|
|
882
|
+
markdown. This snapshot is that fallback, replaced only after a render that
|
|
883
|
+
passed schema enforcement, so it is always a document that renders.
|
|
884
|
+
|
|
885
|
+
Called by the CLI entry point rather than `render_to_file`, so rendering a
|
|
886
|
+
data.json in place (a fixture, a dry run) never writes beside its input.
|
|
887
|
+
Failing to write it must never fail the render: the report is the
|
|
888
|
+
deliverable, the snapshot is a convenience.
|
|
889
|
+
"""
|
|
890
|
+
snapshot = data_path.with_name(data_path.name + ".last-valid")
|
|
891
|
+
try:
|
|
892
|
+
tmp = snapshot.with_suffix(snapshot.suffix + f".tmp.{os.getpid()}")
|
|
893
|
+
tmp.write_bytes(data_path.read_bytes())
|
|
894
|
+
tmp.replace(snapshot)
|
|
895
|
+
except OSError as exc:
|
|
896
|
+
print(
|
|
897
|
+
f"render-final-report: could not write {snapshot.name} ({exc})",
|
|
898
|
+
file=sys.stderr,
|
|
899
|
+
)
|
|
900
|
+
return None
|
|
901
|
+
return snapshot
|
|
@@ -24,6 +24,7 @@ from .filters import (
|
|
|
24
24
|
paragraphs,
|
|
25
25
|
)
|
|
26
26
|
from .models import HtmlRunMeta
|
|
27
|
+
from .report_index import inject_report_index
|
|
27
28
|
from .router import HtmlRenderError, resolve_html_route
|
|
28
29
|
|
|
29
30
|
|
|
@@ -133,7 +134,8 @@ def render_v2_html_view(
|
|
|
133
134
|
# threading the index through every macro and call site.
|
|
134
135
|
anchors = anchor_index(data)
|
|
135
136
|
chrome = load_dictionary(lang, HTML_DICTIONARY_REL)
|
|
136
|
-
|
|
137
|
+
translate = make_jinja_global(chrome)
|
|
138
|
+
env.globals["t"] = translate
|
|
137
139
|
env.filters["code_evidence"] = code_evidence
|
|
138
140
|
env.filters["enum_label"] = lambda value, vocabulary: enum_label(value, vocabulary, chrome)
|
|
139
141
|
env.filters["enum_legend"] = lambda vocabulary: enum_legend(vocabulary, chrome)
|
|
@@ -154,7 +156,10 @@ def render_v2_html_view(
|
|
|
154
156
|
"js": response_js + "\n" + base_js,
|
|
155
157
|
}
|
|
156
158
|
output_path = _html_path(data_path)
|
|
157
|
-
|
|
159
|
+
document = env.get_template(route.template_name).render(**context)
|
|
160
|
+
output_path.write_text(
|
|
161
|
+
inject_report_index(document, label=translate("base.contents")), encoding="utf-8"
|
|
162
|
+
)
|
|
158
163
|
if context["clarificationItems"]:
|
|
159
164
|
# The footer tells the reader to drop the exported file here, so the
|
|
160
165
|
# directory has to exist before they go looking for it.
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Build the reader's index that heads the rendered report.
|
|
2
|
+
|
|
3
|
+
The index is derived from the rendered document instead of being declared in
|
|
4
|
+
each task template. A template that gains a section gets an index entry with
|
|
5
|
+
it, and there is no second list to keep in step.
|
|
6
|
+
|
|
7
|
+
Anchors come from the id a section already carries or from its
|
|
8
|
+
``data-report-section`` slug — never from the heading text, which changes with
|
|
9
|
+
the report language and would move every link with it.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import re
|
|
14
|
+
|
|
15
|
+
# Every section of the human view opens with its own h2. A block that does not
|
|
16
|
+
# is not a place the reader navigates to, so it stays out of the index.
|
|
17
|
+
_SECTION_HEAD_RE = re.compile(
|
|
18
|
+
r'<section\b(?P<attrs>[^>]*)>(?P<heading_open>\s*<h2[^>]*>)(?P<title>.*?)</h2>',
|
|
19
|
+
re.DOTALL,
|
|
20
|
+
)
|
|
21
|
+
_MAIN_OPEN_RE = re.compile(r"<main\b[^>]*>")
|
|
22
|
+
_ID_RE = re.compile(r'\bid="([^"]+)"')
|
|
23
|
+
_SLUG_RE = re.compile(r'\bdata-report-section="([^"]+)"')
|
|
24
|
+
_TAG_RE = re.compile(r"<[^>]+>")
|
|
25
|
+
|
|
26
|
+
INDEX_TITLE_ID = "report-index-title"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _heading_text(title_markup: str) -> str:
|
|
30
|
+
"""The heading's words without the markup a link cannot carry."""
|
|
31
|
+
return " ".join(_TAG_RE.sub("", title_markup).split())
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def inject_report_index(document: str, *, label: str) -> str:
|
|
35
|
+
"""Return ``document`` with a section index at the top of ``<main>``.
|
|
36
|
+
|
|
37
|
+
Sections that lack both an id and a slug are skipped rather than given a
|
|
38
|
+
generated anchor: a link whose target moves between renders is worse than
|
|
39
|
+
an entry the reader never had.
|
|
40
|
+
"""
|
|
41
|
+
opening = _MAIN_OPEN_RE.search(document)
|
|
42
|
+
if opening is None:
|
|
43
|
+
return document
|
|
44
|
+
head, body = document[: opening.end()], document[opening.end() :]
|
|
45
|
+
entries: list[tuple[str, str]] = []
|
|
46
|
+
|
|
47
|
+
def _anchor_section(match: re.Match[str]) -> str:
|
|
48
|
+
attrs = match.group("attrs")
|
|
49
|
+
existing = _ID_RE.search(attrs)
|
|
50
|
+
if existing:
|
|
51
|
+
anchor = existing.group(1)
|
|
52
|
+
else:
|
|
53
|
+
slug = _SLUG_RE.search(attrs)
|
|
54
|
+
if slug is None:
|
|
55
|
+
return match.group(0)
|
|
56
|
+
anchor = f"section-{slug.group(1)}"
|
|
57
|
+
# Last, not first: templates lead a section with the attribute the
|
|
58
|
+
# tests and the validator select it by.
|
|
59
|
+
attrs = f'{attrs} id="{anchor}"'
|
|
60
|
+
entries.append((anchor, _heading_text(match.group("title"))))
|
|
61
|
+
return f'<section{attrs}>{match.group("heading_open")}{match.group("title")}</h2>'
|
|
62
|
+
|
|
63
|
+
body = _SECTION_HEAD_RE.sub(_anchor_section, body)
|
|
64
|
+
if not entries:
|
|
65
|
+
return document
|
|
66
|
+
items = "".join(
|
|
67
|
+
f'<li><a href="#{anchor}">{text}</a></li>' for anchor, text in entries
|
|
68
|
+
)
|
|
69
|
+
index = (
|
|
70
|
+
f'<nav class="report-index" aria-labelledby="{INDEX_TITLE_ID}">'
|
|
71
|
+
f'<h2 id="{INDEX_TITLE_ID}">{label}</h2>'
|
|
72
|
+
f"<ol>{items}</ol>"
|
|
73
|
+
"</nav>"
|
|
74
|
+
)
|
|
75
|
+
return f"{head}\n{index}{body}"
|
|
@@ -31,6 +31,8 @@ from __future__ import annotations
|
|
|
31
31
|
import argparse
|
|
32
32
|
import json
|
|
33
33
|
import sys
|
|
34
|
+
import time
|
|
35
|
+
from collections.abc import Callable
|
|
34
36
|
from dataclasses import dataclass
|
|
35
37
|
from datetime import datetime, timezone
|
|
36
38
|
from pathlib import Path
|
|
@@ -49,6 +51,12 @@ from okstra_ctl.worker_heartbeat import (
|
|
|
49
51
|
)
|
|
50
52
|
|
|
51
53
|
DEFAULT_LAUNCH_GRACE_SECONDS = 60
|
|
54
|
+
DEFAULT_POLL_INTERVAL_SECONDS = 20.0
|
|
55
|
+
DEFAULT_WAIT_TIMEOUT_SECONDS = 2400.0
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _utc_now() -> datetime:
|
|
59
|
+
return datetime.now(timezone.utc)
|
|
52
60
|
|
|
53
61
|
|
|
54
62
|
def _log_path(prompt: Path) -> Path:
|
|
@@ -167,10 +175,12 @@ def probe_launch(
|
|
|
167
175
|
@dataclass(frozen=True)
|
|
168
176
|
class ProbeTarget:
|
|
169
177
|
"""One pending worker resolved from team-state: which artifact answers for
|
|
170
|
-
it, where that artifact is,
|
|
178
|
+
it, where that artifact is, when this dispatch started, and the result file
|
|
179
|
+
whose arrival means this worker is done."""
|
|
171
180
|
liveness_mode: str
|
|
172
181
|
artifact: Path
|
|
173
182
|
dispatched_at: datetime
|
|
183
|
+
result_path: Path | None = None
|
|
174
184
|
|
|
175
185
|
|
|
176
186
|
def probe_one(target: ProbeTarget, *, now: datetime, max_idle: float,
|
|
@@ -198,6 +208,61 @@ def probe_all(
|
|
|
198
208
|
"unhealthy": unhealthy}
|
|
199
209
|
|
|
200
210
|
|
|
211
|
+
def result_ready(target: ProbeTarget) -> bool:
|
|
212
|
+
"""Whether this worker's result file has landed with content in it."""
|
|
213
|
+
path = target.result_path
|
|
214
|
+
return bool(path and path.is_file() and path.stat().st_size > 0)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def wait_for_results(
|
|
218
|
+
targets: list[ProbeTarget],
|
|
219
|
+
*,
|
|
220
|
+
max_idle: float,
|
|
221
|
+
launch_grace: float,
|
|
222
|
+
interval: float,
|
|
223
|
+
timeout: float,
|
|
224
|
+
clock: Callable[[], datetime] = _utc_now,
|
|
225
|
+
sleep: Callable[[float], None] = time.sleep,
|
|
226
|
+
) -> dict:
|
|
227
|
+
"""Poll until every result file lands, a worker dies, or the deadline passes.
|
|
228
|
+
|
|
229
|
+
This is the loop a lead would otherwise hand-write in Bash at each dispatch,
|
|
230
|
+
and every hand-written one has to re-derive the same two facts: what "done"
|
|
231
|
+
means (the persisted ``resultPath``, not a guessed filename) and what
|
|
232
|
+
"dead" means (`probe_all`, whose graces run from ``startedAt`` — never from
|
|
233
|
+
an artifact's mtime, which a re-dispatched worker inherits from its previous
|
|
234
|
+
attempt and reads as instantly stale).
|
|
235
|
+
|
|
236
|
+
``outcome`` is ``completed`` / ``unhealthy`` / ``timeout``.
|
|
237
|
+
"""
|
|
238
|
+
started = clock()
|
|
239
|
+
while True:
|
|
240
|
+
now = clock()
|
|
241
|
+
result = probe_all(
|
|
242
|
+
targets, now=now, max_idle=max_idle, launch_grace=launch_grace
|
|
243
|
+
)
|
|
244
|
+
pending = [
|
|
245
|
+
str(t.result_path) for t in targets if not result_ready(t)
|
|
246
|
+
]
|
|
247
|
+
waited = (now - started).total_seconds()
|
|
248
|
+
if not pending:
|
|
249
|
+
outcome = "completed"
|
|
250
|
+
elif result["unhealthy"]:
|
|
251
|
+
outcome = "unhealthy"
|
|
252
|
+
elif waited >= timeout:
|
|
253
|
+
outcome = "timeout"
|
|
254
|
+
else:
|
|
255
|
+
sleep(interval)
|
|
256
|
+
continue
|
|
257
|
+
return {
|
|
258
|
+
**result,
|
|
259
|
+
"ok": outcome == "completed",
|
|
260
|
+
"outcome": outcome,
|
|
261
|
+
"pending": pending,
|
|
262
|
+
"waitedSeconds": int(waited),
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
|
|
201
266
|
def _parse_utc(value: object, label: str) -> datetime:
|
|
202
267
|
if not isinstance(value, str) or not value:
|
|
203
268
|
raise DispatchError(f"{label} must be a UTC ISO timestamp")
|
|
@@ -262,11 +327,18 @@ def probe_target(team_state_value: str, worker_id: str) -> ProbeTarget:
|
|
|
262
327
|
artifact_value = worker.get(field)
|
|
263
328
|
if not isinstance(artifact_value, str) or not artifact_value.strip():
|
|
264
329
|
raise DispatchError(f"worker {worker_id} has no {field}")
|
|
330
|
+
project_root = _project_root_for_team_state(team_state_path)
|
|
265
331
|
artifact = Path(artifact_value)
|
|
266
332
|
if not artifact.is_absolute():
|
|
267
|
-
artifact =
|
|
333
|
+
artifact = project_root / artifact
|
|
268
334
|
dispatched_at = _parse_utc(worker.get("startedAt"), f"worker {worker_id} startedAt")
|
|
269
|
-
|
|
335
|
+
result_value = worker.get("resultPath")
|
|
336
|
+
result_path = None
|
|
337
|
+
if isinstance(result_value, str) and result_value.strip():
|
|
338
|
+
result_path = Path(result_value)
|
|
339
|
+
if not result_path.is_absolute():
|
|
340
|
+
result_path = project_root / result_path
|
|
341
|
+
return ProbeTarget(mode, artifact, dispatched_at, result_path)
|
|
270
342
|
|
|
271
343
|
|
|
272
344
|
def main(argv: list[str] | None = None) -> int:
|
|
@@ -283,6 +355,18 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
283
355
|
parser.add_argument("--launch-grace", type=float, default=DEFAULT_LAUNCH_GRACE_SECONDS,
|
|
284
356
|
help="seconds a worker may take to write its first artifact")
|
|
285
357
|
parser.add_argument("--json", action="store_true", help="emit JSON (always on)")
|
|
358
|
+
parser.add_argument(
|
|
359
|
+
"--wait", action="store_true",
|
|
360
|
+
help=(
|
|
361
|
+
"poll until every worker's persisted resultPath lands (exit 0), one "
|
|
362
|
+
"worker probes unhealthy (exit 1), or --timeout passes (exit 2). "
|
|
363
|
+
"Use this instead of hand-writing a Bash poll loop."
|
|
364
|
+
),
|
|
365
|
+
)
|
|
366
|
+
parser.add_argument("--interval", type=float, default=DEFAULT_POLL_INTERVAL_SECONDS,
|
|
367
|
+
help="--wait poll interval in seconds")
|
|
368
|
+
parser.add_argument("--timeout", type=float, default=DEFAULT_WAIT_TIMEOUT_SECONDS,
|
|
369
|
+
help="--wait deadline in seconds")
|
|
286
370
|
args = parser.parse_args(argv)
|
|
287
371
|
|
|
288
372
|
if len(args.team_state) != len(args.worker):
|
|
@@ -298,16 +382,37 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
298
382
|
except DispatchError as exc:
|
|
299
383
|
parser.error(str(exc))
|
|
300
384
|
|
|
301
|
-
|
|
385
|
+
if not args.wait:
|
|
386
|
+
result = probe_all(
|
|
387
|
+
targets,
|
|
388
|
+
now=_utc_now(),
|
|
389
|
+
max_idle=args.max_idle,
|
|
390
|
+
launch_grace=args.launch_grace,
|
|
391
|
+
)
|
|
392
|
+
print(json.dumps(result, ensure_ascii=False, indent=2))
|
|
393
|
+
# Non-zero on an unhealthy worker so a poll loop can branch on the exit
|
|
394
|
+
# code without parsing the JSON.
|
|
395
|
+
return 0 if result["ok"] else 1
|
|
396
|
+
|
|
397
|
+
unwaitable = [
|
|
398
|
+
worker for target, worker in zip(targets, args.worker, strict=True)
|
|
399
|
+
if target.result_path is None
|
|
400
|
+
]
|
|
401
|
+
if unwaitable:
|
|
402
|
+
parser.error(
|
|
403
|
+
f"--wait needs a resultPath in team-state for: {', '.join(unwaitable)}. "
|
|
404
|
+
"Waiting on a guessed filename is what --wait exists to prevent."
|
|
405
|
+
)
|
|
406
|
+
|
|
407
|
+
result = wait_for_results(
|
|
302
408
|
targets,
|
|
303
|
-
now=datetime.now(timezone.utc),
|
|
304
409
|
max_idle=args.max_idle,
|
|
305
410
|
launch_grace=args.launch_grace,
|
|
411
|
+
interval=args.interval,
|
|
412
|
+
timeout=args.timeout,
|
|
306
413
|
)
|
|
307
414
|
print(json.dumps(result, ensure_ascii=False, indent=2))
|
|
308
|
-
|
|
309
|
-
# without parsing the JSON.
|
|
310
|
-
return 0 if result["ok"] else 1
|
|
415
|
+
return {"completed": 0, "unhealthy": 1}.get(result["outcome"], 2)
|
|
311
416
|
|
|
312
417
|
|
|
313
418
|
if __name__ == "__main__":
|
|
@@ -187,10 +187,18 @@ def validate_reverify_prompt(
|
|
|
187
187
|
*,
|
|
188
188
|
task_type: str,
|
|
189
189
|
forbidden_actions: str,
|
|
190
|
+
expected_model: str | None = None,
|
|
190
191
|
) -> list[str]:
|
|
191
|
-
"""Require the active phase boundary in a lightweight reverify prompt.
|
|
192
|
+
"""Require the active phase boundary in a lightweight reverify prompt.
|
|
193
|
+
|
|
194
|
+
``expected_model`` is the value this dispatch will actually run. A reverify
|
|
195
|
+
prompt's `**Model:**` header is hand-written per round, and a header naming
|
|
196
|
+
a model the runtime does not serve does not fail here — it fails as a
|
|
197
|
+
provider 400 once the worker launches, where it reads as a worker fault.
|
|
198
|
+
Pass the dispatch's model so the mismatch is caught before launch.
|
|
199
|
+
"""
|
|
192
200
|
normalized = text.replace("\r\n", "\n").replace("\r", "\n")
|
|
193
|
-
errors: list[str] =
|
|
201
|
+
errors: list[str] = _validate_model_header(normalized, expected_model)
|
|
194
202
|
task_values = _header_values(normalized, TASK_TYPE_HEADER)
|
|
195
203
|
if task_values != [task_type]:
|
|
196
204
|
errors.append(
|
|
@@ -287,23 +295,30 @@ def validate_initial_prompt_records(
|
|
|
287
295
|
return errors
|
|
288
296
|
|
|
289
297
|
|
|
290
|
-
def
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
298
|
+
def _validate_model_header(text: str, expected_model: str | None) -> list[str]:
|
|
299
|
+
"""The `**Model:** <label>, <model>` header must name the requested model.
|
|
300
|
+
|
|
301
|
+
A caller with no resolved model passes ``None`` and the header is not
|
|
302
|
+
judged — there is nothing to compare it against.
|
|
303
|
+
"""
|
|
304
|
+
if expected_model is None:
|
|
305
|
+
return []
|
|
297
306
|
model = _model_value(_header_values(text, MODEL_HEADER))
|
|
298
307
|
if model is None:
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
return
|
|
308
|
+
return ["exactly one non-empty **Model:** <label>, <model> header is required"]
|
|
309
|
+
if model != expected_model:
|
|
310
|
+
return [f"prompt model does not match requested model: {expected_model}"]
|
|
311
|
+
return []
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _validate_record_metadata(text: str, record: PromptRecord) -> list[str]:
|
|
315
|
+
return [
|
|
316
|
+
*_validate_delivery_mode(
|
|
317
|
+
_header_values(text, PROMPT_DELIVERY_MODE_HEADER),
|
|
318
|
+
record.expected_delivery_mode,
|
|
319
|
+
),
|
|
320
|
+
*_validate_model_header(text, record.expected_model),
|
|
321
|
+
]
|
|
307
322
|
|
|
308
323
|
|
|
309
324
|
def _validate_evidence_ledger_header(
|
|
@@ -193,6 +193,8 @@ If an action has an unknown `command`, `key`, or `scope`, stop and report the wi
|
|
|
193
193
|
|
|
194
194
|
Before rendering the next phase's bundle — and between worker rounds within a phase (reverify/critic/gapverify batches), after you have collected that round's results and token usage and before you dispatch the next round — reclaim the prior round's completed teammate panes so they do not accumulate, in two passes and adding `--keep report-writer-worker` to **both** whenever the report writer is still in flight. First source the count: `$HOME/.okstra/bin/okstra-trace-cleanup.sh --list --run-dir "<RUN_DIR>" [--keep report-writer-worker]` never kills and prints one `<pane_id>\t<pane_title>` line per pane it would reclaim — count those lines as `<n>`. Then run the same command **without** `--list` to perform the reclaim, and emit `PROGRESS: phase-batch-cleanup panes=<n>` with that count at the batch boundary. Call both passes after collecting results and before the next dispatch so no in-flight worker pane is caught. This `tmux kill-pane`s the harness teammate panes; `shutdown_request` alone only idles the agent and never frees the pane, so it stays part of the run-end sequence for roster/token hygiene. `<RUN_DIR>` is the current (or just-finished) run's directory; its recorded `state/lead-pane.id` scopes the lead's session and the lead pane is never killed. In a non-tmux session there are no panes and the script is a silent no-op.
|
|
195
195
|
|
|
196
|
+
Before you ask the user for any approval, clarification, or decision after workers have been dispatched, run the same reclaim first: `$HOME/.okstra/bin/okstra-trace-cleanup.sh --list --run-dir "<RUN_DIR>" [--keep report-writer-worker]` to count the panes, then the same command without `--list` to close them, emit `PROGRESS: phase-gate-cleanup panes=<n>`, and `TaskStop` each completed worker. A `TaskStop` by itself idles the task but leaves the pane open — the `trace-cleanup` call is what actually closes it. This keeps a user gate from being shown while finished worker panes remain; in-flight workers and an in-flight report writer are preserved.
|
|
197
|
+
|
|
196
198
|
Build the `okstra render-bundle` invocation from `outcome.renderArgs`, passing each key as `--<key>` and the value verbatim (including empty strings — they are intentional `use phase default` markers).
|
|
197
199
|
|
|
198
200
|
Analysis sidetracks therefore forward wizard-owned entries such as `--analysis-target "<args.analysis-target>"` and `--evidence-inputs "<args.evidence-inputs>"` when those keys are present. These are examples of the generic mapping rule, not a separate hard-coded argument list.
|
|
@@ -16,6 +16,13 @@ main { display: grid; gap: 1rem; padding-bottom: 3rem; }
|
|
|
16
16
|
section { padding: 1.4rem; border: 1px solid color-mix(in srgb, CanvasText 14%, transparent); border-radius: 16px; background: color-mix(in srgb, Canvas 94%, CanvasText 6%); }
|
|
17
17
|
h2 { margin-top: 0; font-size: 1.45rem; }
|
|
18
18
|
h3 { margin-bottom: .35rem; }
|
|
19
|
+
/* The report opens with what it holds. It scrolls away with the rest rather
|
|
20
|
+
than sticking: on a phone a pinned index of a dozen sections is the page. */
|
|
21
|
+
nav.report-index { padding: 1.2rem 1.4rem; border: 1px solid color-mix(in srgb, CanvasText 14%, transparent); border-radius: 16px; background: color-mix(in srgb, Canvas 94%, CanvasText 6%); }
|
|
22
|
+
nav.report-index h2 { font-size: .82rem; text-transform: uppercase; letter-spacing: .08em; color: GrayText; margin-bottom: .6rem; }
|
|
23
|
+
nav.report-index ol { margin: 0; padding-left: 1.4rem; columns: 2; column-gap: 2.5rem; }
|
|
24
|
+
nav.report-index li { margin: .2em 0; break-inside: avoid; }
|
|
25
|
+
nav.report-index a { color: inherit; }
|
|
19
26
|
/* One card per row. Fitting three across left each body about sixteen
|
|
20
27
|
characters wide — a third of what reads comfortably — and turned every card
|
|
21
28
|
into a narrow vertical strip. These carry paragraphs, not labels. */
|
|
@@ -92,6 +99,6 @@ button:focus-visible { outline: 2px solid Highlight; outline-offset: 2px; }
|
|
|
92
99
|
button[data-action="export-user-response"] { background: Highlight; border-color: Highlight; color: HighlightText; font-weight: 600; }
|
|
93
100
|
button[data-action="export-user-response"]:hover { background: color-mix(in srgb, Highlight 82%, CanvasText); }
|
|
94
101
|
/* A seven-column table needs 42em of floor, more than a phone can give. */
|
|
95
|
-
@media (max-width: 640px) { section { padding: 1rem; } .visualization { display: none; } th, td { min-width: 4.5em; } }
|
|
102
|
+
@media (max-width: 640px) { section { padding: 1rem; } .visualization { display: none; } th, td { min-width: 4.5em; } nav.report-index ol { columns: 1; } }
|
|
96
103
|
@media print { .skip-link, script { display: none !important; } body { color: #000; background: #fff; } section { break-inside: avoid; border-color: #bbb; } .visualization-fallback { display: table; } }
|
|
97
104
|
code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .9em; padding: .1em .3em; border-radius: 4px; background: color-mix(in srgb, CanvasText 8%, Canvas); }
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
|
|
33
33
|
{% macro clarification_responses(items) -%}
|
|
34
34
|
{% if items %}
|
|
35
|
-
<section class="clarification-responses" aria-label="{{ t('macros.forms.questions-waiting-on-you') }}">
|
|
35
|
+
<section class="clarification-responses" data-report-section="clarification-responses" aria-label="{{ t('macros.forms.questions-waiting-on-you') }}">
|
|
36
36
|
<h2>{{ t('macros.forms.count-questions-waiting-on-you') | replace('{count}', items | length) }}</h2>
|
|
37
37
|
<p class="clarification-lede">{{ t('macros.forms.the-code-alone-could-not-settle-these-fill-i') }} <strong>{{ t('macros.forms.export-my-answers') }}</strong> {{ t('macros.forms.at-the-foot-of-the-page') }}</p>
|
|
38
38
|
{% for row in items %}
|
|
@@ -38,6 +38,17 @@
|
|
|
38
38
|
"filesystem": { "allowWrite": ["~/.gemini", "~/.codex"] }
|
|
39
39
|
},
|
|
40
40
|
"hooks": {
|
|
41
|
+
"SessionStart": [
|
|
42
|
+
{
|
|
43
|
+
"matcher": "compact",
|
|
44
|
+
"hooks": [
|
|
45
|
+
{
|
|
46
|
+
"type": "command",
|
|
47
|
+
"command": "$HOME/.okstra/bin/okstra-compact-reminder.sh"
|
|
48
|
+
}
|
|
49
|
+
]
|
|
50
|
+
}
|
|
51
|
+
],
|
|
41
52
|
"PreToolUse": [
|
|
42
53
|
{
|
|
43
54
|
"matcher": "Write|Edit|MultiEdit|NotebookEdit",
|