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.
Files changed (31) hide show
  1. package/docs/architecture.md +1 -0
  2. package/docs/cli.md +1 -1
  3. package/package.json +1 -1
  4. package/runtime/BUILD.json +2 -2
  5. package/runtime/bin/okstra-compact-reminder.sh +34 -0
  6. package/runtime/bin/okstra-render-final-report.py +2 -0
  7. package/runtime/prompts/lead/adapters/claude-code.md +1 -0
  8. package/runtime/prompts/lead/convergence.md +2 -0
  9. package/runtime/prompts/lead/okstra-lead-contract.md +4 -1
  10. package/runtime/prompts/lead/plan-body-verification.md +13 -2
  11. package/runtime/prompts/lead/team-contract.md +40 -14
  12. package/runtime/python/okstra_ctl/convergence.py +31 -0
  13. package/runtime/python/okstra_ctl/convergence_provenance.py +185 -0
  14. package/runtime/python/okstra_ctl/dispatch_state.py +6 -0
  15. package/runtime/python/okstra_ctl/pane_reclaim.py +30 -6
  16. package/runtime/python/okstra_ctl/render_final_report.py +29 -0
  17. package/runtime/python/okstra_ctl/report_html/render.py +7 -2
  18. package/runtime/python/okstra_ctl/report_html/report_index.py +75 -0
  19. package/runtime/python/okstra_ctl/worker_liveness.py +113 -8
  20. package/runtime/python/okstra_ctl/worker_prompt_contract.py +32 -17
  21. package/runtime/skills/okstra-run/SKILL.md +2 -0
  22. package/runtime/templates/reports/html/assets/base.css +8 -1
  23. package/runtime/templates/reports/html/i18n/en.json +1 -0
  24. package/runtime/templates/reports/html/i18n/ko.json +1 -0
  25. package/runtime/templates/reports/html/macros/forms.html +1 -1
  26. package/runtime/templates/reports/settings.template.json +11 -0
  27. package/runtime/validators/validate-run.py +185 -161
  28. package/runtime/validators/validate_session_conformance.py +57 -0
  29. package/src/cli-registry.mjs +7 -0
  30. package/src/commands/execute/plan-verify.mjs +44 -0
  31. 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
- env.globals["t"] = make_jinja_global(chrome)
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
- output_path.write_text(env.get_template(route.template_name).render(**context), encoding="utf-8")
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, and when this dispatch started."""
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 = _project_root_for_team_state(team_state_path) / artifact
333
+ artifact = project_root / artifact
268
334
  dispatched_at = _parse_utc(worker.get("startedAt"), f"worker {worker_id} startedAt")
269
- return ProbeTarget(mode, artifact, dispatched_at)
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
- result = probe_all(
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
- # Non-zero on an unhealthy worker so a poll loop can branch on the exit code
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 _validate_record_metadata(text: str, record: PromptRecord) -> list[str]:
291
- errors = _validate_delivery_mode(
292
- _header_values(text, PROMPT_DELIVERY_MODE_HEADER),
293
- record.expected_delivery_mode,
294
- )
295
- if record.expected_model is None:
296
- return errors
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
- errors.append(
300
- "exactly one non-empty **Model:** <label>, <model> header is required"
301
- )
302
- elif model != record.expected_model:
303
- errors.append(
304
- f"prompt model does not match requested model: {record.expected_model}"
305
- )
306
- return errors
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); }
@@ -72,6 +72,7 @@
72
72
  },
73
73
  "base": {
74
74
  "skip-to-report-content": "Skip to report content",
75
+ "contents": "Contents",
75
76
  "task": "Task",
76
77
  "task-key": "Task key",
77
78
  "written": "Written",
@@ -72,6 +72,7 @@
72
72
  },
73
73
  "base": {
74
74
  "skip-to-report-content": "리포트 본문으로 건너뛰기",
75
+ "contents": "목차",
75
76
  "task": "태스크",
76
77
  "task-key": "태스크 키",
77
78
  "written": "작성",
@@ -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",