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.
- package/docs/architecture.md +4 -2
- package/docs/cli.md +15 -5
- package/docs/project-structure-overview.md +3 -5
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/agents/workers/report-writer-worker.md +5 -6
- package/runtime/bin/okstra-trace-cleanup.sh +28 -2
- package/runtime/prompts/lead/adapters/claude-code.md +3 -3
- package/runtime/prompts/lead/convergence.md +20 -3
- package/runtime/prompts/lead/okstra-lead-contract.md +2 -2
- package/runtime/prompts/lead/report-writer.md +20 -14
- package/runtime/prompts/lead/team-contract.md +3 -3
- package/runtime/python/okstra_ctl/analysis_packet.py +4 -10
- package/runtime/python/okstra_ctl/codex_dispatch.py +117 -58
- package/runtime/python/okstra_ctl/convergence_engine.py +3 -1
- package/runtime/python/okstra_ctl/dispatch_core.py +19 -56
- package/runtime/python/okstra_ctl/dispatch_state.py +167 -3
- package/runtime/python/okstra_ctl/path_hints.py +6 -0
- package/runtime/python/okstra_ctl/paths.py +7 -44
- package/runtime/python/okstra_ctl/render.py +2 -0
- package/runtime/python/okstra_ctl/wizard.py +34 -0
- package/runtime/python/okstra_ctl/worker_liveness.py +84 -21
- package/runtime/python/okstra_ctl/worker_prompt_body.py +24 -4
- package/runtime/python/okstra_ctl/worker_prompt_contract.py +57 -0
- package/runtime/python/okstra_ctl/worker_state.py +65 -0
- package/runtime/python/okstra_token_usage/antigravity.py +3 -0
- package/runtime/python/okstra_token_usage/codex.py +54 -23
- package/runtime/python/okstra_token_usage/collect.py +141 -33
- package/runtime/python/okstra_token_usage/paths.py +27 -0
- package/runtime/python/okstra_vendor/__init__.py +15 -2
- package/runtime/schemas/convergence-groups-v1.0.schema.json +0 -1
- package/runtime/skills/okstra-run/SKILL.md +14 -4
- package/runtime/skills/okstra-setup/references/project-config.md +13 -4
- package/runtime/validators/lib/fixtures.sh +1 -1
- package/runtime/validators/validate-run.py +52 -1
- package/runtime/validators/validate_analysis_report.py +34 -3
- package/src/cli-registry.mjs +7 -10
- package/src/commands/execute/worker-state.mjs +29 -0
- package/src/commands/inspect/worker-liveness.mjs +5 -3
- package/src/commands/lifecycle/preflight.mjs +13 -3
- package/src/lib/runtime-readiness.mjs +90 -0
- package/runtime/python/okstra_ctl/phase_cleanup.py +0 -235
- 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
|
|
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
|
|
135
|
-
project
|
|
136
|
-
|
|
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
|
|
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
|
-
|
|
423
|
-
|
|
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(
|
package/src/cli-registry.mjs
CHANGED
|
@@ -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>]...
|
|
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
|
-
--
|
|
12
|
-
past the launch grace means the wrapper never
|
|
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
|
-
|
|
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
|
-
}
|