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.
- package/README.kr.md +5 -3
- package/README.md +5 -3
- package/bin/okstra +10 -2
- package/docs/kr/architecture.md +1 -1
- package/docs/kr/cli.md +20 -4
- package/docs/pr-template-usage.md +3 -3
- package/docs/superpowers/plans/2026-05-25-okstra-project-root-rename.md +1 -1
- package/docs/superpowers/specs/2026-06-12-codex-lead-adapter-design.md +358 -0
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/agents/workers/codex-worker.md +1 -1
- package/runtime/agents/workers/gemini-worker.md +1 -1
- package/runtime/bin/lib/okstra/cli.sh +5 -1
- package/runtime/bin/lib/okstra/globals.sh +1 -0
- package/runtime/bin/lib/okstra/usage.sh +6 -4
- package/runtime/bin/okstra.sh +1 -0
- package/runtime/prompts/launch.template.md +4 -13
- package/runtime/prompts/profiles/_coding-conventions-preflight.md +21 -0
- package/runtime/prompts/profiles/_implementation-executor.md +3 -6
- package/runtime/prompts/profiles/error-analysis.md +6 -0
- package/runtime/prompts/profiles/improvement-discovery.md +5 -0
- package/runtime/prompts/profiles/release-handoff.md +2 -2
- package/runtime/prompts/wizard/prompts.ko.json +12 -4
- package/runtime/python/okstra_ctl/analysis_packet.py +17 -0
- package/runtime/python/okstra_ctl/codex_dispatch.py +1484 -0
- package/runtime/python/okstra_ctl/lead_events.py +129 -0
- package/runtime/python/okstra_ctl/paths.py +3 -0
- package/runtime/python/okstra_ctl/pr_template.py +1 -1
- package/runtime/python/okstra_ctl/render.py +160 -29
- package/runtime/python/okstra_ctl/run.py +78 -15
- package/runtime/python/okstra_ctl/wizard.py +23 -9
- package/runtime/python/okstra_token_usage/codex.py +12 -7
- package/runtime/python/okstra_token_usage/collect.py +243 -54
- package/runtime/python/okstra_token_usage/gemini.py +12 -7
- package/runtime/skills/okstra-setup/SKILL.md +1 -1
- package/runtime/validators/validate-run.py +56 -22
- package/runtime/validators/validate_session_conformance.py +121 -4
- package/src/codex-dispatch.mjs +70 -0
- package/src/codex-run.mjs +68 -0
- package/src/doctor.mjs +40 -4
- package/src/install.mjs +122 -26
- package/src/paths.mjs +17 -3
- package/src/render-bundle.mjs +2 -0
- package/src/runtime-manifest.mjs +26 -0
- package/src/uninstall.mjs +26 -4
- /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
|
|
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
|
|
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 (
|
|
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
|
|
80
|
+
return []
|
|
75
81
|
candidates.sort()
|
|
76
|
-
return candidates
|
|
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/
|
|
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
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
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
|
-
|
|
420
|
-
if
|
|
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.
|
|
423
|
-
|
|
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(
|
|
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,
|
|
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(
|
|
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(
|
|
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
|
-
|
|
427
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|