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
@@ -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:]))
@@ -14,6 +14,9 @@ from __future__ import annotations
14
14
  from pathlib import Path
15
15
 
16
16
 
17
+ PRINT_MODE_USAGE_NOTE = "N/A — print mode exposes no transcript"
18
+
19
+
17
20
  def antigravity_session_total(json_path: Path) -> dict:
18
21
  return {"totalTokens": 0, "available": False}
19
22
 
@@ -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 find_codex_sessions(cwd: Path, started_at: str, ended_at: str) -> list[Path]:
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 CODEX_SESSIONS.is_dir() or not started_at or not ended_at:
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
- for p in CODEX_SESSIONS.rglob("rollout-*.jsonl"):
62
- try:
63
- with p.open() as fh:
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
- candidates.append((ts, p))
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") == "running"
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
- wrapper_start: str,
520
- wrapper_end: str,
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
- provider, project_root, [(wrapper_start, wrapper_end)]
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
- if not session_paths:
535
- block["cliNote"] = f"{provider} CLI session not found in wrapper window (fallback or never invoked)"
536
- return
537
- block["cliSessionPaths"] = [str(path) for path in session_paths]
538
- totals = _cli_session_totals(provider, session_paths)
539
- if not totals:
540
- block["cliNote"] = f"{provider} CLI session found but no usage recorded (likely errored before completion)"
541
- return
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 = windows_by_worker.get(worker_id, [])
564
- if not windows:
565
- worker["usage"] = na_block(
566
- f"no lead-events dispatch window found for workerId={worker_id}"
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
- totals = _cli_session_totals(provider, session_paths)
571
- if not totals:
572
- worker["usage"] = na_block(
573
- f"no {provider} CLI session found in lead-events dispatch window"
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
- aggregate.get("startedAt") or "",
787
- aggregate.get("endedAt") or "",
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
- _sys.modules.setdefault("markupsafe", _markupsafe)
47
+ _alias_loaded_submodules(_markupsafe, "markupsafe")
35
48
 
36
49
  from . import jinja2 as _jinja2 # noqa: E402
37
- _sys.modules.setdefault("jinja2", _jinja2)
50
+ _alias_loaded_submodules(_jinja2, "jinja2")
@@ -51,7 +51,6 @@
51
51
  "category": { "type": "string", "pattern": "\\S" },
52
52
  "ticketIds": {
53
53
  "type": "array",
54
- "minItems": 1,
55
54
  "items": { "type": "string", "pattern": "\\S" }
56
55
  },
57
56
  "originWorker": { "type": "string", "pattern": "\\S" },
@@ -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 as its own selectable `AskUserQuestion` choice, including entries that carry a `(default)` / `(recommended)` suffix. 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 as selectable choices — 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
+ 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: true` → carry `projectRoot` and `projectId` as literal strings into Step 2. `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=...`.
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, reclaim the prior phase's residual resources: run `okstra phase-cleanup --task-key <args.project-id>:<args.task-group>:<args.task-id> --project-root "<PROJECT_ROOT>" --fallback-team "session-<lead.sessionId-prefix>"`, then send `SendMessage(to: <name>, message: { type: "shutdown_request" })` to each `dismissible-teammates` nameonly to teammates you have confirmed complete, never the lead and never an in-flight worker. `--fallback-team` takes the same `session-<lead.sessionId-prefix>` label the lead recorded in team-state, and must always be passed: after a resume or compaction the session id is re-issued, and without the label the reconcile finds no live roster and prints no teammate to dismiss. Assemble `--task-key` as the 3-token `project-id:task-group:task-id` literal from `outcome.renderArgs`; any other shape (a `<task-group>/<task-id>` pair, a bare task-id) is rejected and the whole cleanup teammate reconcile included is silently skipped. **This applies only when advancing to a different analysis phase.** The implementation stage chain (Step 7) does its own per-stage reclaim in its step 3 and does NOT run this `--task-key` form. `--run-dir` is unnecessary the command auto-discovers the newest completed run across phases, including staged (implementation stage / single-stage final-verification) `runs/<type>/stage-N/` runs; pass `--run-dir` only to override that discovery with a specific prior run. A cleanup failure never blocks the next phase (the Python worker returns 0); only a malformed invocation exits non-zero.
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 roundreclaim 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 `okstra phase-cleanup --run-dir "<the run dir of the stage you just completed>" --project-root "<PROJECT_ROOT>" --fallback-team "session-<lead.sessionId-prefix>"`; for each `dismissible-teammates` name, send `SendMessage(to: <name>, message: { type: "shutdown_request" })` — only to teammates you have confirmed complete, never the lead and never an in-flight worker. Then move to the next stage.
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.