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
@@ -121,7 +121,7 @@ and `validators/validate-run.py::_validate_conformance`.
121
121
  over-narrow one lets an undeclared surface through — tune to the repo's real
122
122
  db/http/io file naming.
123
123
 
124
- ## C. Project-local Claude settings symlink (automatic — diagnostics only)
124
+ ## C. Project-local Claude settings symlink and workspace trust
125
125
 
126
126
  `okstra setup` (and `okstra run` on its first invocation per project)
127
127
  provisions `<PROJECT_ROOT>/.claude/settings.local.json` as a symlink to
@@ -131,9 +131,18 @@ permission rules required for the codex/antigravity worker wrappers:
131
131
  - `Bash($HOME/.okstra/bin/okstra-codex-exec.sh:*)`
132
132
  - `Bash($HOME/.okstra/bin/okstra-antigravity-exec.sh:*)`
133
133
 
134
- Claude Code automatically loads `.claude/settings.local.json` inside that
135
- project, so okstra workers dispatched from **any** Claude Code session are
136
- allowed to run their wrapper scripts without further configuration.
134
+ Claude Code loads `.claude/settings.local.json` inside that project after the
135
+ project has been trusted. When setup creates this file during an already-open
136
+ Claude Code session, newly dispatched worker processes can each encounter the
137
+ workspace-trust prompt before that host session has accepted the project.
138
+
139
+ `/okstra-run` therefore checks the **host runtime** before starting its wizard.
140
+ For a Claude Code host with pending trust, it stops before any worker dispatch
141
+ and asks the user to close the session, reopen the project, accept the single
142
+ workspace-trust prompt, and rerun `/okstra-run`. This is not a per-worker or
143
+ per-provider approval: selecting Claude, Codex, or Antigravity workers does not
144
+ change the host-runtime check. Codex and external hosts do not read Claude Code
145
+ workspace state.
137
146
 
138
147
  If a non-symlink `.claude/settings.local.json` already exists, setup backs it
139
148
  up to `.claude/settings.local.json.bak.<timestamp>` before installing the
@@ -501,7 +501,7 @@ if lead_sid:
501
501
  progress_lines.append(
502
502
  f"PROGRESS: phase-5-collect worker={worker_id}-worker status=completed"
503
503
  )
504
- progress_lines.append("PROGRESS: phase-batch-cleanup panes=1 teammates=1")
504
+ progress_lines.append("PROGRESS: phase-batch-cleanup panes=1")
505
505
  progress_lines.append("PROGRESS: phase-6-synthesis dispatching report-writer-worker")
506
506
  progress_lines.append("PROGRESS: phase-7-persist updating manifests")
507
507
  records = [
@@ -566,7 +566,7 @@ def _grilling_log_path_from_prompts(
566
566
  continue
567
567
  try:
568
568
  text = record.path.read_text(encoding="utf-8")
569
- except OSError:
569
+ except (OSError, UnicodeError):
570
570
  continue
571
571
  matches = [
572
572
  line.strip()[len(header):].strip().strip("`")
@@ -6354,6 +6354,52 @@ def _validate_forbidden_actions(
6354
6354
  failures.extend(f"forbidden-action: {v}" for v in violations)
6355
6355
 
6356
6356
 
6357
+ _PHASE_BOUNDARY_ERROR_MARKERS = (
6358
+ "phase boundary",
6359
+ "forbidden action",
6360
+ "forbidden-action",
6361
+ "reverify ran ",
6362
+ "reverify executed ",
6363
+ "crossed phase",
6364
+ )
6365
+
6366
+
6367
+ def _validate_phase_boundary_error_log(
6368
+ run_dir: Path,
6369
+ task_type: str,
6370
+ failures: list[str],
6371
+ ) -> None:
6372
+ """Fail a run when its error log records a phase-boundary violation."""
6373
+ logs_dir = run_dir / "logs"
6374
+ if not logs_dir.is_dir():
6375
+ return
6376
+ for log_path in sorted(logs_dir.glob("errors-*.jsonl")):
6377
+ try:
6378
+ lines = log_path.read_text(encoding="utf-8").splitlines()
6379
+ except OSError:
6380
+ continue
6381
+ for raw in lines:
6382
+ try:
6383
+ record = json.loads(raw)
6384
+ except json.JSONDecodeError:
6385
+ continue
6386
+ if not isinstance(record, Mapping):
6387
+ continue
6388
+ if record.get("errorType") != "contract-violation":
6389
+ continue
6390
+ if record.get("phase") != task_type:
6391
+ continue
6392
+ message = str(record.get("message") or "").strip()
6393
+ boundary_type = str(
6394
+ record.get("violationType") or record.get("contractBoundary") or ""
6395
+ ).strip()
6396
+ boundary_recorded = boundary_type == "phase-boundary" or any(
6397
+ marker in message.lower() for marker in _PHASE_BOUNDARY_ERROR_MARKERS
6398
+ )
6399
+ if boundary_recorded:
6400
+ failures.append(f"phase-boundary: {message or 'recorded violation'}")
6401
+
6402
+
6357
6403
  _CONVERGENCE_INTERMEDIATE_PREFIXES = (
6358
6404
  "convergence-groups-",
6359
6405
  "convergence-work-",
@@ -7122,6 +7168,11 @@ def main() -> int:
7122
7168
  validate_team_state_usage(team_state, failures)
7123
7169
 
7124
7170
  validate_phase_boundary(task_type, report_path, failures)
7171
+ _validate_phase_boundary_error_log(
7172
+ report_path.parent.parent,
7173
+ task_type,
7174
+ failures,
7175
+ )
7125
7176
  if task_type:
7126
7177
  validate_worker_results_audit(report_path, task_type, failures)
7127
7178
  _validate_session_conformance(
@@ -296,6 +296,33 @@ def _required_worker_rows(data: dict, required_roles: tuple[str, ...]) -> list[d
296
296
  ]
297
297
 
298
298
 
299
+ def _validate_execution_status_roles(
300
+ data: dict, required_roles: tuple[str, ...], errors: list[str]
301
+ ) -> bool:
302
+ """Validate exact labels and report whether verdict matching is unambiguous."""
303
+ actual_roles = {
304
+ str(row.get("role") or "")
305
+ for row in data.get("executionStatus") or []
306
+ if isinstance(row, dict)
307
+ }
308
+ expected_by_fold = {role.casefold(): role for role in required_roles}
309
+ verdict_roles_unambiguous = True
310
+ for actual_role in sorted(actual_roles):
311
+ expected_role = expected_by_fold.get(actual_role.casefold())
312
+ if expected_role is None or actual_role == expected_role:
313
+ continue
314
+ errors.append(
315
+ f"unknown executionStatus analysis role `{actual_role}`; "
316
+ f"expected exact role `{expected_role}`"
317
+ )
318
+ verdict_roles_unambiguous = False
319
+ for required_role in required_roles:
320
+ if required_role in actual_roles:
321
+ continue
322
+ errors.append(f"missing required analysis role `{required_role}`")
323
+ return verdict_roles_unambiguous
324
+
325
+
299
326
  def _scope_confirmation_status(
300
327
  run_manifest: dict, errors: list[str]
301
328
  ) -> str | None:
@@ -409,6 +436,9 @@ def validate_analysis_semantics(
409
436
  analysis_worker_roles = tuple(
410
437
  role for role in required_roles if role in _ANALYSIS_WORKER_ROLES
411
438
  )
439
+ verdict_roles_unambiguous = _validate_execution_status_roles(
440
+ data, analysis_worker_roles, errors
441
+ )
412
442
  _validate_all_current_code_evidence(task_type, data, errors)
413
443
  if task_type == "project-analysis":
414
444
  _validate_project_semantics(data, errors)
@@ -419,9 +449,10 @@ def validate_analysis_semantics(
419
449
  _validate_scope_before_dispatch(
420
450
  data, analysis_worker_roles, reporter_confirmation, errors
421
451
  )
422
- _validate_analysis_verdict(
423
- data, analysis_worker_roles, reporter_confirmation, errors
424
- )
452
+ if verdict_roles_unambiguous:
453
+ _validate_analysis_verdict(
454
+ data, analysis_worker_roles, reporter_confirmation, errors
455
+ )
425
456
 
426
457
 
427
458
  def _actual_analysis_task_type(
@@ -101,16 +101,6 @@ export const COMMAND_REGISTRY = [
101
101
  category: "admin",
102
102
  summary: ["stage 들을 task 브랜치에 통합 머지하고 stage worktree 를 정리"],
103
103
  },
104
- {
105
- name: "phase-cleanup",
106
- module: "./commands/execute/phase-cleanup.mjs",
107
- export: "run",
108
- category: "admin",
109
- summary: [
110
- "Reclaim the prior phase/batch's completed panes and list dismissible",
111
- "teammates for the lead to shut down (tmux-aware)",
112
- ],
113
- },
114
104
  {
115
105
  name: "paths",
116
106
  module: "./commands/lifecycle/paths.mjs",
@@ -301,6 +291,13 @@ export const COMMAND_REGISTRY = [
301
291
  category: "introspection",
302
292
  summary: ["Dispatch CLI-backed workers for a prepared Codex run"],
303
293
  },
294
+ {
295
+ name: "worker-state",
296
+ module: "./commands/execute/worker-state.mjs",
297
+ export: "run",
298
+ category: "admin",
299
+ summary: ["Atomically transition persisted worker status"],
300
+ },
304
301
  {
305
302
  name: "team",
306
303
  module: "./commands/execute/team.mjs",
@@ -0,0 +1,29 @@
1
+ import { runPythonModule } from "../../lib/python-helper.mjs";
2
+
3
+ const USAGE = `okstra worker-state — atomically transition persisted worker status
4
+
5
+ Usage:
6
+ okstra worker-state transition --team-state <path> --worker <worker-id> \\
7
+ --status <in-progress|completed|timeout|error|not-run> \\
8
+ [--reason <text>] [--model <execution-value>]
9
+
10
+ in-progress records startedAt and clears endedAt. completed, timeout, and error
11
+ record endedAt. timeout, error, and not-run require --reason.
12
+ `;
13
+
14
+ export async function run(args) {
15
+ if (args.includes("--help") || args.includes("-h")) {
16
+ process.stdout.write(USAGE);
17
+ return 0;
18
+ }
19
+ if (args.length === 0) {
20
+ process.stdout.write(USAGE);
21
+ return 2;
22
+ }
23
+ const result = await runPythonModule({
24
+ module: "okstra_ctl.worker_state",
25
+ args,
26
+ stdio: "inherit-stdout",
27
+ });
28
+ return result.code;
29
+ }
@@ -3,13 +3,15 @@ import { runPythonModule } from "../../lib/python-helper.mjs";
3
3
  const USAGE = `okstra worker-liveness — report whether pending workers are still alive
4
4
 
5
5
  Usage:
6
- okstra worker-liveness [--audit <path>]... [--prompt <path>]...
6
+ okstra worker-liveness [--audit <path>]...
7
+ [--team-state <path> --worker <worker-id>]...
7
8
  [--max-idle <seconds>] [--launch-grace <seconds>] [--json]
8
9
 
9
10
  --audit an in-process worker audit sidecar path; stale past the heartbeat cadence
10
11
  means the 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.
12
+ --team-state and --worker select a CLI-wrapper assignment. No prompt sibling
13
+ .log or .status.json past the launch grace means the wrapper never
14
+ started. The grace begins at workers[].startedAt, not prompt mtime.
13
15
 
14
16
  Output: JSON { ok, checkedAt, probes[], unhealthy[] }. Exit 1 when any worker is
15
17
  stalled or did not launch, so a poll loop can branch on the exit code. Read-only:
@@ -3,6 +3,7 @@ import { runEnsureInstalled } from "./install.mjs";
3
3
  import { missingHelperScripts } from "../../lib/helper-scripts.mjs";
4
4
  import { resolveInstalledScript } from "../../lib/python-helper.mjs";
5
5
  import { resolvePaths } from "../../lib/paths.mjs";
6
+ import { checkRuntimeReadiness } from "../../lib/runtime-readiness.mjs";
6
7
 
7
8
  const USAGE = `okstra preflight — one-call skill preflight (ensure-installed + check-project)
8
9
 
@@ -12,7 +13,7 @@ Usage:
12
13
  Runs the runtime freshness check (quietly reinstalling when stale), then
13
14
  verifies the target project has .okstra/project.json. Prints one JSON object:
14
15
 
15
- ok: true {ok, projectRoot, projectJsonPath, projectId}
16
+ ok: true {ok, projectRoot, projectJsonPath, projectId, runtimeReadiness}
16
17
  ok: false {ok, stage, reason, ...}
17
18
  stage: install | helper_scripts_missing | resolve |
18
19
  project_json_missing | project_json_invalid |
@@ -25,7 +26,7 @@ Options:
25
26
  --json Default output format (kept for symmetry).
26
27
 
27
28
  Exit codes:
28
- 0 runtime fresh and project registered
29
+ 0 runtime fresh and project registered; inspect runtimeReadiness before dispatch
29
30
  1 runtime install failed, or project setup missing/invalid
30
31
  2 PROJECT_ROOT could not be resolved from cwd (pass --cwd), or bad flags
31
32
  `;
@@ -100,6 +101,15 @@ export async function run(args) {
100
101
  }
101
102
 
102
103
  const { code, payload } = await checkProject({ cwd: opts.cwd });
103
- emit(payload);
104
+ if (code !== 0) {
105
+ emit(payload);
106
+ return code;
107
+ }
108
+
109
+ const runtimeReadiness = await checkRuntimeReadiness({
110
+ runtime: opts.runtime,
111
+ projectRoot: payload.projectRoot,
112
+ });
113
+ emit({ ...payload, runtimeReadiness });
104
114
  return code;
105
115
  }
@@ -0,0 +1,90 @@
1
+ import { promises as fs } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join, resolve } from "node:path";
4
+
5
+ const WORKSPACE_TRUST_CHECK_ID = "workspace-trust";
6
+
7
+ function ready(runtime, check) {
8
+ return { runtime, status: "ready", checks: check ? [check] : [] };
9
+ }
10
+
11
+ function blocked(runtime) {
12
+ return {
13
+ runtime,
14
+ status: "blocked",
15
+ checks: [{
16
+ id: WORKSPACE_TRUST_CHECK_ID,
17
+ status: "required",
18
+ action: "restart-and-trust",
19
+ }],
20
+ };
21
+ }
22
+
23
+ function unknown(runtime) {
24
+ return {
25
+ runtime,
26
+ status: "unknown",
27
+ checks: [{
28
+ id: WORKSPACE_TRUST_CHECK_ID,
29
+ status: "unavailable",
30
+ action: "open-project-and-trust",
31
+ }],
32
+ };
33
+ }
34
+
35
+ function isJsonObject(value) {
36
+ return value !== null && typeof value === "object" && !Array.isArray(value);
37
+ }
38
+
39
+ async function hasProjectSettings(projectRoot) {
40
+ try {
41
+ await fs.lstat(join(projectRoot, ".claude", "settings.local.json"));
42
+ return true;
43
+ } catch (error) {
44
+ if (error?.code === "ENOENT") return false;
45
+ throw error;
46
+ }
47
+ }
48
+
49
+ async function checkClaudeCodeReadiness({ projectRoot, homeDir }) {
50
+ const runtime = "claude-code";
51
+ try {
52
+ if (!(await hasProjectSettings(projectRoot))) {
53
+ return ready(runtime, {
54
+ id: WORKSPACE_TRUST_CHECK_ID,
55
+ status: "not-applicable",
56
+ });
57
+ }
58
+
59
+ const rawState = await fs.readFile(join(homeDir, ".claude.json"), "utf8");
60
+ const state = JSON.parse(rawState);
61
+ if (!isJsonObject(state?.projects)) return unknown(runtime);
62
+
63
+ const projectState = state.projects[resolve(projectRoot)];
64
+ if (projectState === undefined) return blocked(runtime);
65
+ if (!isJsonObject(projectState)) return unknown(runtime);
66
+ if (projectState.hasTrustDialogAccepted === true) {
67
+ return ready(runtime, { id: WORKSPACE_TRUST_CHECK_ID, status: "accepted" });
68
+ }
69
+ if (projectState.hasTrustDialogAccepted === false
70
+ || projectState.hasTrustDialogAccepted === null
71
+ || projectState.hasTrustDialogAccepted === undefined) {
72
+ return blocked(runtime);
73
+ }
74
+ return unknown(runtime);
75
+ } catch {
76
+ return unknown(runtime);
77
+ }
78
+ }
79
+
80
+ export async function checkRuntimeReadiness({
81
+ runtime,
82
+ projectRoot,
83
+ homeDir = homedir(),
84
+ }) {
85
+ const selectedRuntime = runtime ?? "auto";
86
+ if (selectedRuntime !== "claude-code") {
87
+ return ready(selectedRuntime);
88
+ }
89
+ return checkClaudeCodeReadiness({ projectRoot, homeDir });
90
+ }
@@ -1,235 +0,0 @@
1
- """Phase-transition resource cleanup orchestrator.
2
-
3
- Reuses the existing pane-reclaim (okstra-trace-cleanup.sh) and teammate-reconcile
4
- (okstra-team-reconcile.sh) primitives; this module only decides tmux-vs-in-process
5
- mode, finds the prior run dir, and sequences the two scripts. It never
6
- re-implements pane kill or completion detection.
7
- """
8
- from __future__ import annotations
9
-
10
- import argparse
11
- import json
12
- import subprocess
13
- import sys
14
- from pathlib import Path
15
- from typing import Callable, Optional
16
-
17
- from okstra_project import StateError, parse_task_key
18
-
19
- from .paths import RunRef, okstra_home
20
- from .tmux import resolve_caller_pane, tmux_available
21
-
22
- _TRACE_SCRIPT = "okstra-trace-cleanup.sh"
23
- _RECONCILE_SCRIPT = "okstra-team-reconcile.sh"
24
- _MODE_TMUX = "tmux"
25
- _MODE_IN_PROCESS = "in-process"
26
- _RUNNER_TIMEOUT_SECONDS = 10
27
-
28
-
29
- def _recorded_lead_pane(prev_run_dir: Optional[Path]) -> Optional[str]:
30
- """The pane the prior run's lead recorded, or None when unrecorded.
31
-
32
- '<run_dir>/state/lead-pane.id' is written once at run start by the lead
33
- adapter: a non-empty pane id means that run was inside a tmux pane, an empty
34
- (0-byte) file means the run resolved to in-process. This on-disk fact
35
- outlives the cleanup process's own context, which -- when okstra runs as a
36
- daemon child -- can no longer walk its ancestry back to a pane.
37
- """
38
- if prev_run_dir is None:
39
- return None
40
- try:
41
- return (prev_run_dir / "state" / "lead-pane.id").read_text().strip()
42
- except OSError:
43
- return None
44
-
45
-
46
- def resolve_mode(
47
- *,
48
- prev_run_dir: Optional[Path] = None,
49
- pane_probe: Callable[[], str] = resolve_caller_pane,
50
- tmux_probe: Callable[[], bool] = tmux_available,
51
- ) -> str:
52
- """'tmux' when the prior run had a pane to reclaim, else 'in-process'.
53
-
54
- The prior run's recorded lead pane is authoritative: it captured that run's
55
- tmux-ness at run start and survives a cleanup process that can no longer walk
56
- its ancestry to a pane (daemon child). Only when nothing was recorded does
57
- this fall back to the live ancestor-walk probe.
58
- """
59
- recorded = _recorded_lead_pane(prev_run_dir)
60
- if recorded is not None:
61
- return _MODE_TMUX if recorded else _MODE_IN_PROCESS
62
- if pane_probe():
63
- return _MODE_TMUX
64
- if prev_run_dir is not None and tmux_probe():
65
- # A prior run exists to reclaim from, but neither its recording nor the
66
- # live probe could confirm tmux -- yet tmux is reachable. Surface the
67
- # ambiguity instead of silently skipping pane reclaim.
68
- sys.stderr.write(
69
- "phase-cleanup: prior run has no recorded lead pane and no caller "
70
- "pane could be resolved, but tmux is reachable; treating as "
71
- "in-process and skipping pane reclaim\n"
72
- )
73
- return _MODE_IN_PROCESS
74
-
75
-
76
- def resolve_prev_run_dir(
77
- *,
78
- run_dir: Optional[str],
79
- project_root: Path,
80
- task_group: str,
81
- task_id: str,
82
- ) -> Optional[Path]:
83
- """Explicit --run-dir wins; otherwise the newest completed run across phases.
84
-
85
- Auto-discovery walks both FLAT runs/<type>/reports/ and staged
86
- runs/<type>/stage-N/reports/ (implementation / final-verification), so a
87
- staged prior run is found without an explicit run_dir; --run-dir still
88
- overrides when a specific prior run is wanted.
89
- """
90
- if run_dir:
91
- return Path(run_dir)
92
- ref = RunRef.latest_across(
93
- Path(project_root), task_group, task_id, include_stages=True
94
- )
95
- return ref.run_dir if ref is not None else None
96
-
97
-
98
- def _default_runner(cmd: list[str]) -> str:
99
- # Cleanup must never block the next phase, so a spawn failure or a missing
100
- # script degrades to "nothing to report" instead of propagating.
101
- try:
102
- proc = subprocess.run(
103
- cmd,
104
- capture_output=True,
105
- text=True,
106
- check=False,
107
- timeout=_RUNNER_TIMEOUT_SECONDS,
108
- )
109
- return proc.stdout
110
- except (OSError, subprocess.SubprocessError):
111
- return ""
112
-
113
-
114
- def _script_path(name: str) -> str:
115
- # repo layout: scripts/<name>; installed layout: ~/.okstra/bin/<name>
116
- candidates = [
117
- Path(__file__).resolve().parent.parent / name,
118
- okstra_home() / "bin" / name,
119
- ]
120
- for candidate in candidates:
121
- if candidate.is_file():
122
- return str(candidate)
123
- return str(candidates[-1]) # a missing script is tolerated by _default_runner
124
-
125
-
126
- def _reclaim_panes(prev_run_dir: Path, runner: Callable[[list[str]], str]) -> int:
127
- listing = runner(
128
- [
129
- _script_path(_TRACE_SCRIPT),
130
- "--run-dir",
131
- str(prev_run_dir),
132
- "--reclaim-completed",
133
- "--list",
134
- ]
135
- )
136
- count = len([ln for ln in listing.splitlines() if ln.strip()])
137
- runner(
138
- [_script_path(_TRACE_SCRIPT), "--run-dir", str(prev_run_dir), "--reclaim-completed"]
139
- )
140
- return count
141
-
142
-
143
- def _dismissible_teammates(
144
- project_root: Path,
145
- runner: Callable[[list[str]], str],
146
- fallback_team: str = "",
147
- ) -> list[str]:
148
- # The live team dir is keyed by the current session id, which Claude Code
149
- # re-issues on resume/compaction; without the caller's label the resolver
150
- # finds no live roster and reports nothing to dismiss.
151
- cmd = [_script_path(_RECONCILE_SCRIPT), "--project-root", str(project_root)]
152
- if fallback_team:
153
- cmd += ["--fallback-team", fallback_team]
154
- out = runner(cmd)
155
- prefix = "dismissible-member:"
156
- return [ln[len(prefix):].strip() for ln in out.splitlines() if ln.startswith(prefix)]
157
-
158
-
159
- def run_cleanup(
160
- *,
161
- mode: str,
162
- prev_run_dir: Optional[Path],
163
- project_root: Path,
164
- runner: Callable[[list[str]], str] = _default_runner,
165
- fallback_team: str = "",
166
- ) -> dict:
167
- # Cleanup is performed here (real pane kill + reconcile), not merely logged;
168
- # the CLI's execution IS the enforcement (a caller cannot emit a "cleaned"
169
- # line without this actually running).
170
- panes = 0
171
- if mode == _MODE_TMUX and prev_run_dir is not None:
172
- panes = _reclaim_panes(prev_run_dir, runner)
173
- teammates = _dismissible_teammates(project_root, runner, fallback_team)
174
- return {"mode": mode, "panesReclaimed": panes, "dismissibleTeammates": teammates}
175
-
176
-
177
- def main(argv: list[str]) -> int:
178
- ap = argparse.ArgumentParser(prog="okstra phase-cleanup")
179
- ap.add_argument(
180
- "--task-key",
181
- help="finds the newest completed run across phases, including staged "
182
- "(implementation / final-verification) stage-N runs",
183
- )
184
- ap.add_argument("--run-dir")
185
- ap.add_argument("--project-root", required=True)
186
- ap.add_argument(
187
- "--fallback-team",
188
- default="",
189
- help="team label to resolve the roster from when the live session dir is "
190
- "gone (session id re-issued by resume/compaction)",
191
- )
192
- ap.add_argument("--json", action="store_true")
193
- args = ap.parse_args(argv)
194
-
195
- task_group = task_id = ""
196
- if args.task_key:
197
- try:
198
- _pid, task_group, task_id = parse_task_key(args.task_key)
199
- except StateError as exc:
200
- sys.stderr.write(f"phase-cleanup: {exc}\n")
201
- return 0
202
-
203
- prev = resolve_prev_run_dir(
204
- run_dir=args.run_dir,
205
- project_root=Path(args.project_root),
206
- task_group=task_group,
207
- task_id=task_id,
208
- )
209
- if prev is None and not args.run_dir:
210
- sys.stderr.write(
211
- "phase-cleanup: no prior run found for auto-discovery "
212
- "(normal on a task's first phase); pass --run-dir to point at a "
213
- "specific prior run\n"
214
- )
215
- result = run_cleanup(
216
- mode=resolve_mode(prev_run_dir=prev),
217
- prev_run_dir=prev,
218
- project_root=Path(args.project_root),
219
- fallback_team=args.fallback_team,
220
- )
221
- if args.json:
222
- sys.stdout.write(json.dumps(result) + "\n")
223
- else:
224
- sys.stdout.write(f"mode: {result['mode']}\n")
225
- sys.stdout.write(f"panes-reclaimed: {result['panesReclaimed']}\n")
226
- sys.stdout.write(
227
- "dismissible-teammates: "
228
- + ", ".join(result["dismissibleTeammates"])
229
- + "\n"
230
- )
231
- return 0 # cleanup never blocks the next phase
232
-
233
-
234
- if __name__ == "__main__":
235
- raise SystemExit(main(sys.argv[1:]))
@@ -1,38 +0,0 @@
1
- import { runPythonModule } from "../../lib/python-helper.mjs";
2
-
3
- const USAGE = `okstra phase-cleanup — reclaim the prior phase/batch's completed panes and teammates
4
-
5
- Usage:
6
- okstra phase-cleanup --project-root <dir> [--task-key <k>] [--run-dir <dir>]
7
- [--fallback-team <label>] [--json]
8
-
9
- --task-key project-id:task-group:task-id — finds the newest completed run
10
- across phases, including staged (implementation / final-
11
- verification) stage-N runs.
12
- --run-dir explicit prior run dir (wins over --task-key).
13
- --fallback-team
14
- the live team label (session-<lead-session-prefix>) so teammate
15
- reconcile still finds the roster after a session-id re-issue
16
- (resume/compaction); omit and it resolves the current live
17
- session only.
18
-
19
- Reclaims only completed tmux panes (lead pane and in-flight workers are preserved),
20
- and prints the dismissible-teammate names for the lead to shut down. In a non-tmux
21
- session no panes exist, so it skips pane reclaim and only reconciles teammates.
22
- A cleanup failure never blocks the next phase (the worker returns 0); a malformed
23
- invocation still exits non-zero.
24
- `;
25
-
26
- export async function run(args) {
27
- if (args.includes("--help") || args.includes("-h")) {
28
- process.stdout.write(USAGE);
29
- return 0;
30
- }
31
-
32
- const result = await runPythonModule({
33
- module: "okstra_ctl.phase_cleanup",
34
- args,
35
- stdio: "inherit-stdout",
36
- });
37
- return result.code;
38
- }