okstra 0.125.6 → 0.128.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 (48) hide show
  1. package/docs/architecture/storage-model.md +10 -3
  2. package/docs/architecture.md +14 -2
  3. package/docs/cli.md +1 -0
  4. package/docs/project-structure-overview.md +9 -6
  5. package/package.json +1 -1
  6. package/runtime/BUILD.json +2 -2
  7. package/runtime/agents/workers/report-writer-worker.md +1 -1
  8. package/runtime/bin/okstra-antigravity-exec.sh +8 -2
  9. package/runtime/bin/okstra-claude-exec.sh +8 -2
  10. package/runtime/bin/okstra-codex-exec.sh +65 -8
  11. package/runtime/prompts/lead/adapters/claude-code.md +1 -0
  12. package/runtime/prompts/lead/convergence.md +8 -1
  13. package/runtime/prompts/lead/okstra-lead-contract.md +26 -1
  14. package/runtime/prompts/lead/report-writer.md +1 -1
  15. package/runtime/prompts/lead/team-contract.md +14 -3
  16. package/runtime/prompts/profiles/final-verification.md +10 -6
  17. package/runtime/python/okstra_ctl/analysis_packet.py +7 -2
  18. package/runtime/python/okstra_ctl/codex_dispatch.py +27 -1
  19. package/runtime/python/okstra_ctl/design_surfaces.py +30 -14
  20. package/runtime/python/okstra_ctl/dispatch_core.py +20 -0
  21. package/runtime/python/okstra_ctl/log_report.py +44 -5
  22. package/runtime/python/okstra_ctl/path_hints.py +5 -0
  23. package/runtime/python/okstra_ctl/render.py +22 -0
  24. package/runtime/python/okstra_ctl/render_final_report.py +71 -5
  25. package/runtime/python/okstra_ctl/run.py +69 -3
  26. package/runtime/python/okstra_ctl/schema_excerpt.py +17 -1
  27. package/runtime/python/okstra_ctl/wizard.py +15 -4
  28. package/runtime/python/okstra_ctl/work_categories.py +37 -0
  29. package/runtime/python/okstra_ctl/worker_heartbeat.py +50 -0
  30. package/runtime/python/okstra_ctl/worker_liveness.py +140 -0
  31. package/runtime/python/okstra_ctl/worker_prompt_contract.py +223 -0
  32. package/runtime/python/okstra_ctl/worker_prompt_headers.py +51 -2
  33. package/runtime/templates/reports/final-report.template.md +9 -9
  34. package/runtime/templates/worker-prompt-preamble.md +13 -5
  35. package/runtime/validators/forbidden_actions.py +43 -4
  36. package/runtime/validators/validate-implementation-plan-stages.py +6 -4
  37. package/runtime/validators/validate-report-views.py +4 -4
  38. package/runtime/validators/validate-run.py +61 -6
  39. package/runtime/validators/validate-schedule.py +6 -4
  40. package/runtime/validators/validate_fanout.py +4 -3
  41. package/runtime/validators/validate_improvement_report.py +6 -4
  42. package/runtime/validators/validate_session_conformance.py +16 -7
  43. package/src/cli-registry.mjs +7 -0
  44. package/src/commands/inspect/log-report.mjs +5 -3
  45. package/src/commands/inspect/worker-liveness.mjs +31 -0
  46. package/src/commands/lifecycle/preflight.mjs +25 -2
  47. package/src/lib/helper-scripts.mjs +23 -0
  48. package/src/lib/python-helper.mjs +1 -1
@@ -91,29 +91,37 @@ If a required header line is absent from the dispatch prompt, return `<SENTINEL_
91
91
 
92
92
  ## Anchor headers (lead-injected, BLOCKING)
93
93
 
94
- Every worker prompt MUST begin with these anchor headers, in this exact order, before any other content. Workers extract them verbatim.
94
+ Every initial worker prompt MUST begin with the common anchor headers below in this exact order, before any other content. An `implementation` prompt inserts the implementation-only coding-preflight anchor at the documented position. Workers extract the anchors verbatim.
95
95
 
96
96
  1. `**Project Root:** <absolute-path>` — required so the worker can self-anchor without relying on inherited cwd.
97
97
  2. `**Prompt History Path:** <project-relative-path>`
98
98
  3. `**Result Path:** <project-relative-path>` — canonical destination for the worker's result file.
99
99
  4. `Assigned worker prompt history path: <absolute-path>` — same as the prompt-history path but resolved against `Project Root`. Codex / Antigravity wrapper subagents extract this exact line.
100
100
  5. `**Worker Preamble Path:** <absolute-path>` — points to THIS file. Workers Read it end-to-end before doing anything else.
101
- 6. `**Coding preflight pack:** <absolute-path>` — points to the installed runtime resource pack under `<OKSTRA_HOME>/prompts/coding-preflight`. Implementation executors and verifiers use this to read `overview.md`, `clean-code.md`, and routed language/framework/architecture resources. Other worker roles may ignore it.
101
+ 6. `**Coding preflight pack:** <absolute-path>` — implementation-only; points to the installed runtime resource pack under `<OKSTRA_HOME>/prompts/coding-preflight`. It is present only when `taskType == implementation` and is absent from every non-implementation initial prompt. Implementation executors and implementation verifiers use it to read `overview.md`, `clean-code.md`, and routed language/framework/architecture resources. A pane display role named `verifier` does not activate this anchor in `final-verification`.
102
102
  7. `**Errors log path:** <absolute-path>` — run-level JSONL (see Error reporting above).
103
103
  8. `**Errors sidecar path:** <absolute-path>` — per-worker JSON (see Error reporting above).
104
+ 9. `**Read scope:** …` — restates the *Reading rules* allowlist inline. This file is anchor 5, a path the worker opens partway into its run; by then a host SessionStart hook or a global `CLAUDE.md` / `AGENTS.md` project-conditional has already pointed it at `graphify-out/`, skill catalogs, and similar non-okstra artifacts. Only the inline copy arrives before the worker's first tool call.
104
105
 
105
- **Exception — reverify dispatches.** A Phase 5.5 re-verification prompt (its `**Prompt History Path:**` carries a `-reverify-r<N>-` segment) deliberately omits anchors 5 and 6 and adds a `**Model:**` line — lightweight mode reads neither this file nor the preflight pack. The two errors-path anchors are NOT omitted; that gate applies to reverify prompts unchanged. The reverify anchor set is owned by `prompts/lead/convergence.md` "Required reverify-prompt anchor headers".
106
+ **Exception — reverify dispatches.** A Phase 5.5 re-verification prompt (its `**Prompt History Path:**` carries a `-reverify-r<N>-` segment) deliberately omits anchor 5 and adds a `**Model:**` line — lightweight mode reads neither this file nor the implementation-only preflight pack. The two errors-path anchors are NOT omitted; that gate applies to reverify prompts unchanged. The reverify anchor set is owned by `prompts/lead/convergence.md` "Required reverify-prompt anchor headers".
106
107
 
107
108
  For the **implementation phase** specifically, the dispatched prompt MUST also include:
108
109
 
109
110
  - `**Worktree:** <absolute-path>` — the task worktree path.
110
111
  - `cwd for every mutating command: <absolute-path>` — same as Worktree path; used by codex / antigravity wrappers as `--add-dir` / `--include-directories`.
111
112
 
112
- For the **final-verification phase** specifically, the dispatched prompt MUST also include the verification target snapshot so every analyser verifies the SAME target the lead captured at the entry gate:
113
+ For the **final-verification phase** specifically, the dispatched prompt MUST include the compact identity of the canonical verification-target sidecar so every analyser verifies the SAME target the lead captured at the entry gate:
113
114
 
114
115
  - `**Worktree:** <absolute-path>` — the checkout under verification (read-only).
116
+ - `**Verification scope:** <whole-task|single-stage>` — the selected verification mode.
115
117
  - `**Verification base ref:** <base-ref>` — the implementation base for the diff.
116
- - `**Verification diff stat:** <git diff --stat output>` — the captured diff-stat that bounds the verification scope.
118
+ - `**Verification head ref:** <head-ref>` — the exact delivered revision under verification.
119
+ - `**Verification target path:** <project-relative-path>` — the canonical `instruction-set/verification-target.md` sidecar containing stages, source reports, and the complete captured diff stat.
120
+ - `**Verification target digest:** sha256:<digest>` — the digest over the normalized snapshot body.
121
+
122
+ Read the sidecar on demand when the full stage/report mapping or diff stat is needed. Do not copy its `Verification diff stat` block into the dispatch prompt.
123
+
124
+ The initial analysis prompt may contain exactly one `## Run-specific directive` section only when the run has a shared delta not already captured by the packet. The section applies unchanged to every selected analysis worker and is limited to 40 nonblank lines. If it would be longer, the lead writes the shared material into the instruction set and adds its common reference to `analysis-packet.md`; workers must not accept provider-, model-, or worker-specific verification scopes.
117
125
 
118
126
  For the **improvement-discovery phase** specifically, the dispatched prompt MUST also include the resolved-scope log the lead wrote during Phase 1.5, so workers key off an explicit absolute path instead of re-deriving the run directory:
119
127
 
@@ -15,9 +15,10 @@ Bash tool_use 명령을 phase deny-list 와 대조해 위반을 기계적으로
15
15
  한계 1: claude-code 세션 jsonl 만 스캔한다. codex/antigravity lead·worker 는 동일 형식의
16
16
  tool_use transcript 를 ~/.claude/projects 에 남기지 않으므로 이 스캐너의 사정권 밖이며,
17
17
  해당 런타임의 금지 행위는 여전히 수동 감사에 의존한다.
18
- 한계 2: deny-list 는 Bash 명령 문자열 전체를 검색하므로, 금지 토큰이 인자/메시지에
19
- 등장하는 경우(예: `git commit -m "... npm publish ..."`)도 위반으로 잡힐 수 있다.
20
- `cd <path> && <cmd>` 형태 때문에 명령 head 앵커링은 불가하므로 이 잔여 오탐은 감수한다.
18
+ 한계 2: deny-list 는 heredoc 본문을 걷어낸(strip_heredoc_bodies) 명령 문자열 전체를
19
+ 검색하므로, 금지 토큰이 인자/메시지에 등장하는 경우(예: `git commit -m "... npm
20
+ publish ..."`)는 여전히 위반으로 잡힐 수 있다. `cd <path> && <cmd>` 형태 때문에 명령
21
+ head 앵커링은 불가하므로 이 잔여 오탐은 감수한다.
21
22
  """
22
23
  from __future__ import annotations
23
24
 
@@ -47,6 +48,43 @@ _UNIVERSAL = [
47
48
  ]
48
49
  _NON_HANDOFF_PUSH = ("git push", re.compile(r"\bgit\s+push\b"))
49
50
 
51
+ _HEREDOC_START = re.compile(r"<<-?\s*(['\"]?)([A-Za-z_][A-Za-z0-9_]*)\1")
52
+
53
+
54
+ def strip_heredoc_bodies(command: str) -> str:
55
+ """heredoc 본문을 걷어낸 명령 문자열.
56
+
57
+ heredoc 안의 토큰은 실행되는 명령이 아니라 파일에 **기록되는 데이터**다.
58
+ implementation-planning 은 배포 명령을 서술하는 계획서를 산출물로 내고 그
59
+ 문서를 `python3 - <<'PY'` / `cat > … <<EOF` 로 패치하므로, 본문을 그대로
60
+ 스캔하면 정상 경로에서 매 run 오탐이 난다. heredoc 시작 줄과 종료 구분자는
61
+ 남겨 실제 명령 위치의 토큰은 계속 잡는다.
62
+
63
+ 종료 구분자가 뒤에 없으면 아무것도 걷어내지 않는다 — `python3 -c "1 << shift"`
64
+ 같은 시프트 연산이 heredoc 으로 오인돼 뒤따르는 진짜 명령을 숨기지 않도록,
65
+ 본문 제거는 짝이 맞는 구분자를 실제로 찾았을 때만 한다.
66
+ """
67
+ lines = command.split("\n")
68
+ kept: list[str] = []
69
+ index = 0
70
+ while index < len(lines):
71
+ line = lines[index]
72
+ kept.append(line)
73
+ index += 1
74
+ start = _HEREDOC_START.search(line)
75
+ if not start:
76
+ continue
77
+ delimiter = start.group(2)
78
+ end = next(
79
+ (i for i in range(index, len(lines)) if lines[i].strip() == delimiter),
80
+ None,
81
+ )
82
+ if end is None:
83
+ continue
84
+ kept.append(lines[end])
85
+ index = end + 1
86
+ return "\n".join(kept)
87
+
50
88
 
51
89
  def forbidden_patterns_for(task_type: str) -> list[tuple[str, re.Pattern]]:
52
90
  """task_type(=phase profile) 의 deny-list. 모든 phase 가 _UNIVERSAL 을 받고,
@@ -104,8 +142,9 @@ def _scan_one(
104
142
  if block.get("type") != "tool_use" or block.get("name") != "Bash":
105
143
  continue
106
144
  command = (block.get("input") or {}).get("command") or ""
145
+ executed = strip_heredoc_bodies(command)
107
146
  for label, pattern in patterns:
108
- if pattern.search(command):
147
+ if pattern.search(executed):
109
148
  hits.append((label, command))
110
149
  break # one violation per command — most-specific pattern wins
111
150
  return hits
@@ -12,10 +12,12 @@ from dataclasses import dataclass
12
12
  from pathlib import Path
13
13
  from typing import List, Tuple
14
14
 
15
- # scripts/ is not a package; insert it so okstra_ctl is importable directly.
16
- _SCRIPTS_DIR = Path(__file__).resolve().parent.parent / "scripts"
17
- if str(_SCRIPTS_DIR) not in sys.path:
18
- sys.path.insert(0, str(_SCRIPTS_DIR))
15
+ # scripts/ (repo) and python/ (installed under ~/.okstra/lib) are not packages;
16
+ # insert whichever exists so okstra_ctl is importable directly.
17
+ _VALIDATORS_DIR = Path(__file__).resolve().parent
18
+ for _ssot_dir in (_VALIDATORS_DIR.parent / "scripts", _VALIDATORS_DIR.parent / "python"):
19
+ if _ssot_dir.is_dir() and str(_ssot_dir) not in sys.path:
20
+ sys.path.insert(0, str(_ssot_dir))
19
21
 
20
22
  from okstra_ctl.md_table import split_pipe_row # noqa: E402
21
23
 
@@ -24,10 +24,10 @@ import re
24
24
  import sys
25
25
  from pathlib import Path
26
26
 
27
- REPO_ROOT = Path(__file__).resolve().parents[1]
28
- SCRIPTS_DIR = REPO_ROOT / "scripts"
29
- if str(SCRIPTS_DIR) not in sys.path:
30
- sys.path.insert(0, str(SCRIPTS_DIR))
27
+ _VALIDATORS_DIR = Path(__file__).resolve().parent
28
+ for _ssot_dir in (_VALIDATORS_DIR.parent / "scripts", _VALIDATORS_DIR.parent / "python"):
29
+ if _ssot_dir.is_dir() and str(_ssot_dir) not in sys.path:
30
+ sys.path.insert(0, str(_ssot_dir))
31
31
 
32
32
  from okstra_ctl.clarification_items import ( # noqa: E402
33
33
  parse_clarification_items,
@@ -11,13 +11,14 @@ import sys
11
11
  from datetime import datetime, timezone
12
12
  from pathlib import Path
13
13
 
14
- # Make the in-tree ``okstra_ctl`` package importable when this validator
15
- # runs from the repo (the installed runtime already has ~/.okstra/lib/python
16
- # on PYTHONPATH so the import is a no-op there).
14
+ # Make the okstra packages importable however this validator is reached:
15
+ # ``scripts/`` next to the repo checkout, ``python/`` next to the installed
16
+ # copy under ~/.okstra/lib. The manifests advertise the bare script path, so
17
+ # it must self-bootstrap rather than rely on an inherited PYTHONPATH.
17
18
  _VALIDATORS_DIR = Path(__file__).resolve().parent
18
- _SCRIPTS_DIR = _VALIDATORS_DIR.parent / "scripts"
19
- if _SCRIPTS_DIR.is_dir() and str(_SCRIPTS_DIR) not in sys.path:
20
- sys.path.insert(0, str(_SCRIPTS_DIR))
19
+ for _ssot_dir in (_VALIDATORS_DIR.parent / "scripts", _VALIDATORS_DIR.parent / "python"):
20
+ if _ssot_dir.is_dir() and str(_ssot_dir) not in sys.path:
21
+ sys.path.insert(0, str(_ssot_dir))
21
22
 
22
23
  try:
23
24
  from okstra_ctl.final_report_schema import (
@@ -57,6 +58,9 @@ from okstra_ctl.design_surfaces import ( # noqa: E402
57
58
  detect_design_surfaces,
58
59
  expected_prep_plan_item_id,
59
60
  )
61
+ from okstra_ctl.worker_prompt_contract import ( # noqa: E402
62
+ validate_final_verification_prompt_paths,
63
+ )
60
64
 
61
65
  TERMINAL_STATUSES = {"completed", "timeout", "error", "not-run"}
62
66
  ATTEMPTED_STATUSES = {"completed", "timeout", "error"}
@@ -366,6 +370,48 @@ def effective_run_task_type(run_manifest: dict, task_manifest: dict) -> str:
366
370
  ).strip()
367
371
 
368
372
 
373
+ def _validate_final_verification_initial_prompts(
374
+ data: dict,
375
+ failures: list[str],
376
+ ) -> None:
377
+ """Validate the selected initial analysis prompts persisted for the run."""
378
+ if data.get("taskType") != "final-verification":
379
+ return
380
+
381
+ run_manifest = data.get("runManifest") or {}
382
+ team_contract = run_manifest.get("teamContract") or {}
383
+ selected_workers = team_contract.get("requiredWorkerRoles") or []
384
+ selected_ids = {
385
+ str(worker.get("workerId") or "").strip()
386
+ for worker in selected_workers
387
+ if isinstance(worker, dict)
388
+ }
389
+ project_root = Path(data["projectRoot"])
390
+ team_state = data.get("teamState") or {}
391
+ prompt_paths: dict[str, Path] = {}
392
+ for worker in team_state.get("workers") or []:
393
+ if not isinstance(worker, dict):
394
+ continue
395
+ worker_id = str(worker.get("workerId") or "").strip()
396
+ prompt_value = str(worker.get("promptPath") or "").strip()
397
+ if (
398
+ worker_id not in selected_ids
399
+ or worker_id == "report-writer"
400
+ or not prompt_value
401
+ or "-reverify-r" in Path(prompt_value).name
402
+ ):
403
+ continue
404
+ prompt_path = Path(prompt_value)
405
+ prompt_paths[worker_id] = (
406
+ prompt_path if prompt_path.is_absolute() else project_root / prompt_path
407
+ )
408
+
409
+ errors = validate_final_verification_prompt_paths(prompt_paths)
410
+ failures.extend(
411
+ f"final-verification prompt contract: {error}" for error in errors
412
+ )
413
+
414
+
369
415
  def _is_legal_concurrent_run_skip(
370
416
  team_create: object, concurrent_run_authorized: bool
371
417
  ) -> bool:
@@ -3523,6 +3569,15 @@ def main() -> int:
3523
3569
  # contain every required section. Substring checks below are a
3524
3570
  # safety net for hand-edited or pre-v1.0 reports.
3525
3571
  task_type = effective_run_task_type(run_manifest, task_manifest)
3572
+ _validate_final_verification_initial_prompts(
3573
+ {
3574
+ "taskType": task_type,
3575
+ "projectRoot": project_root,
3576
+ "runManifest": run_manifest,
3577
+ "teamState": team_state,
3578
+ },
3579
+ failures,
3580
+ )
3526
3581
  report_contracts = _normalize_report_contracts(
3527
3582
  run_manifest.get("reportContracts")
3528
3583
  )
@@ -17,10 +17,12 @@ import re
17
17
  import sys
18
18
  from pathlib import Path
19
19
 
20
- # scripts/ is not a package; insert it so okstra_ctl is importable directly.
21
- _SCRIPTS_DIR = Path(__file__).resolve().parent.parent / "scripts"
22
- if str(_SCRIPTS_DIR) not in sys.path:
23
- sys.path.insert(0, str(_SCRIPTS_DIR))
20
+ # scripts/ (repo) and python/ (installed under ~/.okstra/lib) are not packages;
21
+ # insert whichever exists so okstra_ctl is importable directly.
22
+ _VALIDATORS_DIR = Path(__file__).resolve().parent
23
+ for _ssot_dir in (_VALIDATORS_DIR.parent / "scripts", _VALIDATORS_DIR.parent / "python"):
24
+ if _ssot_dir.is_dir() and str(_ssot_dir) not in sys.path:
25
+ sys.path.insert(0, str(_ssot_dir))
24
26
 
25
27
  from okstra_ctl.md_table import split_pipe_row # noqa: E402
26
28
 
@@ -10,9 +10,10 @@ import sys
10
10
  from dataclasses import dataclass, field
11
11
  from pathlib import Path
12
12
 
13
- _SCRIPTS_DIR = Path(__file__).resolve().parent.parent / "scripts"
14
- if str(_SCRIPTS_DIR) not in sys.path:
15
- sys.path.insert(0, str(_SCRIPTS_DIR))
13
+ _VALIDATORS_DIR = Path(__file__).resolve().parent
14
+ for _ssot_dir in (_VALIDATORS_DIR.parent / "scripts", _VALIDATORS_DIR.parent / "python"):
15
+ if _ssot_dir.is_dir() and str(_ssot_dir) not in sys.path:
16
+ sys.path.insert(0, str(_ssot_dir))
16
17
 
17
18
  from okstra_ctl.work_categories import WORK_CATEGORIES # noqa: E402
18
19
  from okstra_ctl.fanout import topological_order, CycleError # noqa: E402
@@ -12,10 +12,12 @@ import sys
12
12
  from dataclasses import dataclass, field
13
13
  from pathlib import Path
14
14
 
15
- # scripts/ is not a package; insert it so okstra_ctl is importable directly.
16
- _SCRIPTS_DIR = Path(__file__).resolve().parent.parent / "scripts"
17
- if str(_SCRIPTS_DIR) not in sys.path:
18
- sys.path.insert(0, str(_SCRIPTS_DIR))
15
+ # scripts/ (repo) and python/ (installed under ~/.okstra/lib) are not packages;
16
+ # insert whichever exists so okstra_ctl is importable directly.
17
+ _VALIDATORS_DIR = Path(__file__).resolve().parent
18
+ for _ssot_dir in (_VALIDATORS_DIR.parent / "scripts", _VALIDATORS_DIR.parent / "python"):
19
+ if _ssot_dir.is_dir() and str(_ssot_dir) not in sys.path:
20
+ sys.path.insert(0, str(_ssot_dir))
19
21
 
20
22
  from okstra_ctl.improvement_lenses import (
21
23
  LENSES,
@@ -26,6 +26,18 @@ from dataclasses import dataclass, field
26
26
  from datetime import datetime
27
27
  from pathlib import Path
28
28
 
29
+ # scripts/ (repo) and python/ (installed under ~/.okstra/lib) are not packages;
30
+ # insert whichever exists so okstra_ctl is importable directly.
31
+ _VALIDATORS_DIR = Path(__file__).resolve().parent
32
+ for _ssot_dir in (_VALIDATORS_DIR.parent / "scripts", _VALIDATORS_DIR.parent / "python"):
33
+ if _ssot_dir.is_dir() and str(_ssot_dir) not in sys.path:
34
+ sys.path.insert(0, str(_ssot_dir))
35
+
36
+ from okstra_ctl.worker_heartbeat import ( # noqa: E402
37
+ HEARTBEAT_LINE_RE,
38
+ HEARTBEAT_MAX_GAP_SECONDS,
39
+ )
40
+
29
41
  _DISPATCHED_STATUSES = {"completed", "timeout", "error", "in-progress"}
30
42
  _WORKER_DISPATCH_MODES = {"cli-wrapper", "mixed", "tmux-pane"}
31
43
  _ATTEMPTED_STATUSES = {"completed", "timeout", "error"}
@@ -39,13 +51,10 @@ _PROGRESS_LINE_RE = re.compile(
39
51
  re.MULTILINE,
40
52
  )
41
53
 
42
- # claude-worker audit 사이드카의 heartbeat 라인 (claude-worker.md "Heartbeat").
43
- _HEARTBEAT_LINE_RE = re.compile(
44
- r"^-[ \t]*PROGRESS:[ \t]*(?P<stage>\S+)[ \t]+(?P<ts>\S+)[ \t]*$", re.MULTILINE
45
- )
46
- # 계약상 cadence 는 5분. append 직전 측정한 시각과 실제 쓰기 사이 지연을 흡수하는
47
- # 고정 grace 60초를 더한다.
48
- _HEARTBEAT_MAX_GAP_SECONDS = 5 * 60 + 60
54
+ # heartbeat 라인 shape cadence 예산은 okstra_ctl.worker_heartbeat 정본을 쓴다 —
55
+ # `okstra worker-liveness` 가 run 도중 같은 판정을 내리므로 정의가 갈리면 안 된다.
56
+ _HEARTBEAT_LINE_RE = HEARTBEAT_LINE_RE
57
+ _HEARTBEAT_MAX_GAP_SECONDS = HEARTBEAT_MAX_GAP_SECONDS
49
58
 
50
59
  # Phase 5/6 진입 전 lead 가 Read 해야 하는 implementation 프로파일 sidecar.
51
60
  # 절대 경로는 레이어(repo / runtime / 설치본)마다 다르지만 basename 은 동일하다.
@@ -159,6 +159,13 @@ export const COMMAND_REGISTRY = [
159
159
  category: "introspection",
160
160
  summary: ["Inventory wrapper sidecar logs by size and task (read-only)"],
161
161
  },
162
+ {
163
+ name: "worker-liveness",
164
+ module: "./commands/inspect/worker-liveness.mjs",
165
+ export: "run",
166
+ category: "introspection",
167
+ summary: ["Report whether pending workers are still alive (read-only)"],
168
+ },
162
169
  {
163
170
  name: "error-report",
164
171
  module: "./commands/inspect/error-report.mjs",
@@ -6,9 +6,11 @@ Usage:
6
6
  okstra log-report [--project-root <dir>] [--cwd <dir>] [--top <N>] [--json]
7
7
 
8
8
  Output: JSON { ok, projectRoot, logsRoot, topLargest[], perTask[], totals }.
9
- Scans <project-root>/.okstra/tasks/**/runs/*/prompts/*.log. Sizes are raw bytes
10
- and mtimes are epoch seconds the caller formats KB/MB and ages. Read-only: it
11
- never deletes logs (cleanup commands stay in the skill).
9
+ Scans <project-root>/.okstra/tasks/**/runs/*/prompts/*.log. Each entry reports
10
+ the wrapper transcript as transcriptBytes and its sibling prompt Markdown as
11
+ promptBytes; sizeBytes remains the transcript-size compatibility field. Sizes
12
+ are raw bytes and mtimes are epoch seconds — the caller formats KB/MB and ages.
13
+ Read-only: it never deletes logs (cleanup commands stay in the skill).
12
14
  `;
13
15
 
14
16
  export async function run(args) {
@@ -0,0 +1,31 @@
1
+ import { runPythonModule } from "../../lib/python-helper.mjs";
2
+
3
+ const USAGE = `okstra worker-liveness — report whether pending workers are still alive
4
+
5
+ Usage:
6
+ okstra worker-liveness [--audit <path>]... [--prompt <path>]...
7
+ [--max-idle <seconds>] [--launch-grace <seconds>] [--json]
8
+
9
+ --audit a claude-worker audit sidecar; stale past the heartbeat cadence means
10
+ the in-process worker hung.
11
+ --prompt a CLI-wrapper prompt-history path; no sibling .log or .status.json
12
+ past the launch grace means the wrapper never started.
13
+
14
+ Output: JSON { ok, checkedAt, probes[], unhealthy[] }. Exit 1 when any worker is
15
+ stalled or did not launch, so a poll loop can branch on the exit code. Read-only:
16
+ it reports, it never kills or re-dispatches.
17
+ `;
18
+
19
+ export async function run(args) {
20
+ if (args.includes("--help") || args.includes("-h")) {
21
+ process.stdout.write(USAGE);
22
+ return 0;
23
+ }
24
+
25
+ const result = await runPythonModule({
26
+ module: "okstra_ctl.worker_liveness",
27
+ args,
28
+ stdio: "inherit-stdout",
29
+ });
30
+ return result.code;
31
+ }
@@ -1,5 +1,8 @@
1
1
  import { checkProject } from "./check-project.mjs";
2
2
  import { runEnsureInstalled } from "./install.mjs";
3
+ import { missingHelperScripts } from "../../lib/helper-scripts.mjs";
4
+ import { resolveInstalledScript } from "../../lib/python-helper.mjs";
5
+ import { resolvePaths } from "../../lib/paths.mjs";
3
6
 
4
7
  const USAGE = `okstra preflight — one-call skill preflight (ensure-installed + check-project)
5
8
 
@@ -11,8 +14,9 @@ verifies the target project has .okstra/project.json. Prints one JSON object:
11
14
 
12
15
  ok: true {ok, projectRoot, projectJsonPath, projectId}
13
16
  ok: false {ok, stage, reason, ...}
14
- stage: install | resolve | project_json_missing |
15
- project_json_invalid | python | parse
17
+ stage: install | helper_scripts_missing | resolve |
18
+ project_json_missing | project_json_invalid |
19
+ python | parse
16
20
 
17
21
  Options:
18
22
  --runtime <name> Runtime whose assets must be present (e.g. claude-code);
@@ -48,6 +52,12 @@ function emit(payload) {
48
52
  process.stdout.write(JSON.stringify(payload, null, 2) + "\n");
49
53
  }
50
54
 
55
+ // A BLOCKING lead step calls these mid-run; resolving them here turns a stale
56
+ // install into an up-front failure instead of a dead end hours into a run.
57
+ async function findMissingHelperScripts() {
58
+ return missingHelperScripts(await resolvePaths(), resolveInstalledScript);
59
+ }
60
+
51
61
  export async function run(args) {
52
62
  if (args.includes("--help") || args.includes("-h")) {
53
63
  process.stdout.write(USAGE);
@@ -76,6 +86,19 @@ export async function run(args) {
76
86
  return installCode === 2 ? 2 : 1;
77
87
  }
78
88
 
89
+ const missingHelpers = await findMissingHelperScripts();
90
+ if (missingHelpers.length > 0) {
91
+ emit({
92
+ ok: false,
93
+ stage: "helper_scripts_missing",
94
+ missingHelpers,
95
+ reason:
96
+ `${missingHelpers.length} helper script(s) a lead prompt can call are not ` +
97
+ "installed — run 'okstra install' to repair before starting a run.",
98
+ });
99
+ return 1;
100
+ }
101
+
79
102
  const { code, payload } = await checkProject({ cwd: opts.cwd });
80
103
  emit(payload);
81
104
  return code;
@@ -0,0 +1,23 @@
1
+ // Single source of truth for the python helper scripts that `okstra <cmd>`
2
+ // subcommands front through runInstalledScript(). Preflight asserts every one
3
+ // of them resolves under ~/.okstra/bin so a stale install fails at the cheap
4
+ // environment gate instead of at the blocking lead step that calls it mid-run.
5
+ // A contract test keeps this list in step with the `scriptName:` literals in
6
+ // src/commands/**.
7
+
8
+ export const HELPER_SCRIPT_NAMES = Object.freeze([
9
+ "okstra-error-log.py",
10
+ "okstra-incremental-carry.py",
11
+ "okstra-incremental-scope.py",
12
+ "okstra-inject-report-index.py",
13
+ "okstra-render-final-report.py",
14
+ "okstra-render-report-views.py",
15
+ "okstra-spawn-followups.py",
16
+ "okstra-token-usage.py",
17
+ ]);
18
+
19
+ // `resolve` is the same resolver the wrappers call, so preflight cannot pass a
20
+ // script the dispatch path would then fail to find.
21
+ export function missingHelperScripts(paths, resolve) {
22
+ return HELPER_SCRIPT_NAMES.filter((name) => !resolve(paths, name));
23
+ }
@@ -4,7 +4,7 @@ import { join, resolve as resolvePath } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { buildPythonpath, resolvePaths } from "./paths.mjs";
6
6
 
7
- function resolveInstalledScript(paths, scriptName) {
7
+ export function resolveInstalledScript(paths, scriptName) {
8
8
  // Prefer the installed copy under ~/.okstra/bin (what production users run);
9
9
  // fall back to the in-repo source when invoked from a checkout that has not
10
10
  // been installed (dev / CI).