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
|
@@ -19,6 +19,8 @@ MAX_FINAL_VERIFICATION_BODY_LINES = 96
|
|
|
19
19
|
PROMPT_DELIVERY_MODE_HEADER = "**Prompt Delivery Mode:**"
|
|
20
20
|
PROMPT_DELIVERY_MODES = frozenset({"eager-include", "lazy-path-reference"})
|
|
21
21
|
MODEL_HEADER = "**Model:**"
|
|
22
|
+
TASK_TYPE_HEADER = "**Task Type:**"
|
|
23
|
+
FORBIDDEN_ACTIONS_HEADER = "**Forbidden actions:**"
|
|
22
24
|
|
|
23
25
|
_DIRECTIVE_HEADING = "## Run-specific directive"
|
|
24
26
|
_WORKER_ERROR_CONTRACT_HEADER = "**Worker Error Contract Path:**"
|
|
@@ -177,6 +179,61 @@ def validate_analysis_prompt_set(prompts: Mapping[str, str]) -> list[str]:
|
|
|
177
179
|
return [f"normalized initial analysis prompts differ across workers: {workers}"]
|
|
178
180
|
|
|
179
181
|
|
|
182
|
+
def validate_reverify_prompt(
|
|
183
|
+
text: str,
|
|
184
|
+
*,
|
|
185
|
+
task_type: str,
|
|
186
|
+
forbidden_actions: str,
|
|
187
|
+
) -> list[str]:
|
|
188
|
+
"""Require the active phase boundary in a lightweight reverify prompt."""
|
|
189
|
+
normalized = text.replace("\r\n", "\n").replace("\r", "\n")
|
|
190
|
+
errors: list[str] = []
|
|
191
|
+
task_values = _header_values(normalized, TASK_TYPE_HEADER)
|
|
192
|
+
if task_values != [task_type]:
|
|
193
|
+
errors.append(
|
|
194
|
+
f"exactly one {TASK_TYPE_HEADER} {task_type} header is required"
|
|
195
|
+
)
|
|
196
|
+
action_blocks = _section_values(normalized, FORBIDDEN_ACTIONS_HEADER)
|
|
197
|
+
if len(action_blocks) != 1:
|
|
198
|
+
errors.append("exactly one **Forbidden actions:** block is required")
|
|
199
|
+
elif action_blocks[0] != forbidden_actions.strip():
|
|
200
|
+
errors.append(
|
|
201
|
+
"Forbidden actions block must exactly match active-run-context "
|
|
202
|
+
"workflow.forbiddenActions"
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
expected_task_header = f"{TASK_TYPE_HEADER} {task_type}"
|
|
206
|
+
boundary_position = normalized.find(expected_task_header)
|
|
207
|
+
read_scope_position = normalized.find("**Read scope:**")
|
|
208
|
+
if boundary_position >= 0 and (
|
|
209
|
+
read_scope_position < 0 or read_scope_position > boundary_position
|
|
210
|
+
):
|
|
211
|
+
errors.append("phase boundary block must follow the reverify anchor headers")
|
|
212
|
+
first_heading = re.search(r"(?m)^##\s+", normalized)
|
|
213
|
+
if (
|
|
214
|
+
boundary_position >= 0
|
|
215
|
+
and first_heading is not None
|
|
216
|
+
and boundary_position > first_heading.start()
|
|
217
|
+
):
|
|
218
|
+
errors.append("phase boundary block must precede reverify instructions")
|
|
219
|
+
return errors
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _section_values(text: str, header: str) -> list[str]:
|
|
223
|
+
lines = text.splitlines()
|
|
224
|
+
values: list[str] = []
|
|
225
|
+
for index, line in enumerate(lines):
|
|
226
|
+
if line.strip() != header:
|
|
227
|
+
continue
|
|
228
|
+
body: list[str] = []
|
|
229
|
+
for candidate in lines[index + 1:]:
|
|
230
|
+
stripped = candidate.strip()
|
|
231
|
+
if stripped.startswith("## ") or re.match(r"^\*\*[^*]+:\*\*", stripped):
|
|
232
|
+
break
|
|
233
|
+
body.append(candidate)
|
|
234
|
+
values.append("\n".join(body).strip())
|
|
235
|
+
return values
|
|
236
|
+
|
|
180
237
|
|
|
181
238
|
def validate_initial_prompt_records(
|
|
182
239
|
*,
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Authoritative worker status transitions for one persisted team-state."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from .dispatch_state import DispatchError, WORKER_STATUSES, transition_worker_status
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _validated_team_state_path(value: str) -> Path:
|
|
13
|
+
path = Path(value).resolve()
|
|
14
|
+
if not any(parent.name == ".okstra" for parent in (path.parent, *path.parents)):
|
|
15
|
+
raise DispatchError(
|
|
16
|
+
f"team-state is outside a project .okstra directory: {path}"
|
|
17
|
+
)
|
|
18
|
+
return path
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _parser() -> argparse.ArgumentParser:
|
|
22
|
+
parser = argparse.ArgumentParser(prog="okstra worker-state")
|
|
23
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
24
|
+
transition = commands.add_parser(
|
|
25
|
+
"transition", help="atomically transition one worker status"
|
|
26
|
+
)
|
|
27
|
+
transition.add_argument("--team-state", required=True)
|
|
28
|
+
transition.add_argument("--worker", required=True)
|
|
29
|
+
transition.add_argument("--status", required=True, choices=sorted(WORKER_STATUSES))
|
|
30
|
+
transition.add_argument("--reason", default="")
|
|
31
|
+
transition.add_argument("--model", default="")
|
|
32
|
+
return parser
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def main(argv: list[str] | None = None) -> int:
|
|
36
|
+
parser = _parser()
|
|
37
|
+
args = parser.parse_args(argv)
|
|
38
|
+
try:
|
|
39
|
+
team_state_path = _validated_team_state_path(args.team_state)
|
|
40
|
+
transition_worker_status(
|
|
41
|
+
team_state_path,
|
|
42
|
+
args.worker,
|
|
43
|
+
args.status,
|
|
44
|
+
args.reason,
|
|
45
|
+
model_execution_value=args.model,
|
|
46
|
+
)
|
|
47
|
+
except DispatchError as exc:
|
|
48
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
49
|
+
return 1
|
|
50
|
+
print(
|
|
51
|
+
json.dumps(
|
|
52
|
+
{
|
|
53
|
+
"ok": True,
|
|
54
|
+
"teamStatePath": str(team_state_path),
|
|
55
|
+
"workerId": args.worker,
|
|
56
|
+
"status": args.status,
|
|
57
|
+
},
|
|
58
|
+
ensure_ascii=False,
|
|
59
|
+
)
|
|
60
|
+
)
|
|
61
|
+
return 0
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
if __name__ == "__main__":
|
|
65
|
+
raise SystemExit(main(sys.argv[1:]))
|
|
@@ -2,9 +2,13 @@
|
|
|
2
2
|
from __future__ import annotations
|
|
3
3
|
|
|
4
4
|
import json
|
|
5
|
+
import os
|
|
5
6
|
from pathlib import Path
|
|
6
7
|
from .jsonl_io import iter_jsonl
|
|
7
|
-
from .paths import CODEX_SESSIONS, ts_in_window
|
|
8
|
+
from .paths import CODEX_SESSIONS, codex_session_roots, ts_in_window
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
_DEFAULT_CODEX_SESSIONS = CODEX_SESSIONS
|
|
8
12
|
|
|
9
13
|
|
|
10
14
|
def codex_session_total(jsonl_path: Path) -> dict:
|
|
@@ -52,33 +56,60 @@ def find_codex_session(cwd: Path, started_at: str, ended_at: str) -> Path | None
|
|
|
52
56
|
return sessions[-1] if sessions else None
|
|
53
57
|
|
|
54
58
|
|
|
55
|
-
def
|
|
59
|
+
def _session_metadata(path: Path) -> tuple[str, str] | None:
|
|
60
|
+
try:
|
|
61
|
+
with path.open() as fh:
|
|
62
|
+
first = fh.readline()
|
|
63
|
+
except OSError:
|
|
64
|
+
return None
|
|
65
|
+
if not first:
|
|
66
|
+
return None
|
|
67
|
+
try:
|
|
68
|
+
record = json.loads(first)
|
|
69
|
+
except json.JSONDecodeError:
|
|
70
|
+
return None
|
|
71
|
+
if record.get("type") != "session_meta":
|
|
72
|
+
return None
|
|
73
|
+
payload = record.get("payload") or {}
|
|
74
|
+
timestamp = payload.get("timestamp") or record.get("timestamp") or ""
|
|
75
|
+
return str(payload.get("cwd") or ""), timestamp
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def find_codex_sessions(
|
|
79
|
+
cwd: Path,
|
|
80
|
+
started_at: str,
|
|
81
|
+
ended_at: str,
|
|
82
|
+
*,
|
|
83
|
+
session_roots: tuple[Path, ...] | None = None,
|
|
84
|
+
) -> list[Path]:
|
|
56
85
|
"""Find codex rollout jsonls whose meta.cwd matches the window."""
|
|
57
|
-
if not
|
|
86
|
+
if not started_at or not ended_at:
|
|
58
87
|
return []
|
|
88
|
+
if session_roots is None:
|
|
89
|
+
if CODEX_SESSIONS != _DEFAULT_CODEX_SESSIONS:
|
|
90
|
+
session_roots = (CODEX_SESSIONS,)
|
|
91
|
+
else:
|
|
92
|
+
session_roots = codex_session_roots(Path.home(), os.environ)
|
|
59
93
|
target_cwd = str(cwd)
|
|
60
94
|
candidates: list[tuple[str, Path]] = []
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
first = fh.readline()
|
|
65
|
-
except OSError:
|
|
66
|
-
continue
|
|
67
|
-
if not first:
|
|
68
|
-
continue
|
|
69
|
-
try:
|
|
70
|
-
rec = json.loads(first)
|
|
71
|
-
except json.JSONDecodeError:
|
|
72
|
-
continue
|
|
73
|
-
if rec.get("type") != "session_meta":
|
|
74
|
-
continue
|
|
75
|
-
payload = rec.get("payload") or {}
|
|
76
|
-
if payload.get("cwd") != target_cwd:
|
|
77
|
-
continue
|
|
78
|
-
ts = payload.get("timestamp") or rec.get("timestamp") or ""
|
|
79
|
-
if not ts_in_window(ts, started_at, ended_at):
|
|
95
|
+
seen: set[Path] = set()
|
|
96
|
+
for root in session_roots:
|
|
97
|
+
if not root.is_dir():
|
|
80
98
|
continue
|
|
81
|
-
|
|
99
|
+
for p in root.rglob("rollout-*.jsonl"):
|
|
100
|
+
identity = p.resolve()
|
|
101
|
+
if identity in seen:
|
|
102
|
+
continue
|
|
103
|
+
seen.add(identity)
|
|
104
|
+
metadata = _session_metadata(p)
|
|
105
|
+
if metadata is None:
|
|
106
|
+
continue
|
|
107
|
+
session_cwd, ts = metadata
|
|
108
|
+
if session_cwd != target_cwd:
|
|
109
|
+
continue
|
|
110
|
+
if not ts_in_window(ts, started_at, ended_at):
|
|
111
|
+
continue
|
|
112
|
+
candidates.append((ts, p))
|
|
82
113
|
if not candidates:
|
|
83
114
|
return []
|
|
84
115
|
candidates.sort()
|
|
@@ -15,11 +15,13 @@ from .claude import (
|
|
|
15
15
|
)
|
|
16
16
|
from .codex import codex_session_total, find_codex_sessions
|
|
17
17
|
from .antigravity import (
|
|
18
|
+
PRINT_MODE_USAGE_NOTE,
|
|
18
19
|
antigravity_session_total,
|
|
19
20
|
find_antigravity_sessions,
|
|
20
21
|
)
|
|
21
22
|
from .paths import claude_project_dir, utc_now
|
|
22
23
|
from .pricing import codex_cost_usd, antigravity_cost_usd
|
|
24
|
+
from okstra_ctl.wrapper_status import read_wrapper_status, status_path_for_prompt
|
|
23
25
|
|
|
24
26
|
|
|
25
27
|
def match_prefixes(worker_id: str) -> list[str]:
|
|
@@ -434,7 +436,7 @@ def _codex_worker_windows(project_root: Path, state: dict) -> dict[str, list[tup
|
|
|
434
436
|
running_workers = {
|
|
435
437
|
str(worker.get("workerId") or "").strip()
|
|
436
438
|
for worker in state.get("workers", [])
|
|
437
|
-
if isinstance(worker, dict) and worker.get("status")
|
|
439
|
+
if isinstance(worker, dict) and worker.get("status") in {"running", "in-progress"}
|
|
438
440
|
}
|
|
439
441
|
if running_workers:
|
|
440
442
|
open_until = utc_now()
|
|
@@ -512,12 +514,116 @@ def _cli_usage_block(provider: str, totals: dict, session_paths: list[Path]) ->
|
|
|
512
514
|
return block
|
|
513
515
|
|
|
514
516
|
|
|
517
|
+
def _epoch_iso(value: object) -> str | None:
|
|
518
|
+
if not isinstance(value, (int, float)) or isinstance(value, bool):
|
|
519
|
+
return None
|
|
520
|
+
return datetime.fromtimestamp(value, timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def wrapper_execution(status_path: Path | None) -> dict:
|
|
524
|
+
"""Read wrapper execution evidence without inferring token availability."""
|
|
525
|
+
if status_path is None or not status_path.is_file():
|
|
526
|
+
return {"status": "not-started"}
|
|
527
|
+
status = read_wrapper_status(status_path)
|
|
528
|
+
if status is None:
|
|
529
|
+
return {"status": "failed", "statusPath": str(status_path)}
|
|
530
|
+
|
|
531
|
+
if status.timeout:
|
|
532
|
+
execution_status = "timeout"
|
|
533
|
+
elif status.stage == "started":
|
|
534
|
+
execution_status = "started"
|
|
535
|
+
elif status.stage == "exited" and status.exit_code == 0:
|
|
536
|
+
execution_status = "exited"
|
|
537
|
+
else:
|
|
538
|
+
execution_status = "failed"
|
|
539
|
+
|
|
540
|
+
execution = {
|
|
541
|
+
"status": execution_status,
|
|
542
|
+
"statusPath": str(status_path),
|
|
543
|
+
"startedAt": _epoch_iso(status.raw.get("started_ts")),
|
|
544
|
+
"endedAt": _epoch_iso(status.raw.get("ended_ts")),
|
|
545
|
+
}
|
|
546
|
+
if status.exit_code is not None:
|
|
547
|
+
execution["exitCode"] = status.exit_code
|
|
548
|
+
return execution
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
def _execution_note(execution: dict) -> str:
|
|
552
|
+
status = execution["status"]
|
|
553
|
+
if status == "exited":
|
|
554
|
+
return f"wrapper exited {execution.get('exitCode', 0)}"
|
|
555
|
+
if status == "timeout":
|
|
556
|
+
return "wrapper timed out"
|
|
557
|
+
if status == "failed":
|
|
558
|
+
exit_code = execution.get("exitCode")
|
|
559
|
+
if exit_code is not None:
|
|
560
|
+
return f"wrapper failed with exit code {exit_code}"
|
|
561
|
+
return "wrapper status is invalid"
|
|
562
|
+
if status == "started":
|
|
563
|
+
return "wrapper started and has not recorded an exit"
|
|
564
|
+
return "wrapper status sidecar is unavailable"
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
def collect_cli_usage(
|
|
568
|
+
*,
|
|
569
|
+
provider: str,
|
|
570
|
+
status_path: Path | None,
|
|
571
|
+
sessions: list[Path],
|
|
572
|
+
fallback_window_used: bool = False,
|
|
573
|
+
) -> dict:
|
|
574
|
+
"""Collect attributable CLI usage while retaining execution evidence."""
|
|
575
|
+
execution = wrapper_execution(status_path)
|
|
576
|
+
execution_note = _execution_note(execution)
|
|
577
|
+
|
|
578
|
+
if provider == "antigravity":
|
|
579
|
+
block = na_block(f"{PRINT_MODE_USAGE_NOTE}; {execution_note}")
|
|
580
|
+
block["cliNote"] = block["note"]
|
|
581
|
+
else:
|
|
582
|
+
totals = _cli_session_totals(provider, sessions)
|
|
583
|
+
if not sessions:
|
|
584
|
+
block = na_block(
|
|
585
|
+
f"{execution_note}; CLI usage attribution unavailable because no transcript was found"
|
|
586
|
+
)
|
|
587
|
+
block["cliNote"] = block["note"]
|
|
588
|
+
elif not totals:
|
|
589
|
+
block = na_block(
|
|
590
|
+
f"{execution_note}; transcript found but no final token snapshot was recorded"
|
|
591
|
+
)
|
|
592
|
+
block["cliNote"] = block["note"]
|
|
593
|
+
block["cliSessionPaths"] = [str(path) for path in sessions]
|
|
594
|
+
else:
|
|
595
|
+
block = _cli_usage_block(provider, _aggregate_totals(totals), sessions)
|
|
596
|
+
|
|
597
|
+
block["cliExecutionStatus"] = execution["status"]
|
|
598
|
+
if fallback_window_used:
|
|
599
|
+
fallback_note = "wrapper status sidecar unavailable; used aggregate wrapper window fallback"
|
|
600
|
+
prior_note = block.get("cliNote")
|
|
601
|
+
block["cliNote"] = f"{prior_note}; {fallback_note}" if prior_note else fallback_note
|
|
602
|
+
return block
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
def _worker_cli_windows(
|
|
606
|
+
project_root: Path,
|
|
607
|
+
worker: dict,
|
|
608
|
+
fallback_windows: list[tuple[str, str]],
|
|
609
|
+
) -> tuple[list[tuple[str, str]], Path | None, bool]:
|
|
610
|
+
prompt_path = _resolve_project_path(project_root, str(worker.get("promptPath") or ""))
|
|
611
|
+
status_path = status_path_for_prompt(prompt_path) if prompt_path is not None else None
|
|
612
|
+
execution = wrapper_execution(status_path)
|
|
613
|
+
started_at = execution.get("startedAt")
|
|
614
|
+
ended_at = execution.get("endedAt")
|
|
615
|
+
if started_at:
|
|
616
|
+
return [(started_at, ended_at or utc_now())], status_path, False
|
|
617
|
+
return fallback_windows, status_path, True
|
|
618
|
+
|
|
619
|
+
|
|
515
620
|
def _attach_cli_usage(
|
|
516
621
|
block: dict,
|
|
517
622
|
provider: str,
|
|
518
623
|
project_root: Path,
|
|
519
|
-
|
|
520
|
-
|
|
624
|
+
windows: list[tuple[str, str]],
|
|
625
|
+
status_path: Path | None,
|
|
626
|
+
fallback_window_used: bool,
|
|
521
627
|
) -> None:
|
|
522
628
|
"""Layer aggregated CLI token/cost onto a Claude-side worker ``block``.
|
|
523
629
|
|
|
@@ -528,19 +634,21 @@ def _attach_cli_usage(
|
|
|
528
634
|
CLI spend is fully reported. (agy print mode leaves no transcript, so the
|
|
529
635
|
antigravity path still resolves to "session not found" → na cost.)
|
|
530
636
|
"""
|
|
531
|
-
session_paths = _cli_sessions_for_windows(
|
|
532
|
-
|
|
637
|
+
session_paths = _cli_sessions_for_windows(provider, project_root, windows)
|
|
638
|
+
cli = collect_cli_usage(
|
|
639
|
+
provider=provider,
|
|
640
|
+
status_path=status_path,
|
|
641
|
+
sessions=session_paths,
|
|
642
|
+
fallback_window_used=fallback_window_used,
|
|
533
643
|
)
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
cli = _cli_usage_block(provider, _aggregate_totals(totals), session_paths)
|
|
543
|
-
for key in ("cliTotalTokens", "cliEstimatedCostUsd", "cliModel"):
|
|
644
|
+
for key in (
|
|
645
|
+
"cliTotalTokens",
|
|
646
|
+
"cliEstimatedCostUsd",
|
|
647
|
+
"cliModel",
|
|
648
|
+
"cliSessionPaths",
|
|
649
|
+
"cliNote",
|
|
650
|
+
"cliExecutionStatus",
|
|
651
|
+
):
|
|
544
652
|
if key in cli:
|
|
545
653
|
block[key] = cli[key]
|
|
546
654
|
|
|
@@ -560,23 +668,17 @@ def _collect_codex_runtime_usage(state: dict, project_root: Path) -> dict:
|
|
|
560
668
|
)
|
|
561
669
|
continue
|
|
562
670
|
worker_id = str(worker.get("workerId") or "").strip()
|
|
563
|
-
windows =
|
|
564
|
-
|
|
565
|
-
worker
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
continue
|
|
671
|
+
windows, status_path, used_fallback = _worker_cli_windows(
|
|
672
|
+
project_root,
|
|
673
|
+
worker,
|
|
674
|
+
windows_by_worker.get(worker_id, []),
|
|
675
|
+
)
|
|
569
676
|
session_paths = _cli_sessions_for_windows(provider, project_root, windows)
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
continue
|
|
576
|
-
worker["usage"] = _cli_usage_block(
|
|
577
|
-
provider,
|
|
578
|
-
_aggregate_totals(totals),
|
|
579
|
-
session_paths,
|
|
677
|
+
worker["usage"] = collect_cli_usage(
|
|
678
|
+
provider=provider,
|
|
679
|
+
status_path=status_path,
|
|
680
|
+
sessions=session_paths,
|
|
681
|
+
fallback_window_used=used_fallback,
|
|
580
682
|
)
|
|
581
683
|
_populate_usage_summary(state, team_name=resolve_team_name(state),
|
|
582
684
|
sessions_found=0, needle_source="none")
|
|
@@ -779,12 +881,18 @@ def collect(team_state_path: Path, project_root: Path | None = None, *,
|
|
|
779
881
|
# For codex/antigravity workers, sum every CLI session that fell inside
|
|
780
882
|
# the aggregated wrapper window (re-dispatches leave one rollout each).
|
|
781
883
|
if agent in ("codex", "antigravity"):
|
|
884
|
+
windows, status_path, used_fallback = _worker_cli_windows(
|
|
885
|
+
cwd,
|
|
886
|
+
worker,
|
|
887
|
+
[(aggregate.get("startedAt") or "", aggregate.get("endedAt") or "")],
|
|
888
|
+
)
|
|
782
889
|
_attach_cli_usage(
|
|
783
890
|
block,
|
|
784
891
|
agent,
|
|
785
892
|
cwd,
|
|
786
|
-
|
|
787
|
-
|
|
893
|
+
windows,
|
|
894
|
+
status_path,
|
|
895
|
+
used_fallback,
|
|
788
896
|
)
|
|
789
897
|
worker["usage"] = block
|
|
790
898
|
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
"""Filesystem locations for agent session transcripts and time helpers."""
|
|
2
2
|
from __future__ import annotations
|
|
3
3
|
|
|
4
|
+
import os
|
|
4
5
|
from datetime import datetime, timezone
|
|
5
6
|
from pathlib import Path
|
|
7
|
+
from typing import Mapping
|
|
6
8
|
|
|
7
9
|
|
|
8
10
|
HOME = Path.home()
|
|
@@ -10,6 +12,31 @@ CLAUDE_PROJECTS = HOME / ".claude" / "projects"
|
|
|
10
12
|
CODEX_SESSIONS = HOME / ".agent" / "sessions"
|
|
11
13
|
|
|
12
14
|
|
|
15
|
+
def codex_session_roots(
|
|
16
|
+
home: Path,
|
|
17
|
+
env: Mapping[str, str],
|
|
18
|
+
) -> tuple[Path, ...]:
|
|
19
|
+
"""Return Codex transcript roots in configuration precedence order."""
|
|
20
|
+
candidates: list[Path] = []
|
|
21
|
+
configured_home = env.get("CODEX_HOME", "").strip()
|
|
22
|
+
if configured_home:
|
|
23
|
+
candidates.append(Path(configured_home).expanduser() / "sessions")
|
|
24
|
+
candidates.extend((
|
|
25
|
+
home / ".codex" / "sessions",
|
|
26
|
+
home / ".agent" / "sessions",
|
|
27
|
+
))
|
|
28
|
+
|
|
29
|
+
roots: list[Path] = []
|
|
30
|
+
seen: set[Path] = set()
|
|
31
|
+
for candidate in candidates:
|
|
32
|
+
normalized = Path(os.path.abspath(candidate))
|
|
33
|
+
if normalized in seen:
|
|
34
|
+
continue
|
|
35
|
+
seen.add(normalized)
|
|
36
|
+
roots.append(candidate)
|
|
37
|
+
return tuple(roots)
|
|
38
|
+
|
|
39
|
+
|
|
13
40
|
def utc_now() -> str:
|
|
14
41
|
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
15
42
|
|
|
@@ -26,12 +26,25 @@ imports find them deterministically.
|
|
|
26
26
|
|
|
27
27
|
import sys as _sys
|
|
28
28
|
|
|
29
|
+
|
|
30
|
+
def _alias_loaded_submodules(package: object, canonical_name: str) -> None:
|
|
31
|
+
"""Expose one vendored package tree under its canonical import name."""
|
|
32
|
+
vendored_name = package.__name__
|
|
33
|
+
_sys.modules[canonical_name] = package
|
|
34
|
+
vendored_prefix = f"{vendored_name}."
|
|
35
|
+
for module_name, module in tuple(_sys.modules.items()):
|
|
36
|
+
if module is None or not module_name.startswith(vendored_prefix):
|
|
37
|
+
continue
|
|
38
|
+
suffix = module_name[len(vendored_name):]
|
|
39
|
+
_sys.modules[f"{canonical_name}{suffix}"] = module
|
|
40
|
+
|
|
41
|
+
|
|
29
42
|
# Register markupsafe alias BEFORE importing jinja2 — jinja2's __init__
|
|
30
43
|
# evaluates `from .environment import ...`, which does `from markupsafe
|
|
31
44
|
# import Markup` at module-load time. Reversing the order would crash
|
|
32
45
|
# the import chain before the alias was registered.
|
|
33
46
|
from . import markupsafe as _markupsafe
|
|
34
|
-
|
|
47
|
+
_alias_loaded_submodules(_markupsafe, "markupsafe")
|
|
35
48
|
|
|
36
49
|
from . import jinja2 as _jinja2 # noqa: E402
|
|
37
|
-
|
|
50
|
+
_alias_loaded_submodules(_jinja2, "jinja2")
|
|
@@ -43,6 +43,7 @@ On `ok: false`, re-prompt with the same `current.step` using the error message.
|
|
|
43
43
|
|
|
44
44
|
The wizard tells you *which UI to use* via `kind` (and the optional `multi` flag on `pick`):
|
|
45
45
|
|
|
46
|
+
- `kind: "pick"` + `presentation: "numbered-text"` → do not call `AskUserQuestion`. Render every option as a numbered Markdown list in its original order, then consume the user's next message as the answer. Submit that message unchanged with `--answer`; the wizard accepts a 1-based number, an exact option value, or an exact option label and resolves it to the canonical value. An invalid, out-of-range, or ambiguous answer returns `ok: false` and must re-prompt the same complete list.
|
|
46
47
|
- `kind: "pick"` + `multi: false` (default) → render `AskUserQuestion` with `label`, `options[].label`, and `multiSelect: false`. Use the chosen `options[].value` (single string) as the answer.
|
|
47
48
|
- `kind: "pick"` + `multi: true` → render `AskUserQuestion` with `label`, `options[].label`, and `multiSelect: true`. Join the chosen `options[].value` entries with `,` into a single CSV string and submit that as `--answer "csv,values"`. If the user selects nothing, still submit `--answer ""` — the wizard will reply `ok: false` and re-prompt the same step (do not skip the call).
|
|
48
49
|
- `kind: "pick_group"` → render a SINGLE `AskUserQuestion` whose questions array maps 1:1 to the wizard's `questions[]`. For each entry use `questions[].label`, `questions[].options[].label`, and `multiSelect: questions[].multi`. Collect the user's chosen `options[].value` per tab, build a JSON object keyed by each `questions[].step`, and submit it as a single literal `--answer '{"lead_model":"opus","claude_model":"default",...}'`. A tab the user leaves at its default still gets its `"default"`/`""` value in the JSON. Never split a `pick_group` into multiple `AskUserQuestion` calls — the wizard already capped it at 4 tabs and emits any remainder as the next prompt.
|
|
@@ -52,7 +53,7 @@ The wizard tells you *which UI to use* via `kind` (and the optional `multi` flag
|
|
|
52
53
|
|
|
53
54
|
The final `confirm` step is a normal `pick` step with three options — `Proceed` / `Edit` / `Abort`(abort) — and is rendered the same way (no special handling). `Edit` rewinds to any earlier step (including `base-ref`); `Abort` terminally cancels the wizard. The branch/worktree decision the run will actually use (for `implementation`, the **stage worktree** — not the task-key directory) is folded into the Step 4 confirmation summary block as a `worktree` line, so there is no separate branch-confirm prompt.
|
|
54
55
|
|
|
55
|
-
Never invent additional questions. Never reorder. **Never drop, hide, or merge a `pick` / `pick_group` option** — render every `options[]` entry
|
|
56
|
+
Never invent additional questions. Never reorder. **Never drop, hide, or merge a `pick` / `pick_group` option** — render every `options[]` entry, including entries that carry a `(default)` / `(recommended)` suffix. Use an `AskUserQuestion` choice unless the wizard explicitly sets `presentation: "numbered-text"`; that presentation preserves overflow choices as a numbered Markdown list. Do NOT collapse a multi-option pick into a "recommended + Enter directly / Other" shortlist: the wizard's `options[]` array IS the complete, authoritative choice set. Example: if a pick's `options[]` carries N entries, render all N — never abbreviate a multi-option step down to one recommended value. The run-prompt recommendation rule (1–2 recommendations + Enter directly) applies ONLY to prompts this skill authors itself (e.g. the conformance-waiver picker), never to wizard-provided `options[]`. Never use `AskUserQuestion` for `text` prompts — the wizard explicitly chose `text` to avoid the picker-Other re-render lag.
|
|
56
57
|
|
|
57
58
|
## Step 1: Preflight
|
|
58
59
|
|
|
@@ -62,7 +63,15 @@ Run one Bash tool call (Bash invocation rule from the top of this file applies):
|
|
|
62
63
|
okstra preflight --runtime claude-code --json
|
|
63
64
|
```
|
|
64
65
|
|
|
65
|
-
Parse the stdout JSON. `ok:
|
|
66
|
+
Parse the stdout JSON. `ok: false` (or `okstra` not on `PATH` at all) → tell the user: "okstra not set up — run `/okstra-setup` first." Then stop. If the call fails with `unknown command: preflight`, the `okstra` binary on PATH predates this skill — tell the user to update it (`npm i -g okstra@latest`), then stop (`/okstra-setup` does not update the binary). Do **not** try to invoke `npx -y okstra@latest ...` as a fallback — `npx` is not on the literal-token allow-list and will force a confirmation prompt on every wizard call afterward. Every subsequent `okstra <subcmd>` call self-bootstraps its Python path, so never `export PYTHONPATH=...`.
|
|
67
|
+
|
|
68
|
+
On `ok: true`, inspect `runtimeReadiness.checks` for the check whose `id` is `workspace-trust`. This is a Claude Code host-runtime check, independent of whether the worker providers are Claude, Codex, or Antigravity.
|
|
69
|
+
|
|
70
|
+
- `status: required` with `action: restart-and-trust` → tell the user: "Claude Code must trust `<projectRoot>` before Okstra can dispatch workers. Close this session, reopen that project in Claude Code, choose `Yes, I trust this folder`, then run `/okstra-run` again." Then stop before Step 2. Never start the wizard or dispatch a worker from this session.
|
|
71
|
+
- `status: unavailable` → tell the user that Claude Code workspace trust could not be verified, ask them to open `<projectRoot>` directly in Claude Code and complete any trust prompt, then run `/okstra-run` again. Then stop before Step 2.
|
|
72
|
+
- `status: accepted` or `status: not-applicable` → carry `projectRoot` and `projectId` as literal strings into Step 2.
|
|
73
|
+
- If `runtimeReadiness` is absent, preserve compatibility with an older preflight response: carry `projectRoot` and `projectId` into Step 2. The existing `unknown command: preflight` branch remains the authoritative stale-CLI failure.
|
|
74
|
+
- Any other `workspace-trust` status is unrecognized runtime output: show the status and stop before Step 2 instead of guessing that trust exists.
|
|
66
75
|
|
|
67
76
|
## Step 2: Initialize the wizard
|
|
68
77
|
|
|
@@ -90,6 +99,7 @@ Output: the same `{ok, next}` JSON described above. The first `next` is always `
|
|
|
90
99
|
Repeat until `next.kind == "done"` (or `"aborted"` — terminal cancel, see "How the wizard talks to you"):
|
|
91
100
|
|
|
92
101
|
1. **Render** the prompt according to `kind` (and `multi` for pick). **Always append the progress marker to the rendered question label** (the `AskUserQuestion` question text, or the `text`-prompt message): suffix it with ` (<next.progress.label>)` — render `progress.label` exactly as the wizard sent it, never recompute it. Example: label `Step 8/11 · 3 steps remaining` → `Select a model (Step 8/11 · 3 steps remaining)`. Re-prompts after `ok: false` reuse `current.progress.label` the same way. The progress marker is presentation-only — never send it back to the wizard as part of an answer.
|
|
102
|
+
- `pick` + `presentation: "numbered-text"` → plain text containing `label`, followed by every option as a numbered Markdown list. Consume the user's next message verbatim as the answer string.
|
|
93
103
|
- `pick` + `multi: false` → `AskUserQuestion` with `multiSelect: false`, `label`, and `options`. The user's chosen option's `value` is the answer string.
|
|
94
104
|
- `pick` + `multi: true` → `AskUserQuestion` with `multiSelect: true`, `label`, and `options`. Join the selected `value`s with `,` into a single literal CSV string (e.g. `"claude,codex,antigravity"`) and submit it as a single `--answer "claude,codex,antigravity"`. Empty selection submits `--answer ""` and the wizard re-prompts.
|
|
95
105
|
- `pick_group` → one `AskUserQuestion` with one question per `questions[]` entry (tab). Map each tab's selected `value` back by `questions[].step`, assemble a JSON object, and submit it as a single literal `--answer '<json>'`.
|
|
@@ -171,7 +181,7 @@ okstra config set pr-template-path "<value>" --scope global
|
|
|
171
181
|
|
|
172
182
|
If an action has an unknown `command`, `key`, or `scope`, stop and report the wizard output instead of inventing a command.
|
|
173
183
|
|
|
174
|
-
Before rendering the next phase's bundle
|
|
184
|
+
Before rendering the next phase's bundle — and between worker rounds within a phase (reverify/critic/gapverify batches), after you have collected that round's results and token usage and before you dispatch the next round — reclaim the prior round's completed teammate panes so they do not accumulate, in two passes and adding `--keep report-writer-worker` to **both** whenever the report writer is still in flight. First source the count: `$HOME/.okstra/bin/okstra-trace-cleanup.sh --list --run-dir "<RUN_DIR>" [--keep report-writer-worker]` never kills and prints one `<pane_id>\t<pane_title>` line per pane it would reclaim — count those lines as `<n>`. Then run the same command **without** `--list` to perform the reclaim, and emit `PROGRESS: phase-batch-cleanup panes=<n>` with that count at the batch boundary. Call both passes after collecting results and before the next dispatch so no in-flight worker pane is caught. This `tmux kill-pane`s the harness teammate panes; `shutdown_request` alone only idles the agent and never frees the pane, so it stays part of the run-end sequence for roster/token hygiene. `<RUN_DIR>` is the current (or just-finished) run's directory; its recorded `state/lead-pane.id` scopes the lead's session and the lead pane is never killed. In a non-tmux session there are no panes and the script is a silent no-op.
|
|
175
185
|
|
|
176
186
|
Build the `okstra render-bundle` invocation from `outcome.renderArgs`, passing each key as `--<key>` and the value verbatim (including empty strings — they are intentional `use phase default` markers).
|
|
177
187
|
|
|
@@ -272,7 +282,7 @@ Queue = the topologically-sorted stage list from splitting `chain-stages` on `,`
|
|
|
272
282
|
|
|
273
283
|
1. Call Step 5's `render-bundle` with the same arguments but `--stage N` (the base commit is auto-computed by prepare from the predecessor's done `head_commit`, so do not pass it by hand). Step 5's blocking local conformance waiver offer·concurrent-run detection·git-reconcile gates apply identically to each stage's `render-bundle`.
|
|
274
284
|
2. As in Step 6, become Claude lead and run that stage's Phase 1–7 inline. Phase 6's lead post-stage persistence appends that stage's `status:"done"` row to `runs/<plan-task-key>/consumers.jsonl` (per the implementation profile directive).
|
|
275
|
-
3. After confirming that `done` row was written, run
|
|
285
|
+
3. After confirming that `done` row was written, reclaim the completed teammate panes of the stage you just finished: run `$HOME/.okstra/bin/okstra-trace-cleanup.sh --run-dir "<the run dir of the stage you just completed>"` (add `--keep report-writer-worker` if the report writer is still in flight). Then move to the next stage.
|
|
276
286
|
4. One-line report at each stage start/finish: `stage N/<total> start` / `stage N done → next K`.
|
|
277
287
|
|
|
278
288
|
Once the whole queue is consumed, end the chain and report completion to the user.
|