okstra 0.145.0 → 0.146.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 (43) hide show
  1. package/docs/architecture.md +4 -2
  2. package/docs/cli.md +15 -5
  3. package/docs/project-structure-overview.md +3 -5
  4. package/package.json +1 -1
  5. package/runtime/BUILD.json +2 -2
  6. package/runtime/agents/workers/report-writer-worker.md +5 -6
  7. package/runtime/bin/okstra-trace-cleanup.sh +28 -2
  8. package/runtime/prompts/lead/adapters/claude-code.md +3 -3
  9. package/runtime/prompts/lead/convergence.md +20 -3
  10. package/runtime/prompts/lead/okstra-lead-contract.md +2 -2
  11. package/runtime/prompts/lead/report-writer.md +20 -14
  12. package/runtime/prompts/lead/team-contract.md +3 -3
  13. package/runtime/python/okstra_ctl/analysis_packet.py +4 -10
  14. package/runtime/python/okstra_ctl/codex_dispatch.py +117 -58
  15. package/runtime/python/okstra_ctl/convergence_engine.py +3 -1
  16. package/runtime/python/okstra_ctl/dispatch_core.py +19 -56
  17. package/runtime/python/okstra_ctl/dispatch_state.py +167 -3
  18. package/runtime/python/okstra_ctl/path_hints.py +6 -0
  19. package/runtime/python/okstra_ctl/paths.py +7 -44
  20. package/runtime/python/okstra_ctl/render.py +2 -0
  21. package/runtime/python/okstra_ctl/wizard.py +34 -0
  22. package/runtime/python/okstra_ctl/worker_liveness.py +84 -21
  23. package/runtime/python/okstra_ctl/worker_prompt_body.py +24 -4
  24. package/runtime/python/okstra_ctl/worker_prompt_contract.py +57 -0
  25. package/runtime/python/okstra_ctl/worker_state.py +65 -0
  26. package/runtime/python/okstra_token_usage/antigravity.py +3 -0
  27. package/runtime/python/okstra_token_usage/codex.py +54 -23
  28. package/runtime/python/okstra_token_usage/collect.py +141 -33
  29. package/runtime/python/okstra_token_usage/paths.py +27 -0
  30. package/runtime/python/okstra_vendor/__init__.py +15 -2
  31. package/runtime/schemas/convergence-groups-v1.0.schema.json +0 -1
  32. package/runtime/skills/okstra-run/SKILL.md +14 -4
  33. package/runtime/skills/okstra-setup/references/project-config.md +13 -4
  34. package/runtime/validators/lib/fixtures.sh +1 -1
  35. package/runtime/validators/validate-run.py +52 -1
  36. package/runtime/validators/validate_analysis_report.py +34 -3
  37. package/src/cli-registry.mjs +7 -10
  38. package/src/commands/execute/worker-state.mjs +29 -0
  39. package/src/commands/inspect/worker-liveness.mjs +5 -3
  40. package/src/commands/lifecycle/preflight.mjs +13 -3
  41. package/src/lib/runtime-readiness.mjs +90 -0
  42. package/runtime/python/okstra_ctl/phase_cleanup.py +0 -235
  43. package/src/commands/execute/phase-cleanup.mjs +0 -38
@@ -22,9 +22,13 @@ import os
22
22
  from dataclasses import dataclass
23
23
  from datetime import datetime, timezone
24
24
  from pathlib import Path
25
- from typing import Any, Mapping, Sequence
25
+ from typing import Any, Callable, Mapping, Sequence
26
26
 
27
- from .worker_prompt_contract import PromptRecord, validate_initial_prompt_records
27
+ from .worker_prompt_contract import (
28
+ PromptRecord,
29
+ validate_initial_prompt_records,
30
+ validate_reverify_prompt,
31
+ )
28
32
 
29
33
  BACKEND_CLI_WRAPPER = "cli-wrapper"
30
34
  BACKEND_TMUX_PANE = "tmux-pane"
@@ -35,6 +39,10 @@ BACKEND_MIXED = "mixed"
35
39
  # analysis phases write their artifacts under project-root (see the contract in
36
40
  # scripts/okstra-codex-exec.sh).
37
41
  WORKTREE_TASK_TYPES = frozenset({"implementation", "final-verification"})
42
+ WORKER_STATUSES = frozenset(
43
+ {"in-progress", "completed", "timeout", "error", "not-run"}
44
+ )
45
+ REASON_REQUIRED_STATUSES = frozenset({"timeout", "error", "not-run"})
38
46
 
39
47
 
40
48
  class DispatchError(Exception):
@@ -171,14 +179,24 @@ def worker_state(team_state: Mapping[str, Any], worker_id: str) -> Mapping[str,
171
179
  raise DispatchError(f"team-state has no workerId={worker_id}")
172
180
 
173
181
 
174
- def set_worker_status(
182
+ def transition_worker_status(
175
183
  team_state_path: Path,
176
184
  worker_id: str,
177
185
  status: str,
178
186
  reason: str,
179
187
  *,
180
188
  model_execution_value: str = "",
189
+ at: str | datetime | None = None,
181
190
  ) -> None:
191
+ if status not in WORKER_STATUSES:
192
+ allowed = ", ".join(sorted(WORKER_STATUSES))
193
+ raise DispatchError(
194
+ f"unsupported worker status `{status}`; expected one of: {allowed}"
195
+ )
196
+ reason = reason.strip()
197
+ if status in REASON_REQUIRED_STATUSES and not reason:
198
+ raise DispatchError(f"worker status `{status}` requires a non-empty reason")
199
+ timestamp = _utc_timestamp(at)
182
200
  payload = load_json_object(team_state_path, "team-state")
183
201
  workers = payload.get("workers")
184
202
  if not isinstance(workers, list):
@@ -187,6 +205,14 @@ def set_worker_status(
187
205
  if isinstance(worker, dict) and worker.get("workerId") == worker_id:
188
206
  worker["status"] = status
189
207
  worker["reason"] = reason
208
+ if status == "in-progress":
209
+ worker["startedAt"] = timestamp
210
+ worker.pop("endedAt", None)
211
+ elif status == "not-run":
212
+ worker.pop("startedAt", None)
213
+ worker.pop("endedAt", None)
214
+ else:
215
+ worker["endedAt"] = timestamp
190
216
  if model_execution_value:
191
217
  worker["model"] = model_execution_value
192
218
  worker["modelExecutionValue"] = model_execution_value
@@ -195,6 +221,23 @@ def set_worker_status(
195
221
  raise DispatchError(f"team-state has no workerId={worker_id}: {team_state_path}")
196
222
 
197
223
 
224
+ def _utc_timestamp(value: str | datetime | None) -> str:
225
+ if value is None:
226
+ instant = datetime.now(timezone.utc)
227
+ elif isinstance(value, datetime):
228
+ instant = value
229
+ elif isinstance(value, str):
230
+ try:
231
+ instant = datetime.fromisoformat(value.replace("Z", "+00:00"))
232
+ except ValueError as exc:
233
+ raise DispatchError(f"invalid UTC timestamp `{value}`") from exc
234
+ else:
235
+ raise DispatchError(f"invalid UTC timestamp `{value}`")
236
+ if instant.tzinfo is None or instant.utcoffset() != timezone.utc.utcoffset(instant):
237
+ raise DispatchError(f"invalid UTC timestamp `{value}`")
238
+ return instant.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
239
+
240
+
198
241
  def set_dispatch_mode(team_state_path: Path, dispatch_mode: str) -> None:
199
242
  payload = load_json_object(team_state_path, "team-state")
200
243
  payload["dispatchMode"] = dispatch_mode
@@ -227,5 +270,126 @@ def validate_initial_prompts(
227
270
  raise DispatchError(f"{task_type} prompt contract: " + "; ".join(errors))
228
271
 
229
272
 
273
+ def validate_dispatch_prompts(
274
+ manifest: Mapping[str, Any],
275
+ active_context: Mapping[str, Any],
276
+ jobs: Sequence[WorkerJob],
277
+ ) -> None:
278
+ initial_jobs = [
279
+ job for job in jobs if not job.dispatch_kind.startswith("reverify-r")
280
+ ]
281
+ if initial_jobs:
282
+ validate_initial_prompts(manifest, initial_jobs)
283
+
284
+ reverify_jobs = [
285
+ job for job in jobs if job.dispatch_kind.startswith("reverify-r")
286
+ ]
287
+ if not reverify_jobs:
288
+ return
289
+ task_type = require_string(manifest, "taskType")
290
+ workflow = active_context.get("workflow")
291
+ if not isinstance(workflow, Mapping):
292
+ raise DispatchError("reverify prompt contract: active run workflow is missing")
293
+ forbidden_actions = string_value(workflow.get("forbiddenActions"))
294
+ if not forbidden_actions:
295
+ raise DispatchError(
296
+ "reverify prompt contract: workflow.forbiddenActions is missing"
297
+ )
298
+
299
+ errors: list[str] = []
300
+ for job in reverify_jobs:
301
+ try:
302
+ text = job.prompt_path.read_text(encoding="utf-8")
303
+ except OSError as exc:
304
+ errors.append(f"{job.worker_id}: cannot read prompt {job.prompt_path}: {exc}")
305
+ continue
306
+ errors.extend(
307
+ f"{job.worker_id}: {error}"
308
+ for error in validate_reverify_prompt(
309
+ text,
310
+ task_type=task_type,
311
+ forbidden_actions=forbidden_actions,
312
+ )
313
+ )
314
+ if errors:
315
+ raise DispatchError("reverify prompt contract: " + "; ".join(errors))
316
+
317
+
318
+ def worker_jobs_from_file(
319
+ project_root: Path,
320
+ jobs_file: Path,
321
+ *,
322
+ backend: str,
323
+ idle_timeout_seconds: int,
324
+ default_dispatch_kind: str,
325
+ resolve_wrapper: Callable[[str], Path],
326
+ default_provider: Callable[[str], str],
327
+ ) -> list[WorkerJob]:
328
+ payload = load_json_object(
329
+ resolve_project_path(project_root, str(jobs_file)),
330
+ "jobs file",
331
+ )
332
+ dispatch_kind = string_value(payload.get("dispatchKind")) or default_dispatch_kind
333
+ workers = payload.get("workers")
334
+ if not isinstance(workers, list):
335
+ raise DispatchError("jobs file workers must be an array")
336
+ return [
337
+ _worker_job_from_file(
338
+ project_root,
339
+ item,
340
+ backend=backend,
341
+ idle_timeout_seconds=idle_timeout_seconds,
342
+ dispatch_kind=dispatch_kind,
343
+ resolve_wrapper=resolve_wrapper,
344
+ default_provider=default_provider,
345
+ )
346
+ for item in workers
347
+ if isinstance(item, Mapping)
348
+ ]
349
+
350
+
351
+ def _worker_job_from_file(
352
+ project_root: Path,
353
+ item: Mapping[str, Any],
354
+ *,
355
+ backend: str,
356
+ idle_timeout_seconds: int,
357
+ dispatch_kind: str,
358
+ resolve_wrapper: Callable[[str], Path],
359
+ default_provider: Callable[[str], str],
360
+ ) -> WorkerJob:
361
+ worker_id = require_string(item, "workerId")
362
+ provider = string_value(item.get("provider")) or default_provider(worker_id)
363
+ prompt_path = resolve_project_path(
364
+ project_root, require_string(item, "promptPath")
365
+ )
366
+ result_path = resolve_project_path(
367
+ project_root, require_string(item, "resultPath")
368
+ )
369
+ worker_result_path = resolve_project_path(
370
+ project_root, require_string(item, "workerResultPath")
371
+ )
372
+ completion_paths = tuple(
373
+ resolve_project_path(project_root, path)
374
+ for path in string_list(item.get("completionPaths"))
375
+ ) or (result_path,)
376
+ return WorkerJob(
377
+ worker_id=worker_id,
378
+ provider=provider,
379
+ backend=backend,
380
+ project_root=project_root,
381
+ model_execution_value=require_string(item, "modelExecutionValue"),
382
+ wrapper_path=resolve_wrapper(provider),
383
+ prompt_path=prompt_path,
384
+ result_path=result_path,
385
+ worker_result_path=worker_result_path,
386
+ completion_paths=completion_paths,
387
+ worktree_path=string_value(item.get("worktreePath")),
388
+ role=require_string(item, "role"),
389
+ idle_timeout_seconds=idle_timeout_seconds,
390
+ dispatch_kind=dispatch_kind,
391
+ )
392
+
393
+
230
394
  def utc_now() -> str:
231
395
  return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
@@ -188,6 +188,7 @@ def _hydrate_active_run(ctx: Mapping[str, str]) -> dict[str, str]:
188
188
  "teamStatePath": ctx.get("TEAM_STATE_RELATIVE_PATH", ""),
189
189
  "promptSnapshotPath": ctx.get("RUN_PROMPT_SNAPSHOT_RELATIVE_PATH", ""),
190
190
  "finalReportPath": ctx.get("FINAL_REPORT_RELATIVE_PATH", ""),
191
+ "convergenceStatePath": ctx.get("CONVERGENCE_STATE_RELATIVE_PATH", ""),
191
192
  "finalStatusPath": ctx.get("FINAL_STATUS_RELATIVE_PATH", ""),
192
193
  "validatorScriptPath": ctx.get("RUN_VALIDATOR_RELATIVE_PATH", ""),
193
194
  "resumeCommandPath": ctx.get("CLAUDE_RESUME_COMMAND_RELATIVE_PATH", ""),
@@ -430,6 +431,7 @@ def _run_files(
430
431
  "team_state": run_state / f"team-state{suffixes['state']}.json",
431
432
  "active_run_context": run_state / f"active-run-context{suffixes['state']}.json",
432
433
  "lead_events": run_state / f"lead-events-{task_type_segment}-{sequences['state']}.jsonl",
434
+ "convergence_state": run_state / f"convergence-{task_type_segment}-{sequences['state']}.json",
433
435
  "claude_resume_command": run_sessions / f"claude-resume{suffixes['sessions']}.sh",
434
436
  "claude_worker_result": worker_results / f"claude-worker{suffixes['worker_results']}.md",
435
437
  "codex_worker_result": worker_results / f"codex-worker{suffixes['worker_results']}.md",
@@ -540,6 +542,7 @@ def _absolute_run_fields(paths: Mapping[str, Any]) -> dict[str, str]:
540
542
  "RUN_CONTEXT_FILE": str(paths["run_context_file"]),
541
543
  "RUN_PROMPT_SNAPSHOT_FILE": str(paths["run_prompt_snapshot"]),
542
544
  "FINAL_REPORT_PATH": str(paths["final_report"]),
545
+ "CONVERGENCE_STATE_PATH": str(paths["convergence_state"]),
543
546
  "FINAL_STATUS_PATH": str(paths["final_status"]),
544
547
  "TEAM_STATE_PATH": str(paths["team_state"]),
545
548
  "ACTIVE_RUN_CONTEXT_PATH": str(paths["active_run_context"]),
@@ -629,6 +632,9 @@ def _relative_file_fields(project_root: Path, paths: Mapping[str, Any]) -> dict[
629
632
  "ANTIGRAVITY_WORKER_PROMPT_RELATIVE_PATH": _rel(project_root, paths["antigravity_worker_prompt"]),
630
633
  "REPORT_WRITER_WORKER_PROMPT_RELATIVE_PATH": _rel(project_root, paths["report_writer_worker_prompt"]),
631
634
  "FINAL_REPORT_RELATIVE_PATH": _rel(project_root, paths["final_report"]),
635
+ "CONVERGENCE_STATE_RELATIVE_PATH": _rel(
636
+ project_root, paths["convergence_state"]
637
+ ),
632
638
  "FINAL_STATUS_RELATIVE_PATH": _rel(project_root, paths["final_status"]),
633
639
  "TEAM_STATE_RELATIVE_PATH": _rel(project_root, paths["team_state"]),
634
640
  "ACTIVE_RUN_CONTEXT_RELATIVE_PATH": _rel(project_root, paths["active_run_context"]),
@@ -66,19 +66,6 @@ def _newest_report(reports_dir: Path) -> Optional[Path]:
66
66
  return max(found, key=lambda p: (p.stat().st_mtime, p.name))
67
67
 
68
68
 
69
- def _report_dirs(type_dir: Path, include_stages: bool) -> list[Path]:
70
- """한 task-type 의 report 디렉터리들. flat 은 항상, stage-<N> 은 opt-in.
71
-
72
- stage-isolated task-type(implementation / final-verification)만 stage 하위를
73
- 갖는다. `include_stages` 가 꺼져 있으면 flat `reports/` 만 돌려준다 —
74
- `latest_under` 의 원래 범위이자 resume-clarification 이 의존하는 계약이다.
75
- """
76
- dirs = [type_dir / "reports"]
77
- if include_stages and type_dir.name in _STAGED_TASK_TYPES:
78
- dirs += sorted(type_dir.glob("stage-*/reports"))
79
- return dirs
80
-
81
-
82
69
  def _project_root_of(task_root: Path) -> Optional[Path]:
83
70
  """canonical `<project>/.okstra/tasks/<g>/<t>` 일 때만 project root.
84
71
 
@@ -264,42 +251,19 @@ class RunRef:
264
251
  return None if best is None else cls.from_report_path(best)
265
252
 
266
253
  @classmethod
267
- def latest_across(
254
+ def latest_under(
268
255
  cls,
269
- project_root: Path,
270
- task_group: str,
271
- task_id: str,
256
+ task_root: Path,
272
257
  task_types: Optional[tuple[str, ...]] = None,
273
- *,
274
- include_stages: bool = False,
275
258
  ) -> Optional["RunRef"]:
276
- """여러 task-type 을 가로질러 최신 final-report 를 가리키는 ref.
259
+ """task_root 아래 여러 task-type 을 가로질러 최신 final-report 를 찾는다.
277
260
 
278
261
  `task_types` 를 주지 않으면 runs/ 에 실제로 존재하는 모든 task-type 을
279
262
  훑는다. 비교 규칙은 `latest` 와 같다(mtime, 동률이면 basename).
280
263
 
281
- `include_stages` 를 켜면 stage-isolated task-type 의 `stage-<N>/reports/`
282
- 도 함께 훑는다 — phase-cleanup 이 staged run 을 자동발견할 때 쓴다.
283
- """
284
- return cls.latest_under(
285
- task_dir(project_root, task_group, task_id),
286
- task_types,
287
- include_stages=include_stages,
288
- )
289
-
290
- @classmethod
291
- def latest_under(
292
- cls,
293
- task_root: Path,
294
- task_types: Optional[tuple[str, ...]] = None,
295
- *,
296
- include_stages: bool = False,
297
- ) -> Optional["RunRef"]:
298
- """`latest_across` 의 task_root 진입점.
299
-
300
264
  task-key 단축키/오타 진단을 이미 거쳐 task_root 를 손에 쥔 호출자
301
265
  (bash resume-clarification)가 쓴다. 그 호출자는 언제나 flat 분석 phase 만
302
- 넘기므로 기본값(`include_stages=False`)이범위를 그대로 보존한다.
266
+ 넘기므로 stage 하위는 훑지 않는다 — flat `reports/` 고정이 계약이다.
303
267
  """
304
268
  runs_dir = runs_dir_of(task_root)
305
269
  if task_types is None:
@@ -308,10 +272,9 @@ class RunRef:
308
272
  task_types = tuple(sorted(p.name for p in runs_dir.iterdir() if p.is_dir()))
309
273
  candidates: list[Path] = []
310
274
  for task_type in task_types:
311
- for reports_dir in _report_dirs(runs_dir / task_type, include_stages):
312
- found = _newest_report(reports_dir)
313
- if found is not None:
314
- candidates.append(found)
275
+ found = _newest_report(runs_dir / task_type / "reports")
276
+ if found is not None:
277
+ candidates.append(found)
315
278
  if not candidates:
316
279
  return None
317
280
  return cls.from_report_path(
@@ -347,6 +347,7 @@ def _active_run(ctx: dict) -> dict:
347
347
  "teamStatePath": ctx.get("TEAM_STATE_RELATIVE_PATH", ""),
348
348
  "promptSnapshotPath": ctx.get("RUN_PROMPT_SNAPSHOT_RELATIVE_PATH", ""),
349
349
  "finalReportPath": ctx.get("FINAL_REPORT_RELATIVE_PATH", ""),
350
+ "convergenceStatePath": ctx.get("CONVERGENCE_STATE_RELATIVE_PATH", ""),
350
351
  "finalStatusPath": ctx.get("FINAL_STATUS_RELATIVE_PATH", ""),
351
352
  "validatorScriptPath": ctx.get("RUN_VALIDATOR_RELATIVE_PATH", ""),
352
353
  "resumeCommandPath": ctx.get("CLAUDE_RESUME_COMMAND_RELATIVE_PATH", ""),
@@ -1389,6 +1390,7 @@ def render_run_manifest(run_manifest_path: str, ctx: dict) -> None:
1389
1390
  "expectedStatusPath": ctx.get("FINAL_STATUS_RELATIVE_PATH", ""),
1390
1391
  "teamStatePath": ctx.get("TEAM_STATE_RELATIVE_PATH", ""),
1391
1392
  "activeRunContextPath": ctx.get("ACTIVE_RUN_CONTEXT_RELATIVE_PATH", ""),
1393
+ "convergenceStatePath": ctx.get("CONVERGENCE_STATE_RELATIVE_PATH", ""),
1392
1394
  "analysisPacketPath": ctx.get("ANALYSIS_PACKET_RELATIVE_PATH", ""),
1393
1395
  "analysisEvidencePath": (
1394
1396
  ctx.get("INSTRUCTION_SET_RELATIVE_PATH", "") + "/analysis-evidence.md"
@@ -160,6 +160,7 @@ _BRIEF_ENTRY_TASK_TYPES = (
160
160
 
161
161
  CANONICAL_BASE_REFS = ["main", "dev", "staging", "preprod", "prod"]
162
162
  BASE_REF_FREE_INPUT_TOKEN = "__free_input__"
163
+ HOST_PICK_OPTION_CAP = 4
163
164
 
164
165
  CLAUDE_MODEL_OPTIONS = ["default", *picker_options("claude")]
165
166
  CODEX_MODEL_OPTIONS = ["default", *picker_options("codex")]
@@ -498,6 +499,8 @@ class Prompt:
498
499
  "echoTemplate": self.echo_template,
499
500
  "multi": self.multi,
500
501
  }
502
+ if self.kind == "pick" and len(self.options) > HOST_PICK_OPTION_CAP:
503
+ out["presentation"] = "numbered-text"
501
504
  if self.kind == "pick_group":
502
505
  out["questions"] = [
503
506
  {"step": q.step, "label": q.label,
@@ -512,6 +515,35 @@ class WizardError(Exception):
512
515
  """validation failure surfaced to user."""
513
516
 
514
517
 
518
+ def _normalize_numbered_pick_item(prompt: Prompt, answer: str) -> str:
519
+ candidate = (answer or "").strip()
520
+ exact_values = [option.value for option in prompt.options if option.value == candidate]
521
+ if exact_values:
522
+ return exact_values[0]
523
+
524
+ if candidate.isdecimal():
525
+ number = int(candidate)
526
+ if 1 <= number <= len(prompt.options):
527
+ return prompt.options[number - 1].value
528
+ raise WizardError(f"numbered-text answer is out of range: {candidate}")
529
+
530
+ label_values = [option.value for option in prompt.options if option.label == candidate]
531
+ if len(label_values) == 1:
532
+ return label_values[0]
533
+ if len(label_values) > 1:
534
+ raise WizardError(f"numbered-text answer label is ambiguous: {candidate}")
535
+ raise WizardError(f"numbered-text answer is not an option: {candidate}")
536
+
537
+
538
+ def _normalize_numbered_pick_answer(prompt: Prompt, answer: str) -> str:
539
+ if prompt.multi:
540
+ return ",".join(
541
+ _normalize_numbered_pick_item(prompt, item)
542
+ for item in (answer or "").split(",")
543
+ )
544
+ return _normalize_numbered_pick_item(prompt, answer)
545
+
546
+
515
547
  # ---- Validation helpers --------------------------------------------------
516
548
 
517
549
  _SLUG_OK = re.compile(r"[a-z0-9]")
@@ -4328,6 +4360,8 @@ def submit(state: WizardState, value: str) -> dict[str, Any]:
4328
4360
  return {"echo": "", "next": prompt_payload(state, prompt)}
4329
4361
  if prompt.kind == "pick_group":
4330
4362
  return _submit_group(state, prompt, value)
4363
+ if prompt.kind == "pick" and len(prompt.options) > HOST_PICK_OPTION_CAP:
4364
+ value = _normalize_numbered_pick_answer(prompt, value)
4331
4365
  step = STEP_BY_ID[prompt.step]
4332
4366
  echo = step.submit(state, value or "")
4333
4367
  if prompt.step not in state.answered:
@@ -15,10 +15,10 @@ Two probes, matching the two ways a pending worker goes quiet:
15
15
  * ``--audit`` — an in-process worker audit sidecar. Stale past the heartbeat
16
16
  cadence (or present with no heartbeat at all) means the worker hung. Uses
17
17
  the same line shape and budget the Phase 7 validator applies.
18
- * ``--prompt`` — a CLI-wrapper prompt-history path. Neither `<prompt>.log` nor
19
- `<prompt>.status.json` past the launch grace means the wrapper never ran —
20
- the dispatch itself failed, which no artifact otherwise distinguishes from a
21
- worker that has merely not finished.
18
+ * ``--team-state`` + ``--worker`` — a CLI-wrapper assignment. Neither the
19
+ worker's `<prompt>.log` nor `<prompt>.status.json` past the launch grace means
20
+ the wrapper never ran. The grace starts at the persisted dispatch timestamp,
21
+ not when the prompt was materialized.
22
22
  """
23
23
  from __future__ import annotations
24
24
 
@@ -28,6 +28,7 @@ import sys
28
28
  from datetime import datetime, timezone
29
29
  from pathlib import Path
30
30
 
31
+ from okstra_ctl.dispatch_state import DispatchError, load_json_object
31
32
  from okstra_ctl.worker_heartbeat import (
32
33
  HEARTBEAT_MAX_GAP_SECONDS,
33
34
  latest_heartbeat,
@@ -80,43 +81,97 @@ def probe_heartbeat(sidecar: Path, now: datetime, max_idle: float) -> dict:
80
81
  }
81
82
 
82
83
 
83
- def probe_launch(prompt: Path, now: datetime, grace: float) -> dict:
84
+ def probe_launch(
85
+ prompt: Path, dispatched_at: datetime, now: datetime, grace: float
86
+ ) -> dict:
84
87
  """Whether the wrapper behind *prompt* ever started."""
85
88
  log, status = _log_path(prompt), Path(f"{prompt}.status.json")
86
89
  probe = {"kind": "launch", "path": str(prompt)}
87
90
  if log.exists() or status.exists():
88
91
  return {**probe, "state": "live", "reason": ""}
89
- if not prompt.is_file():
90
- return {**probe, "state": "pending", "reason": "prompt history not written yet"}
91
- waited = now.timestamp() - prompt.stat().st_mtime
92
+ waited = (now - dispatched_at).total_seconds()
92
93
  if waited <= grace:
93
- return {**probe, "state": "pending", "reason": "within launch grace"}
94
+ return {
95
+ **probe,
96
+ "state": "pending",
97
+ "waitedSeconds": int(waited),
98
+ "reason": "within launch grace",
99
+ }
94
100
  return {
95
101
  **probe,
96
102
  "state": "did-not-launch",
97
103
  "waitedSeconds": int(waited),
98
104
  "reason": (
99
105
  f"neither {log.name} nor {status.name} exists {int(waited)}s after the "
100
- f"prompt was written (grace {int(grace)}s)"
106
+ f"dispatch started (grace {int(grace)}s)"
101
107
  ),
102
108
  }
103
109
 
104
110
 
105
111
  def probe_all(
106
112
  audits: list[str],
107
- prompts: list[str],
113
+ launches: list[tuple[str, datetime]],
108
114
  *,
109
115
  now: datetime,
110
116
  max_idle: float,
111
117
  launch_grace: float,
112
118
  ) -> dict:
113
119
  probes = [probe_heartbeat(Path(p), now, max_idle) for p in audits]
114
- probes += [probe_launch(Path(p), now, launch_grace) for p in prompts]
120
+ probes += [
121
+ probe_launch(Path(prompt), dispatched_at, now, launch_grace)
122
+ for prompt, dispatched_at in launches
123
+ ]
115
124
  unhealthy = [p for p in probes if p["state"] in ("stalled", "did-not-launch")]
116
125
  return {"ok": not unhealthy, "checkedAt": now.isoformat(), "probes": probes,
117
126
  "unhealthy": unhealthy}
118
127
 
119
128
 
129
+ def _parse_utc(value: object, label: str) -> datetime:
130
+ if not isinstance(value, str) or not value:
131
+ raise DispatchError(f"{label} must be a UTC ISO timestamp")
132
+ try:
133
+ instant = datetime.fromisoformat(value.replace("Z", "+00:00"))
134
+ except ValueError as exc:
135
+ raise DispatchError(f"{label} must be a UTC ISO timestamp") from exc
136
+ if instant.tzinfo is None or instant.utcoffset() != timezone.utc.utcoffset(instant):
137
+ raise DispatchError(f"{label} must be a UTC ISO timestamp")
138
+ return instant.astimezone(timezone.utc)
139
+
140
+
141
+ def _project_root_for_team_state(team_state_path: Path) -> Path:
142
+ for parent in team_state_path.resolve().parents:
143
+ if parent.name == ".okstra":
144
+ return parent.parent
145
+ raise DispatchError(
146
+ f"team-state is outside a project .okstra directory: {team_state_path}"
147
+ )
148
+
149
+
150
+ def _launch_target(team_state_value: str, worker_id: str) -> tuple[str, datetime]:
151
+ team_state_path = Path(team_state_value).resolve()
152
+ state = load_json_object(team_state_path, "team-state")
153
+ workers = state.get("workers")
154
+ if not isinstance(workers, list):
155
+ raise DispatchError(f"team-state workers must be an array: {team_state_path}")
156
+ worker = next(
157
+ (
158
+ row for row in workers
159
+ if isinstance(row, dict) and row.get("workerId") == worker_id
160
+ ),
161
+ None,
162
+ )
163
+ if worker is None:
164
+ raise DispatchError(f"team-state has no workerId={worker_id}: {team_state_path}")
165
+ prompt_value = worker.get("promptPath")
166
+ if not isinstance(prompt_value, str) or not prompt_value.strip():
167
+ raise DispatchError(f"worker {worker_id} has no promptPath")
168
+ prompt = Path(prompt_value)
169
+ if not prompt.is_absolute():
170
+ prompt = _project_root_for_team_state(team_state_path) / prompt
171
+ dispatched_at = _parse_utc(worker.get("startedAt"), f"worker {worker_id} startedAt")
172
+ return str(prompt), dispatched_at
173
+
174
+
120
175
  def main(argv: list[str] | None = None) -> int:
121
176
  parser = argparse.ArgumentParser(
122
177
  prog="okstra worker-liveness",
@@ -124,12 +179,10 @@ def main(argv: list[str] | None = None) -> int:
124
179
  )
125
180
  parser.add_argument("--audit", action="append", default=[],
126
181
  help="in-process worker audit sidecar path (repeatable)")
127
- parser.add_argument("--prompt", action="append", default=[],
128
- help="CLI-wrapper (codex/antigravity) prompt-history path "
129
- "(repeatable). Only the wrappers write the artifacts "
130
- "this probe looks for, so passing an in-process "
131
- "claude-worker path here always reports "
132
- "did-not-launch — probe those with --audit")
182
+ parser.add_argument("--team-state", action="append", default=[],
183
+ help="team-state path for a CLI-wrapper assignment (repeatable)")
184
+ parser.add_argument("--worker", action="append", default=[],
185
+ help="worker id paired with --team-state (repeatable)")
133
186
  parser.add_argument("--max-idle", type=float, default=HEARTBEAT_MAX_GAP_SECONDS,
134
187
  help="heartbeat staleness budget in seconds")
135
188
  parser.add_argument("--launch-grace", type=float, default=DEFAULT_LAUNCH_GRACE_SECONDS,
@@ -137,12 +190,22 @@ def main(argv: list[str] | None = None) -> int:
137
190
  parser.add_argument("--json", action="store_true", help="emit JSON (always on)")
138
191
  args = parser.parse_args(argv)
139
192
 
140
- if not args.audit and not args.prompt:
141
- parser.error("pass at least one --audit or --prompt path")
193
+ if len(args.team_state) != len(args.worker):
194
+ parser.error("each --team-state must have one paired --worker")
195
+ if not args.audit and not args.team_state:
196
+ parser.error("pass at least one --audit or --team-state/--worker pair")
197
+
198
+ try:
199
+ launches = [
200
+ _launch_target(team_state, worker)
201
+ for team_state, worker in zip(args.team_state, args.worker, strict=True)
202
+ ]
203
+ except DispatchError as exc:
204
+ parser.error(str(exc))
142
205
 
143
206
  result = probe_all(
144
207
  args.audit,
145
- args.prompt,
208
+ launches,
146
209
  now=datetime.now(timezone.utc),
147
210
  max_idle=args.max_idle,
148
211
  launch_grace=args.launch_grace,
@@ -90,8 +90,8 @@ def report_writer_prompt_body(
90
90
  "",
91
91
  "## Role",
92
92
  (
93
- "You are the Report writer worker. Author the final-report data.json "
94
- "and the worker-results audit file."
93
+ "You are the Report writer worker. Author the final-report data.json, "
94
+ "its rendered Markdown sibling, and the worker-result pointer."
95
95
  ),
96
96
  "",
97
97
  "## Task",
@@ -104,9 +104,15 @@ def report_writer_prompt_body(
104
104
  mcp_pointer_line(),
105
105
  "",
106
106
  "## Output Contract",
107
- "You are the author of TWO files:",
107
+ "You are the author of THREE files:",
108
108
  "- The final-report data.json at Result Path.",
109
- "- The worker-results audit file at Audit sidecar path.",
109
+ "- The rendered Markdown sibling produced through okstra render-final-report.",
110
+ "- The worker-result pointer at Worker Result Path.",
111
+ (
112
+ "Keep the pointer to three entries: the data.json path, rendered "
113
+ "Markdown path, and Convergence state input path."
114
+ ),
115
+ "Maintain the separate audit sidecar at Audit sidecar path.",
110
116
  (
111
117
  'After writing the data.json, invoke "okstra render-final-report '
112
118
  '<Result Path>" so the markdown sibling is rendered before you return.'
@@ -129,6 +135,7 @@ def report_writer_input_lines(
129
135
  ("Clarification response", instruction_path(manifest, active_context, "clarificationResponsePath")),
130
136
  ("Final report template", instruction_path(manifest, active_context, "finalReportTemplatePath")),
131
137
  ("Final report schema", instruction_path(manifest, active_context, "finalReportSchemaPath")),
138
+ ("Convergence state", run_path(manifest, active_context, "convergenceStatePath")),
132
139
  ("Worker results directory", _string_value(manifest.get("workerResultsDirectoryPath"))),
133
140
  ]
134
141
  lines = existing_input_lines(inputs)
@@ -171,6 +178,19 @@ def instruction_path(
171
178
  return _string_value(manifest.get(key))
172
179
 
173
180
 
181
+ def run_path(
182
+ manifest: Mapping[str, Any],
183
+ active_context: Mapping[str, Any],
184
+ key: str,
185
+ ) -> str:
186
+ run = active_context.get("run")
187
+ if isinstance(run, Mapping):
188
+ value = _string_value(run.get(key))
189
+ if value:
190
+ return value
191
+ return _string_value(manifest.get(key))
192
+
193
+
174
194
  def mcp_pointer_line() -> str:
175
195
  return (
176
196
  '**MCP servers:** follow the task brief\'s "## Available MCP Servers" '