okstra 0.75.0 → 0.77.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 (46) hide show
  1. package/README.kr.md +5 -3
  2. package/README.md +5 -3
  3. package/bin/okstra +10 -2
  4. package/docs/kr/architecture.md +1 -1
  5. package/docs/kr/cli.md +20 -4
  6. package/docs/pr-template-usage.md +3 -3
  7. package/docs/superpowers/plans/2026-05-25-okstra-project-root-rename.md +1 -1
  8. package/docs/superpowers/specs/2026-06-12-codex-lead-adapter-design.md +358 -0
  9. package/package.json +1 -1
  10. package/runtime/BUILD.json +2 -2
  11. package/runtime/agents/workers/codex-worker.md +1 -1
  12. package/runtime/agents/workers/gemini-worker.md +1 -1
  13. package/runtime/bin/lib/okstra/cli.sh +5 -1
  14. package/runtime/bin/lib/okstra/globals.sh +1 -0
  15. package/runtime/bin/lib/okstra/usage.sh +6 -4
  16. package/runtime/bin/okstra.sh +1 -0
  17. package/runtime/prompts/launch.template.md +4 -13
  18. package/runtime/prompts/profiles/_coding-conventions-preflight.md +21 -0
  19. package/runtime/prompts/profiles/_implementation-executor.md +3 -6
  20. package/runtime/prompts/profiles/error-analysis.md +6 -0
  21. package/runtime/prompts/profiles/improvement-discovery.md +5 -0
  22. package/runtime/prompts/profiles/release-handoff.md +2 -2
  23. package/runtime/prompts/wizard/prompts.ko.json +12 -4
  24. package/runtime/python/okstra_ctl/analysis_packet.py +17 -0
  25. package/runtime/python/okstra_ctl/codex_dispatch.py +1484 -0
  26. package/runtime/python/okstra_ctl/lead_events.py +129 -0
  27. package/runtime/python/okstra_ctl/paths.py +3 -0
  28. package/runtime/python/okstra_ctl/pr_template.py +1 -1
  29. package/runtime/python/okstra_ctl/render.py +160 -29
  30. package/runtime/python/okstra_ctl/run.py +78 -15
  31. package/runtime/python/okstra_ctl/wizard.py +23 -9
  32. package/runtime/python/okstra_token_usage/codex.py +12 -7
  33. package/runtime/python/okstra_token_usage/collect.py +243 -54
  34. package/runtime/python/okstra_token_usage/gemini.py +12 -7
  35. package/runtime/skills/okstra-setup/SKILL.md +1 -1
  36. package/runtime/validators/validate-run.py +56 -22
  37. package/runtime/validators/validate_session_conformance.py +121 -4
  38. package/src/codex-dispatch.mjs +70 -0
  39. package/src/codex-run.mjs +68 -0
  40. package/src/doctor.mjs +40 -4
  41. package/src/install.mjs +122 -26
  42. package/src/paths.mjs +17 -3
  43. package/src/render-bundle.mjs +2 -0
  44. package/src/runtime-manifest.mjs +26 -0
  45. package/src/uninstall.mjs +26 -4
  46. /package/runtime/{skills/okstra-run/templates → templates/prd}/pr-body.template.md +0 -0
@@ -3,7 +3,7 @@ from __future__ import annotations
3
3
 
4
4
  import json
5
5
  from pathlib import Path
6
- from .paths import GEMINI_TMP
6
+ from .paths import GEMINI_TMP, ts_in_window
7
7
 
8
8
 
9
9
  def gemini_session_total(json_path: Path) -> dict:
@@ -40,9 +40,15 @@ def gemini_session_total(json_path: Path) -> dict:
40
40
 
41
41
 
42
42
  def find_gemini_session(project_root: Path, started_at: str, ended_at: str) -> Path | None:
43
- """Find the gemini chat session whose first user message references the project."""
43
+ """Find the latest gemini chat session in the requested window."""
44
+ sessions = find_gemini_sessions(project_root, started_at, ended_at)
45
+ return sessions[-1] if sessions else None
46
+
47
+
48
+ def find_gemini_sessions(project_root: Path, started_at: str, ended_at: str) -> list[Path]:
49
+ """Find gemini chat sessions whose first user message references the project."""
44
50
  if not GEMINI_TMP.is_dir() or not started_at or not ended_at:
45
- return None
51
+ return []
46
52
  project_str = str(project_root)
47
53
  candidates: list[tuple[str, Path]] = []
48
54
  for p in GEMINI_TMP.glob("*/chats/session-*.json"):
@@ -51,7 +57,7 @@ def find_gemini_session(project_root: Path, started_at: str, ended_at: str) -> P
51
57
  except (OSError, json.JSONDecodeError):
52
58
  continue
53
59
  start = data.get("startTime") or ""
54
- if not (started_at <= start <= ended_at):
60
+ if not ts_in_window(start, started_at, ended_at):
55
61
  continue
56
62
  # Match by content: first user message should mention project root or task path.
57
63
  msgs = data.get("messages") or []
@@ -71,7 +77,6 @@ def find_gemini_session(project_root: Path, started_at: str, ended_at: str) -> P
71
77
  candidates.append((start, p))
72
78
  break
73
79
  if not candidates:
74
- return None
80
+ return []
75
81
  candidates.sort()
76
- return candidates[-1][1]
77
-
82
+ return [path for _start, path in candidates]
@@ -251,7 +251,7 @@ back it up as `.bak.<timestamp>` rather than overwriting silently.
251
251
 
252
252
  `release-handoff` fills the PR body from a template. By default it uses the
253
253
  bundled skill template at
254
- `~/.claude/skills/okstra-run/templates/pr-body.template.md`. Most projects
254
+ `~/.claude/skills/templates/prd/pr-body.template.md`. Most projects
255
255
  want their own — e.g. the repo's `.github/PULL_REQUEST_TEMPLATE.md` — to
256
256
  keep PRs consistent with what the team already merges manually.
257
257
 
@@ -47,6 +47,7 @@ from okstra_ctl.md_table import ( # noqa: E402
47
47
 
48
48
  TERMINAL_STATUSES = {"completed", "timeout", "error", "not-run"}
49
49
  ATTEMPTED_STATUSES = {"completed", "timeout", "error"}
50
+ CODEX_DISPATCH_MODES = {"cli-wrapper", "mixed"}
50
51
 
51
52
 
52
53
  def utc_now() -> str:
@@ -401,27 +402,36 @@ def validate_team_state(
401
402
  for w in workers
402
403
  )
403
404
  if any_dispatched:
404
- team_create = team_state.get("teamCreate")
405
- if _is_legal_concurrent_run_skip(team_create, concurrent_run_authorized):
406
- pass
407
- elif not isinstance(team_create, dict) or not team_create.get("attempted"):
408
- failures.append(
409
- "team-state.teamCreate.attempted must be true once any worker has "
410
- "been dispatched (status in completed/timeout/error/in-progress). "
411
- "Phase 3 (TeamCreate) was skipped — workers ran in-process without "
412
- "the Teams split-pane surface. See agents/SKILL.md Phase 3. "
413
- "(The no-team concurrent-run path is legal ONLY when the "
414
- "run-manifest carries prepare-recorded `concurrentRun.detected: "
415
- 'true` AND team-state records `teamCreate: { attempted: false, '
416
- 'status: "skipped", reason: "concurrent-run" }`.)'
417
- )
405
+ if team_state.get("leadRuntime") == "codex":
406
+ dispatch_mode = str(team_state.get("dispatchMode", "")).strip()
407
+ if dispatch_mode not in CODEX_DISPATCH_MODES:
408
+ expected = ", ".join(sorted(CODEX_DISPATCH_MODES))
409
+ failures.append(
410
+ "team-state.dispatchMode must be set for Codex worker dispatch "
411
+ f"(expected one of: {expected})"
412
+ )
418
413
  else:
419
- tc_status = str(team_create.get("status", "")).strip()
420
- if tc_status not in {"ok", "error"}:
414
+ team_create = team_state.get("teamCreate")
415
+ if _is_legal_concurrent_run_skip(team_create, concurrent_run_authorized):
416
+ pass
417
+ elif not isinstance(team_create, dict) or not team_create.get("attempted"):
421
418
  failures.append(
422
- "team-state.teamCreate.status must be `ok` or `error` once "
423
- f"workers have been dispatched (found: `{tc_status}`)."
419
+ "team-state.teamCreate.attempted must be true once any worker has "
420
+ "been dispatched (status in completed/timeout/error/in-progress). "
421
+ "Phase 3 (TeamCreate) was skipped — workers ran in-process without "
422
+ "the Teams split-pane surface. See agents/SKILL.md Phase 3. "
423
+ "(The no-team concurrent-run path is legal ONLY when the "
424
+ "run-manifest carries prepare-recorded `concurrentRun.detected: "
425
+ 'true` AND team-state records `teamCreate: { attempted: false, '
426
+ 'status: "skipped", reason: "concurrent-run" }`.)'
424
427
  )
428
+ else:
429
+ tc_status = str(team_create.get("status", "")).strip()
430
+ if tc_status not in {"ok", "error"}:
431
+ failures.append(
432
+ "team-state.teamCreate.status must be `ok` or `error` once "
433
+ f"workers have been dispatched (found: `{tc_status}`)."
434
+ )
425
435
 
426
436
  by_role: dict[str, dict] = {}
427
437
  for worker in workers:
@@ -587,7 +597,12 @@ _TOKEN_USAGE_SENTINEL_VALUES = frozenset(
587
597
  _TOKEN_USAGE_ZERO_VALUES = frozenset({"0", "$0.00", "$0", "0.00"})
588
598
 
589
599
 
590
- def _scan_token_usage_summary(content: str, failures: list[str]) -> None:
600
+ def _scan_token_usage_summary(
601
+ content: str,
602
+ failures: list[str],
603
+ *,
604
+ allow_unavailable_tokens: bool = False,
605
+ ) -> None:
591
606
  """Reject sentinel / zero values that workers typed into the Token
592
607
  Usage Summary table instead of leaving the `{{...}}` placeholders
593
608
  verbatim for Phase 7 substitution.
@@ -639,6 +654,8 @@ def _scan_token_usage_summary(content: str, failures: list[str]) -> None:
639
654
  for value in _TOKEN_USAGE_BACKTICK_CELL_RE.findall(raw_cell):
640
655
  stripped = value.strip()
641
656
  lowered = stripped.lower()
657
+ if allow_unavailable_tokens and stripped == "--":
658
+ continue
642
659
  if lowered in _TOKEN_USAGE_SENTINEL_VALUES:
643
660
  failures.append(
644
661
  "Token Usage Summary cell contains sentinel value "
@@ -883,7 +900,11 @@ def _validate_conformance(report_path: Path, failures: list[str],
883
900
 
884
901
 
885
902
  def validate_report(
886
- report_path: Path, required_agent_status_entries: list[str], failures: list[str]
903
+ report_path: Path,
904
+ required_agent_status_entries: list[str],
905
+ failures: list[str],
906
+ *,
907
+ allow_unavailable_token_usage: bool = False,
887
908
  ) -> None:
888
909
  if not report_path.exists():
889
910
  failures.append(f"final report is missing: {report_path}")
@@ -905,7 +926,11 @@ def validate_report(
905
926
 
906
927
  # Catch the "workers typed `0` / `pending` instead of the placeholder"
907
928
  # failure mode that bypasses the placeholder check above.
908
- _scan_token_usage_summary(content, failures)
929
+ _scan_token_usage_summary(
930
+ content,
931
+ failures,
932
+ allow_unavailable_tokens=allow_unavailable_token_usage,
933
+ )
909
934
 
910
935
  # Verdict Card is mandatory in every final-report (introduced with the
911
936
  # report-format readability pass). Missing card means the reader has no
@@ -1051,6 +1076,8 @@ def validate_worker_results_audit(
1051
1076
 
1052
1077
 
1053
1078
  def validate_team_state_usage(team_state: dict, failures: list[str]) -> None:
1079
+ if team_state.get("leadRuntime") == "codex":
1080
+ return
1054
1081
  summary = team_state.get("usageSummary") or {}
1055
1082
  if not summary or not summary.get("collectedAt"):
1056
1083
  failures.append(
@@ -1909,6 +1936,8 @@ def _import_token_usage():
1909
1936
 
1910
1937
 
1911
1938
  def _needs_token_autofix(team_state: dict, report_path: Path) -> bool:
1939
+ if team_state.get("leadRuntime") == "codex":
1940
+ return False
1912
1941
  summary = team_state.get("usageSummary") or {}
1913
1942
  if not summary or not summary.get("collectedAt"):
1914
1943
  return True
@@ -2187,7 +2216,12 @@ def main() -> int:
2187
2216
  _validate_fix_cycle(
2188
2217
  run_manifest, _load_final_report_data(report_path), failures
2189
2218
  )
2190
- validate_report(report_path, contract["required_agent_status_entries"], failures)
2219
+ validate_report(
2220
+ report_path,
2221
+ contract["required_agent_status_entries"],
2222
+ failures,
2223
+ allow_unavailable_token_usage=team_state.get("leadRuntime") == "codex",
2224
+ )
2191
2225
  validate_team_state_usage(team_state, failures)
2192
2226
 
2193
2227
  validate_phase_boundary(task_type, report_path, failures)
@@ -190,6 +190,117 @@ def _collect_lead_evidence(
190
190
  return evidence, None
191
191
 
192
192
 
193
+ def _resolve_lead_events_path(
194
+ team_state: dict, project_root: Path
195
+ ) -> tuple[Path | None, str | None]:
196
+ raw = team_state.get("leadEventsPath") or (
197
+ (team_state.get("artifacts") or {}).get("leadEventsPath")
198
+ if isinstance(team_state.get("artifacts"), dict)
199
+ else ""
200
+ )
201
+ if not raw:
202
+ return None, (
203
+ "Codex lead event log path missing from team-state "
204
+ "(`leadEventsPath`) — leadRuntime=codex conformance cannot be verified."
205
+ )
206
+ path = Path(str(raw))
207
+ if not path.is_absolute():
208
+ path = project_root / path
209
+ if not path.is_file():
210
+ return None, (
211
+ f"Codex lead event log not found: {path} — leadRuntime=codex "
212
+ "conformance cannot be verified."
213
+ )
214
+ return path, None
215
+
216
+
217
+ def _event_matches_run(event, team_state: dict, task_type: str, run_seq: str) -> bool:
218
+ task_key = str(team_state.get("taskKey", ""))
219
+ if event.lead_runtime != "codex":
220
+ return False
221
+ if task_key and event.task_key != task_key:
222
+ return False
223
+ return event.task_type == task_type and event.run_seq == run_seq
224
+
225
+
226
+ def _progress_line_from_event(event) -> tuple[str, str, str] | None:
227
+ details = event.details
228
+ phase = details.get("phase")
229
+ if not isinstance(phase, str) or not phase:
230
+ line_candidate = details.get("line")
231
+ if isinstance(line_candidate, str):
232
+ match = _PROGRESS_LINE_RE.search(line_candidate)
233
+ if match:
234
+ phase = match.group("phase")
235
+ if not isinstance(phase, str) or not phase:
236
+ return None
237
+ line = details.get("line")
238
+ if not isinstance(line, str) or not line:
239
+ message = details.get("message")
240
+ tail = f" {message}" if isinstance(message, str) and message else ""
241
+ line = f"PROGRESS: {phase}{tail}"
242
+ return (event.timestamp, phase, line)
243
+
244
+
245
+ def _sidecar_read_from_event(event) -> tuple[str, str] | None:
246
+ details = event.details
247
+ if event.event_type != "sidecar-read" and details.get("kind") != "implementation-sidecar":
248
+ return None
249
+ basename = details.get("basename")
250
+ if not isinstance(basename, str) or not basename:
251
+ raw_path = details.get("path") or details.get("filePath")
252
+ basename = Path(str(raw_path or "")).name
253
+ if basename not in _SIDECAR_BASENAMES:
254
+ return None
255
+ return (basename, event.timestamp)
256
+
257
+
258
+ def _collect_codex_lead_evidence(
259
+ team_state: dict,
260
+ project_root: Path,
261
+ task_type: str,
262
+ suffix: str | None,
263
+ ) -> tuple[_LeadEvidence | None, str | None]:
264
+ if not suffix or "-" not in suffix:
265
+ return None, (
266
+ "Codex lead event log cannot be scoped because team-state filename "
267
+ "does not expose a run artifact suffix."
268
+ )
269
+ events_path, error = _resolve_lead_events_path(team_state, project_root)
270
+ if error:
271
+ return None, error
272
+
273
+ try:
274
+ from okstra_ctl.lead_events import LeadEventParseError, read_lead_events
275
+ except ImportError as exc:
276
+ return None, f"okstra_ctl.lead_events import failed — {exc}"
277
+
278
+ try:
279
+ events = read_lead_events(events_path)
280
+ except LeadEventParseError as exc:
281
+ return None, f"Codex lead event log is malformed — {exc}"
282
+
283
+ run_seq = suffix.rsplit("-", 1)[1]
284
+ evidence = _LeadEvidence(scanned_files=[events_path])
285
+ for event in events:
286
+ if not _event_matches_run(event, team_state, task_type, run_seq):
287
+ continue
288
+ if event.event_type in ("progress", "progress-checkpoint"):
289
+ progress = _progress_line_from_event(event)
290
+ if progress is not None:
291
+ evidence.progress.append(progress)
292
+ elif event.event_type in ("artifact-read", "sidecar-read"):
293
+ read = _sidecar_read_from_event(event)
294
+ if read is not None:
295
+ basename, timestamp = read
296
+ evidence.sidecar_reads.setdefault(basename, []).append(timestamp)
297
+
298
+ evidence.progress.sort()
299
+ for ts_list in evidence.sidecar_reads.values():
300
+ ts_list.sort()
301
+ return evidence, None
302
+
303
+
193
304
  def _convergence_rounds_ran(run_dir: Path, suffix: str | None) -> bool:
194
305
  """이 run 의 convergence state artifact 가 실제 round 를 1회 이상 돌았는지.
195
306
  auto-disable(`totalRounds: 0`)·artifact 부재는 phase-5.5 라인을 요구하지 않는다."""
@@ -422,14 +533,20 @@ def validate_session_conformance(
422
533
 
423
534
  run_dir = report_path.parent.parent # reports/ 의 부모 = run 디렉터리
424
535
  _check_heartbeat_sidecars(run_dir, task_type, result.errors)
536
+ suffix = run_artifact_suffix(team_state_path)
425
537
 
426
- evidence, error = _collect_lead_evidence(
427
- team_state, team_state_path, project_root, claude_projects_dir
428
- )
538
+ lead_runtime = team_state.get("leadRuntime", "") or "claude-code"
539
+ if lead_runtime == "codex":
540
+ evidence, error = _collect_codex_lead_evidence(
541
+ team_state, project_root, task_type, suffix
542
+ )
543
+ else:
544
+ evidence, error = _collect_lead_evidence(
545
+ team_state, team_state_path, project_root, claude_projects_dir
546
+ )
429
547
  if error:
430
548
  result.errors.append(error)
431
549
  return result
432
- suffix = run_artifact_suffix(team_state_path)
433
550
  _check_progress_checkpoints(evidence, team_state, run_dir, suffix, result.errors)
434
551
  if task_type == "implementation":
435
552
  _check_implementation_sidecar_reads(evidence, result.errors)
@@ -0,0 +1,70 @@
1
+ import { runPythonModule } from "./_python-helper.mjs";
2
+ import { resolvePaths } from "./paths.mjs";
3
+
4
+ const USAGE = `okstra codex-dispatch — dispatch CLI-backed workers for a prepared Codex run
5
+
6
+ Usage:
7
+ okstra codex-dispatch --project-root <dir> --run-manifest <path> \\
8
+ [--workers codex[,gemini[,report-writer]]] [--dry-run] \\
9
+ [--idle-timeout-seconds <n>] \\
10
+ [--enable-codex-report-writer --report-writer-codex-model <model>]
11
+
12
+ This command reads an existing run manifest produced by 'okstra codex-run' and
13
+ dispatches only Codex-side supported workers. When --workers is omitted, the
14
+ command selects the supported subset of the run roster (codex/gemini, plus
15
+ report-writer only when explicitly opted in). Codex and gemini use their
16
+ existing wrappers. The report-writer role is available only through explicit
17
+ Codex opt-in because the prepared manifest still records the Claude-side report
18
+ writer model by default. Explicitly requesting unsupported workers such as
19
+ claude fails.
20
+
21
+ Missing worker prompt files are generated automatically from the run manifest
22
+ and active-run-context. Existing prompt files are never overwritten.
23
+ When the Codex report-writer path completes, this command also runs the
24
+ idempotent post-report steps: token-usage substitution, render-views,
25
+ spawn-followups, and validate-run.
26
+
27
+ --workspace-root and --okstra-bin are owned by this command.
28
+ `;
29
+
30
+ const OWNED_FLAGS = new Set(["--workspace-root", "--okstra-bin"]);
31
+
32
+ function isOwnedFlag(arg) {
33
+ if (OWNED_FLAGS.has(arg)) return true;
34
+ return [...OWNED_FLAGS].some((flag) => arg.startsWith(`${flag}=`));
35
+ }
36
+
37
+ export function buildCodexDispatchArgs(args, paths) {
38
+ return [
39
+ "--workspace-root", paths.workspace,
40
+ "--okstra-bin", paths.bin,
41
+ ...args,
42
+ ];
43
+ }
44
+
45
+ export async function run(args) {
46
+ if (args.includes("--help") || args.includes("-h")) {
47
+ process.stdout.write(USAGE);
48
+ return 0;
49
+ }
50
+ if (args.length === 0) {
51
+ process.stdout.write(USAGE);
52
+ return 2;
53
+ }
54
+
55
+ const forbidden = args.find(isOwnedFlag);
56
+ if (forbidden) {
57
+ process.stderr.write(
58
+ `error: ${forbidden} is set by 'okstra codex-dispatch' itself — remove it from your args\n`,
59
+ );
60
+ return 2;
61
+ }
62
+
63
+ const paths = await resolvePaths();
64
+ const result = await runPythonModule({
65
+ module: "okstra_ctl.codex_dispatch",
66
+ args: buildCodexDispatchArgs(args, paths),
67
+ stdio: "inherit-stdout",
68
+ });
69
+ return result.code ?? 0;
70
+ }
@@ -0,0 +1,68 @@
1
+ import { runPythonModule } from "./_python-helper.mjs";
2
+ import { resolvePaths } from "./paths.mjs";
3
+
4
+ const USAGE = `okstra codex-run — prepare a render-only Codex lead task bundle
5
+
6
+ This is the dry-run entry point for the Codex lead adapter. It forwards to
7
+ \`python3 -m okstra_ctl.run --render-only --lead-runtime codex\`, materializes
8
+ the normal okstra task bundle, and prints the rendered lead prompt without
9
+ dispatching workers.
10
+
11
+ Usage:
12
+ okstra codex-run --project-root <dir> --project-id <id> \\
13
+ --task-group <tg> --task-id <tid> --task-type <type> \\
14
+ --task-brief <path-or-rel> [--workers <list>] [--directive <text>] \\
15
+ [--lead-model <m>] [--claude-model <m>] [--codex-model <m>] \\
16
+ [--gemini-model <m>] [--report-writer-model <m>] \\
17
+ [--related-tasks <list>] [--base-ref <ref>] \\
18
+ [--clarification-response <path>] [--work-category <cat>] \\
19
+ [--stage <auto|N>] [--stages <csv>] [--pr-template-path <path>] \\
20
+ [--fix-cycle <yes|no>]
21
+
22
+ --render-only and --lead-runtime are owned by this command.
23
+ Worker dispatch is intentionally disabled here; use 'okstra codex-dispatch'
24
+ with the rendered run manifest for CLI-backed codex/gemini workers.
25
+ `;
26
+
27
+ const OWNED_FLAGS = new Set(["--workspace-root", "--render-only", "--lead-runtime"]);
28
+
29
+ function isOwnedFlag(arg) {
30
+ if (OWNED_FLAGS.has(arg)) return true;
31
+ return [...OWNED_FLAGS].some((flag) => arg.startsWith(`${flag}=`));
32
+ }
33
+
34
+ export function buildCodexRunArgs(args, workspaceRoot) {
35
+ return [
36
+ "--workspace-root", workspaceRoot,
37
+ "--render-only",
38
+ "--lead-runtime", "codex",
39
+ ...args,
40
+ ];
41
+ }
42
+
43
+ export async function run(args) {
44
+ if (args.includes("--help") || args.includes("-h")) {
45
+ process.stdout.write(USAGE);
46
+ return 0;
47
+ }
48
+ if (args.length === 0) {
49
+ process.stdout.write(USAGE);
50
+ return 2;
51
+ }
52
+
53
+ const forbidden = args.find(isOwnedFlag);
54
+ if (forbidden) {
55
+ process.stderr.write(
56
+ `error: ${forbidden} is set by 'okstra codex-run' itself — remove it from your args\n`,
57
+ );
58
+ return 2;
59
+ }
60
+
61
+ const paths = await resolvePaths();
62
+ const result = await runPythonModule({
63
+ module: "okstra_ctl.run",
64
+ args: buildCodexRunArgs(args, paths.workspace),
65
+ stdio: "inherit-stdout",
66
+ });
67
+ return result.code ?? 0;
68
+ }
package/src/doctor.mjs CHANGED
@@ -3,6 +3,7 @@ import { homedir } from "node:os";
3
3
  import { join } from "node:path";
4
4
  import { resolvePaths } from "./paths.mjs";
5
5
  import { fileExists, runProcess } from "./_proc.mjs";
6
+ import { runtimeIncludesClaudeAssets, validateInstallRuntime } from "./install.mjs";
6
7
 
7
8
  const CLAUDE_SKILLS_DIR = join(homedir(), ".claude", "skills");
8
9
  const REQUIRED_SKILLS = ["okstra-setup", "okstra-run"];
@@ -11,6 +12,7 @@ const USAGE = `okstra doctor — diagnose the installed runtime
11
12
 
12
13
  Usage:
13
14
  okstra doctor Run all checks and print a summary
15
+ okstra doctor --runtime <claude-code|codex|all>
14
16
  okstra doctor --json Machine-readable result
15
17
 
16
18
  Checks:
@@ -20,9 +22,37 @@ Checks:
20
22
  - bash entrypoints present and executable in $HOME/.okstra/bin
21
23
  - agents/ directory exists inside the okstra package
22
24
  - canonical skills present at $HOME/.claude/skills/<name>/SKILL.md
25
+ (skipped with --runtime codex)
23
26
  - version stamp matches package version
24
27
  `;
25
28
 
29
+ export function parseDoctorArgs(args) {
30
+ const result = {
31
+ jsonMode: false,
32
+ runtime: "claude-code",
33
+ };
34
+ for (let i = 0; i < args.length; i++) {
35
+ const arg = args[i];
36
+ if (arg === "--json") {
37
+ result.jsonMode = true;
38
+ } else if (arg === "--runtime" || arg.startsWith("--runtime=")) {
39
+ const value = arg.startsWith("--runtime=") ? arg.slice("--runtime=".length) : args[i + 1];
40
+ if (!value || value.startsWith("--")) {
41
+ throw new Error("--runtime requires one of: claude-code, codex, all");
42
+ }
43
+ result.runtime = validateInstallRuntime(value);
44
+ if (arg === "--runtime") i++;
45
+ } else {
46
+ throw new Error(`unknown argument '${arg}'`);
47
+ }
48
+ }
49
+ return result;
50
+ }
51
+
52
+ export function requiredSkillNamesForRuntime(runtime) {
53
+ return runtimeIncludesClaudeAssets(runtime) ? REQUIRED_SKILLS : [];
54
+ }
55
+
26
56
  async function checkPython3() {
27
57
  const r = await runProcess("python3", ["--version"]);
28
58
  if (r.code !== 0) return { ok: false, detail: `python3 not found: ${r.stderr.trim() || "missing binary"}` };
@@ -74,7 +104,13 @@ export async function run(args) {
74
104
  process.stdout.write(USAGE);
75
105
  return 0;
76
106
  }
77
- const jsonMode = args.includes("--json");
107
+ let opts;
108
+ try {
109
+ opts = parseDoctorArgs(args);
110
+ } catch (err) {
111
+ process.stderr.write(`error: ${err.message}\n`);
112
+ return 2;
113
+ }
78
114
  const paths = await resolvePaths();
79
115
 
80
116
  const results = [
@@ -91,7 +127,7 @@ export async function run(args) {
91
127
  await check("bin: okstra-gemini-exec.sh", () => checkBashEntry(paths.bin, "okstra-gemini-exec.sh")),
92
128
  await check("bin: okstra-central.sh", () => checkBashEntry(paths.bin, "okstra-central.sh")),
93
129
  ...(await Promise.all(
94
- REQUIRED_SKILLS.map((name) =>
130
+ requiredSkillNamesForRuntime(opts.runtime).map((name) =>
95
131
  check(`skill: ${name}`, async () => {
96
132
  const p = join(CLAUDE_SKILLS_DIR, name, "SKILL.md");
97
133
  return (await fileExists(p))
@@ -109,8 +145,8 @@ export async function run(args) {
109
145
 
110
146
  const allOk = results.every((r) => r.ok);
111
147
 
112
- if (jsonMode) {
113
- process.stdout.write(`${JSON.stringify({ ok: allOk, paths, checks: results }, null, 2)}\n`);
148
+ if (opts.jsonMode) {
149
+ process.stdout.write(`${JSON.stringify({ ok: allOk, runtime: opts.runtime, paths, checks: results }, null, 2)}\n`);
114
150
  return allOk ? 0 : 1;
115
151
  }
116
152