okstra 0.177.0 → 0.177.1

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 (37) hide show
  1. package/dist/commands/execute/team.mjs +14 -4
  2. package/dist/commands/execute/team.mjs.map +1 -1
  3. package/dist/commands/lifecycle/install.mjs +0 -1
  4. package/dist/commands/lifecycle/install.mjs.map +1 -1
  5. package/docs/architecture.md +3 -3
  6. package/docs/cli.md +1 -1
  7. package/docs/project-structure-overview.md +2 -2
  8. package/package.json +1 -1
  9. package/runtime/BUILD.json +2 -2
  10. package/runtime/agents/workers/report-writer-worker.md +1 -1
  11. package/runtime/bin/okstra-compact-reminder.sh +2 -2
  12. package/runtime/bin/okstra-render-report-views.py +13 -10
  13. package/runtime/prompts/lead/okstra-lead-contract.md +1 -1
  14. package/runtime/prompts/profiles/_common-contract.md +1 -1
  15. package/runtime/python/okstra_ctl/adapters/hosts/claude-code/relay.md +5 -6
  16. package/runtime/python/okstra_ctl/adapters/providers/antigravity/adapter.py +23 -1
  17. package/runtime/python/okstra_ctl/agent_invocation.py +17 -0
  18. package/runtime/python/okstra_ctl/agent_prompt_cli.py +13 -0
  19. package/runtime/python/okstra_ctl/dispatch_core.py +96 -17
  20. package/runtime/python/okstra_ctl/dispatch_state.py +57 -0
  21. package/runtime/python/okstra_ctl/model_cli.py +11 -2
  22. package/runtime/python/okstra_ctl/model_discovery.py +12 -0
  23. package/runtime/python/okstra_ctl/pane_reclaim.py +49 -43
  24. package/runtime/python/okstra_ctl/render.py +53 -0
  25. package/runtime/python/okstra_ctl/report_html/render.py +7 -4
  26. package/runtime/python/okstra_ctl/run.py +88 -2
  27. package/runtime/python/okstra_ctl/team.py +84 -14
  28. package/runtime/python/okstra_ctl/tmux.py +2 -3
  29. package/runtime/python/okstra_ctl/worker_runner.py +2 -2
  30. package/runtime/python/okstra_ctl/write_policy.py +9 -1
  31. package/runtime/schemas/final-report-v2.0.schema.json +3 -1
  32. package/runtime/skills/okstra-run/SKILL.md +3 -3
  33. package/runtime/templates/reports/html/base.template.html +1 -2
  34. package/runtime/validators/validate-report-views.py +30 -17
  35. package/runtime/validators/validate-run.py +123 -78
  36. package/runtime/validators/validate_session_conformance.py +1 -1
  37. package/runtime/bin/okstra-trace-cleanup.sh +0 -185
@@ -51,6 +51,10 @@ from okstra_ctl.conformance import ( # noqa: E402
51
51
  qa_result_from_dict,
52
52
  validate_conformance_manifest,
53
53
  )
54
+ from okstra_ctl.dispatch_state import ( # noqa: E402
55
+ DispatchError,
56
+ v2_worker_state_key,
57
+ )
54
58
  from okstra_ctl.paths import RunRef # noqa: E402
55
59
  from okstra_ctl.report_contract import CURRENT_REPORT_SCHEMA_VERSION # noqa: E402
56
60
  from okstra_ctl.release_gate import ( # noqa: E402
@@ -862,6 +866,12 @@ def extract_contract(
862
866
  required_worker_roles = []
863
867
  failures.append("requiredWorkerRoles is missing from run/task manifest")
864
868
 
869
+ optional_worker_roles = run_contract.get("optionalWorkerRoles")
870
+ if not isinstance(optional_worker_roles, list):
871
+ optional_worker_roles = task_contract.get("optionalWorkerRoles")
872
+ if not isinstance(optional_worker_roles, list):
873
+ optional_worker_roles = []
874
+
865
875
  lead_role = (
866
876
  run_contract.get("leadRole")
867
877
  or task_contract.get("leadRole")
@@ -892,6 +902,7 @@ def extract_contract(
892
902
  or ""
893
903
  ),
894
904
  "required_worker_roles": required_worker_roles,
905
+ "optional_worker_roles": optional_worker_roles,
895
906
  "required_agent_status_entries": [
896
907
  item
897
908
  for item in required_agent_status_entries
@@ -1216,6 +1227,24 @@ def _is_legal_concurrent_run_skip(
1216
1227
  )
1217
1228
 
1218
1229
 
1230
+ def _dispatch_roster_key(row: Mapping[str, Any]) -> str:
1231
+ """The roster worker this dispatch row started, v1 or v2.
1232
+
1233
+ A v2 row carries no `workerId` — `_validate_agent_dispatch_contract`
1234
+ fails the run when one is present, calling it a v1/v2 identity mix. So
1235
+ reading the roster key off that field alone left every v2 row invisible
1236
+ and reported workers okstra had in fact started as never dispatched. The
1237
+ v2 projection is the one dispatch itself uses.
1238
+ """
1239
+ worker_id = str(row.get("workerId", "")).strip()
1240
+ if worker_id:
1241
+ return worker_id
1242
+ try:
1243
+ return v2_worker_state_key(row)
1244
+ except DispatchError:
1245
+ return ""
1246
+
1247
+
1219
1248
  def _validate_cmux_workers_were_dispatched_by_okstra(
1220
1249
  team_state: dict,
1221
1250
  workers: list,
@@ -1243,9 +1272,11 @@ def _validate_cmux_workers_were_dispatched_by_okstra(
1243
1272
  if str(adapter.get("name", "")).strip() != "cmux":
1244
1273
  return
1245
1274
  recorded = {
1246
- str(row.get("workerId", "")).strip()
1275
+ key
1247
1276
  for row in team_state.get("workerDispatches") or []
1248
1277
  if isinstance(row, dict)
1278
+ for key in (_dispatch_roster_key(row),)
1279
+ if key
1249
1280
  }
1250
1281
  missing = []
1251
1282
  for worker in workers:
@@ -1447,7 +1478,15 @@ def validate_team_state(
1447
1478
  if status != "completed" and not reason:
1448
1479
  failures.append(f"{role} with status `{status}` must include a reason")
1449
1480
 
1450
- unexpected_roles = set(by_role) - set(expected_workers)
1481
+ # A declared optional role may appear in the roster and may equally be
1482
+ # absent: the run states which ones it can dispatch (today, the critics),
1483
+ # and running one is not a contract violation.
1484
+ optional_roles = {
1485
+ str(worker.get("role", "")).strip()
1486
+ for worker in contract.get("optional_worker_roles", [])
1487
+ if isinstance(worker, dict) and str(worker.get("role", "")).strip()
1488
+ }
1489
+ unexpected_roles = set(by_role) - set(expected_workers) - optional_roles
1451
1490
  for role in sorted(unexpected_roles):
1452
1491
  failures.append(f"unexpected worker role detected: {role}")
1453
1492
 
@@ -1621,60 +1660,46 @@ def _scan_token_usage_summary(
1621
1660
  # a section heading line (not as inline text inside a paragraph or table).
1622
1661
  _VERDICT_CARD_HEADING_RE = re.compile(r"^##[ \t]+Verdict Card\b", re.MULTILINE)
1623
1662
 
1624
- _V2_AI_HANDOFF_HEADINGS = (
1625
- "## AI Handoff Summary",
1626
- "## Clarification and User Decisions",
1627
- "## Evidence Ledger",
1628
- "## Task Deliverable:",
1629
- "## Cross Verification Audit",
1630
- "## Execution Audit",
1631
- "## Token and Cost Audit",
1632
- )
1633
-
1634
-
1635
- # `humanSummary` / `userNarrative` belong to the human HTML. The AI Markdown
1636
- # renders field names as headings or bold labels
1637
- # (scripts/okstra_ctl/report_markdown.py `humanise`), so this matches the two
1638
- # forms the renderer can emit rather than the raw JSON key — a key name in
1639
- # quotes no longer appears anywhere in the Markdown. Anchored to the start of a
1640
- # line so the same words inside worker prose are not a violation.
1641
- _V2_HUMAN_ONLY_RENDERED_RE = re.compile(
1642
- r"^(?:#{2,6}[ \t]+(?P<heading>Human Summary|User Narrative)[ \t]*$"
1643
- r"|[ \t]*-[ \t]+\*\*(?P<label>Human Summary|User Narrative)\*\*[ \t]*:)",
1644
- re.MULTILINE,
1645
- )
1646
-
1647
-
1648
- def _validate_v2_ai_handoff(content: str, failures: list[str]) -> None:
1649
- positions: list[int] = []
1650
- for heading in _V2_AI_HANDOFF_HEADINGS:
1651
- count = content.count(heading)
1652
- if count != 1:
1663
+ def _validate_v2_report(
1664
+ report_data: Mapping[str, Any],
1665
+ required_agent_status_entries: list[str],
1666
+ failures: list[str],
1667
+ ) -> None:
1668
+ """Contract checks for a schema-v2 report, read from its data.json.
1669
+
1670
+ The AI-handoff markdown is a deterministic rendering of this record, so the
1671
+ checks that used to scan it — heading count and order, human-only fields
1672
+ leaking into the AI artifact, a Reading Confirmation heading — were asking
1673
+ whether the renderer had done its job, not whether the report was sound.
1674
+ Schema enforcement in `render_final_report._enforce_schema` already answers
1675
+ the second question, and the first is settled by the template. What is left
1676
+ is the pair of facts the markdown could only ever carry second-hand: which
1677
+ agents the run must account for, and whether the token cells were filled.
1678
+
1679
+ Both are scanned over the serialized record, as text, because that is what
1680
+ the markdown scan was. Requiring each label to equal an
1681
+ `executionStatus[].role` would be a stricter rule than the contract states
1682
+ anywhere: the schema types `role` as a free string, no prompt or worker spec
1683
+ fixes its vocabulary, and `okstra_token_usage.report._match_worker_index`
1684
+ treats function-role spellings ("Analysis verifier", "Acceptance critic") as
1685
+ a shape reports do take — matching them by containment, never equality.
1686
+ Tightening this belongs with an authoring rule that says what to write, not
1687
+ on its own. Nothing is lost by reading the record instead of its rendering:
1688
+ the labels never come from the template or the i18n dictionaries.
1689
+ """
1690
+ serialized = json.dumps(report_data, ensure_ascii=False)
1691
+ for label in required_agent_status_entries:
1692
+ if label not in serialized:
1653
1693
  failures.append(
1654
- "schema-v2 AI handoff markdown requires exactly one "
1655
- f"`{heading}` heading; found {count}."
1694
+ f"final report does not include required agent status entry: {label}"
1656
1695
  )
1657
- continue
1658
- positions.append(content.index(heading))
1659
- if len(positions) == len(_V2_AI_HANDOFF_HEADINGS) and positions != sorted(
1660
- positions
1661
- ):
1662
- failures.append(
1663
- "schema-v2 AI handoff markdown heading order does not match "
1664
- "templates/reports/final-report-v2.template.md."
1665
- )
1666
- for match in _V2_HUMAN_ONLY_RENDERED_RE.finditer(content):
1667
- field = match.group("heading") or match.group("label")
1668
- failures.append(
1669
- "schema-v2 AI handoff markdown contains human-only field "
1670
- f"{field!r}; render it only in the task-specific HTML."
1671
- )
1672
- if READING_CONFIRMATION_HEADING_RE.search(content) is not None:
1673
- failures.append(
1674
- "final report contains a `## 0. Reading Confirmation` heading — "
1675
- "Reading Confirmation lives in the worker audit sidecar, never "
1676
- "in the AI handoff markdown."
1677
- )
1696
+ for placeholder in TOKEN_PLACEHOLDERS:
1697
+ if placeholder in serialized:
1698
+ failures.append(
1699
+ f"final report contains unsubstituted token placeholder `{placeholder}` — "
1700
+ "run `okstra-token-usage.py ... --substitute-data <report-path>` during Phase 7"
1701
+ )
1702
+
1678
1703
 
1679
1704
  # Top-of-report Index block. The renderer
1680
1705
  # (scripts/okstra_ctl/render_final_report.py) injects `<a id="report-index">`
@@ -1782,16 +1807,34 @@ def _load_conformance_results(qa_dir: Path, manifest: dict) -> dict:
1782
1807
  return results
1783
1808
 
1784
1809
 
1785
- _DIFF_SUMMARY_RE = re.compile(r"^###\s+5\.7\.3\b.*?(?=^###\s|\Z)", re.MULTILINE | re.DOTALL)
1786
- _DIFF_ROW_PATH_RE = re.compile(r"^\|\s*`([^`]+)`\s*\|", re.MULTILINE)
1810
+ def _diff_summary_files(report_data: Mapping[str, Any]) -> list[str]:
1811
+ """implementation 리포트가 신고한 변경 파일 목록 (`implementation.diffSummary.files[].file`).
1787
1812
 
1813
+ 렌더된 §5.7.3 표를 정규식으로 긁던 자리다. 표는 data.json 의 이 배열에서
1814
+ 렌더되는 파생물이라, 표를 읽는 쪽은 렌더 형식이 바뀔 때마다 조용히 빈
1815
+ 목록을 돌려주고 — conformance / self-mock 두 게이트가 전부 통과로 열렸다.
1816
+ 스키마가 `implementation` 블록에서 `diffSummary` 를 required 로 잡고
1817
+ `rawStat` 이 비어있지 않으면 `files` 최소 1행을 요구하므로, 여기서는
1818
+ 구조가 어긋난 경우만 빈 목록으로 떨어뜨린다.
1788
1819
 
1789
- def _parse_diff_summary_files(content: str) -> list[str]:
1790
- """implementation 리포트 §5.7.3 Diff Summary 표의 첫 셀(백틱 경로)들을 추출."""
1791
- section = _DIFF_SUMMARY_RE.search(content)
1792
- if section is None:
1820
+ `diffSummary` 를 가진 task-type 은 implementation 뿐이다. final-verification
1821
+ 은 diff 를 `diffSummaryQuote` 문자열로만 인용하므로 두 게이트는 거기서
1822
+ (md 를 읽던 시절과 똑같이) vacuous 하다.
1823
+ """
1824
+ implementation = report_data.get("implementation")
1825
+ if not isinstance(implementation, Mapping):
1826
+ return []
1827
+ diff_summary = implementation.get("diffSummary")
1828
+ if not isinstance(diff_summary, Mapping):
1793
1829
  return []
1794
- return _DIFF_ROW_PATH_RE.findall(section.group(0))
1830
+ rows = diff_summary.get("files")
1831
+ if not isinstance(rows, list):
1832
+ return []
1833
+ return [
1834
+ row["file"]
1835
+ for row in rows
1836
+ if isinstance(row, Mapping) and isinstance(row.get("file"), str) and row["file"]
1837
+ ]
1795
1838
 
1796
1839
 
1797
1840
  _STAGE_RUN_DIR_RE = re.compile(r"^stage-\d+$")
@@ -2155,12 +2198,12 @@ def _validate_planning_conformance_declared(report_path: Path, failures: list[st
2155
2198
 
2156
2199
 
2157
2200
  def _validate_conformance_surfaces(
2158
- report_path: Path,
2201
+ report_data: Mapping[str, Any],
2159
2202
  scoped_manifest: dict,
2160
2203
  surface_patterns: object,
2161
2204
  failures: list[str],
2162
2205
  ) -> None:
2163
- changed_files = _parse_diff_summary_files(report_path.read_text(encoding="utf-8"))
2206
+ changed_files = _diff_summary_files(report_data)
2164
2207
  if not changed_files:
2165
2208
  return
2166
2209
  uncovered = (
@@ -2196,6 +2239,7 @@ def _validate_conformance(
2196
2239
  own stage suffix. Whole-task runs evaluate every entry.
2197
2240
  """
2198
2241
  warnings: list[str] = []
2242
+ report_data = _load_final_report_data(report_path)
2199
2243
  # conformance 산출물은 task-level(<task_root>/qa)에 있어 planning/
2200
2244
  # implementation/final-verification 가 공유한다. report_path 는
2201
2245
  # task_root/runs/<task-type>/reports/final-report.md (implementation 은
@@ -2236,7 +2280,7 @@ def _validate_conformance(
2236
2280
  f"{manifest_path} is absent"
2237
2281
  )
2238
2282
  _validate_conformance_surfaces(
2239
- report_path,
2283
+ report_data,
2240
2284
  empty_scoped_manifest,
2241
2285
  surface_patterns,
2242
2286
  failures,
@@ -2300,7 +2344,7 @@ def _validate_conformance(
2300
2344
  f"docs/superpowers/specs/2026-06-07-stage-conformance-qa-design.md."
2301
2345
  )
2302
2346
  _validate_conformance_surfaces(
2303
- report_path,
2347
+ report_data,
2304
2348
  scoped,
2305
2349
  surface_patterns,
2306
2350
  failures,
@@ -2638,13 +2682,13 @@ def _validate_selfmock(report_path: Path, failures: list[str]) -> None:
2638
2682
  Stage-isolated runs read their own `self-mock-stage-<N>.json`; whole-task runs
2639
2683
  read the flat `self-mock.json`.
2640
2684
 
2641
- Only the implementation template renders the §5.7.3 diff summary
2642
- (`templates/reports/final-report-v2.template.md:589`); a final-verification report
2643
- quotes the diff as a blockquote instead, so this gate is vacuous there by
2644
- design — self-mock is enforced at the implementation stage, and
2645
- final-verification is a read-only re-verify that adds no test files.
2685
+ Only an implementation report carries `implementation.diffSummary.files[]`;
2686
+ a final-verification report records the diff as the `diffSummaryQuote`
2687
+ string instead, so this gate is vacuous there by design — self-mock is
2688
+ enforced at the implementation stage, and final-verification is a read-only
2689
+ re-verify that adds no test files.
2646
2690
  """
2647
- changed = _parse_diff_summary_files(report_path.read_text(encoding="utf-8"))
2691
+ changed = _diff_summary_files(_load_final_report_data(report_path))
2648
2692
  test_files = [
2649
2693
  path
2650
2694
  for path in changed
@@ -2745,8 +2789,8 @@ def _check_selfmock_changed_files(
2745
2789
  `--changed-file` is what the mutation adapters select their production
2746
2790
  sources from. Omit it and every adapter gets an empty target set, which is
2747
2791
  not a finding but reads like one had been looked for. Requiring the sidecar
2748
- to account for every file in the §5.7.3 diff summary keeps "gate B saw this
2749
- stage" apart from "gate B was handed nothing".
2792
+ to account for every file in `implementation.diffSummary.files[]` keeps
2793
+ "gate B saw this stage" apart from "gate B was handed nothing".
2750
2794
 
2751
2795
  Coverage is asked of the WHOLE diff, not just its test files: the production
2752
2796
  sources are precisely the part gate A never looks at.
@@ -2771,7 +2815,8 @@ def _check_selfmock_changed_files(
2771
2815
  f"the mutation gate — {sidecar} records changedFiles={declared}. Each "
2772
2816
  "adapter picks its production sources out of that set, so a file left "
2773
2817
  "out is a file no mutant was ever generated for. Pass every path from "
2774
- "the §5.7.3 diff summary with `--changed-file`, production sources "
2818
+ "the report's `implementation.diffSummary.files[]` with `--changed-file`, "
2819
+ "production sources "
2775
2820
  "included — filtering to the test files leaves gate B nothing to run "
2776
2821
  "on and reports a pass it never earned."
2777
2822
  )
@@ -2835,6 +2880,10 @@ def validate_report(
2835
2880
  failures.append(f"final report is missing: {report_path}")
2836
2881
  return
2837
2882
 
2883
+ if (report_data or {}).get("schemaVersion") == "2.0":
2884
+ _validate_v2_report(report_data or {}, required_agent_status_entries, failures)
2885
+ return
2886
+
2838
2887
  content = report_path.read_text()
2839
2888
  for label in required_agent_status_entries:
2840
2889
  if label not in content:
@@ -2849,10 +2898,6 @@ def validate_report(
2849
2898
  "run `okstra-token-usage.py ... --substitute-data <report-path>` during Phase 7"
2850
2899
  )
2851
2900
 
2852
- if (report_data or {}).get("schemaVersion") == "2.0":
2853
- _validate_v2_ai_handoff(content, failures)
2854
- return
2855
-
2856
2901
  # Catch the "workers typed `0` / `pending` instead of the placeholder"
2857
2902
  # failure mode that bypasses the placeholder check above.
2858
2903
  _scan_token_usage_summary(
@@ -1034,7 +1034,7 @@ def _check_batch_cleanup_checkpoints(
1034
1034
  실제로 일어났는지 (prompts/profiles/_common-contract.md 'Phase-start cleanup').
1035
1035
  R1=convergence round 1 직전, R2=report-writer dispatch 직전(수렴이 있었으면
1036
1036
  마지막 라운드 이후 별도 1회). ISO-8601 ts 는 lexicographic 비교가 곧 시간순."""
1037
- detail = "prompts/profiles/_common-contract.md 'Phase-start cleanup'"
1037
+ detail = "prompts/lead/okstra-lead-contract.md 'Run-scoped worker-resource lifecycle'"
1038
1038
  cleanup_ts = sorted(ts for ts, _line in by_phase.get("phase-batch-cleanup", []))
1039
1039
  conv_ts = sorted(ts for ts, _line in by_phase.get("phase-5.5-convergence", []))
1040
1040
  synth_ts = sorted(ts for ts, _line in by_phase.get("phase-6-synthesis", []))
@@ -1,185 +0,0 @@
1
- #!/usr/bin/env bash
2
- #
3
- # okstra-trace-cleanup.sh — close the harness-owned worker panes of an okstra run.
4
- #
5
- # The panes this closes are the teammate panes the HARNESS splits for dispatched
6
- # worker agents. okstra does not create them and cannot tag them, so they are
7
- # found by a title allowlist scoped to the lead's window. They accumulate until
8
- # the pane budget runs out, and `shutdown_request` only idles the agent without
9
- # freeing its pane — this script is the only thing that closes them, which is why
10
- # the lead calls it at every round boundary and before every user gate.
11
- #
12
- # Two tag-driven paths used to live here and both are gone:
13
- #
14
- # - Worker-compute panes of the `tmux-pane` dispatch backend, tagged
15
- # `@okstra_worker_run`. That backend was removed — a worker now gets a cmux
16
- # surface or a cli-wrapper subprocess, and neither is a tmux pane okstra owns.
17
- # - Trace panes, tagged `@okstra_trace_run` / `@okstra_status`, which the
18
- # provider wrappers used to split as `tail -F` siblings. The wrappers became
19
- # four-line entrypoints and worker progress renders into the worker's own
20
- # pane, so nothing had spawned a trace pane for some time.
21
- #
22
- # With both writers gone the tag scan matched nothing, so it was removed along
23
- # with the two modes that existed only to drive it (`--reap`, which the SessionEnd
24
- # hook called, and `--reclaim-completed`, which `okstra-subagent-reclaim.sh` drove
25
- # from SubagentStop / TaskCompleted). Those hooks are gone from the seeded
26
- # settings too.
27
- #
28
- # Invocation:
29
- #
30
- # --run-dir <RUN_DIR> Closes the run's worker-agent panes (`claude-worker` /
31
- # `codex-worker` / `antigravity-worker` /
32
- # `report-writer-worker` and the FleetView teammate
33
- # prefixes), scoped to the LEAD's WINDOW. The lead pane is
34
- # read from `<RUN_DIR>/state/lead-pane.id` (recorded once
35
- # by the lead in its own foreground pane — reliable even
36
- # though Claude Code's Bash tool strips `$TMUX` /
37
- # `$TMUX_PANE`); it scopes the scan and is NEVER killed.
38
- # Window scope keeps a second lead running in another
39
- # window of the same session out of range.
40
- #
41
- # `--list` (alias `--dry-run`) prints `<pane_id>\t<pane_title>` per pane instead
42
- # of killing.
43
- #
44
- # `--keep <substr>` (repeatable) spares any pane whose current title contains
45
- # <substr>, in both the kill and the list set. Used to preserve an in-flight
46
- # report-writer at a round boundary.
47
- #
48
- # Failures are tolerated silently — a stale pane id, no tmux, or a locked tmux
49
- # client must never prevent Claude from exiting cleanly.
50
-
51
- set -u
52
-
53
- # Caller-pane resolution helper (okstra_resolve_caller_pane) — see
54
- # lib/okstra/tmux-pane.sh. Used as the lead-pane fallback below so a missing /
55
- # stale lead-pane.id resolves to the pane THIS process actually runs in (via
56
- # ancestor-PID ↔ tmux pane_pid matching), never a foreign active-client pane.
57
- # Bin-sibling path in repo + installed layouts; degrade silently if absent.
58
- _clean_script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)"
59
- [ -r "$_clean_script_dir/lib/okstra/tmux-pane.sh" ] && . "$_clean_script_dir/lib/okstra/tmux-pane.sh"
60
-
61
- MODE="kill" # kill | list
62
- run_dir=""
63
- KEEP_PATTERNS=() # --keep <substr>: panes whose title contains substr are spared from kill/list
64
- while [[ $# -gt 0 ]]; do
65
- case "$1" in
66
- --list|--dry-run) MODE="list" ;;
67
- --run-dir) shift; run_dir="${1-}" ;;
68
- --run-dir=*) run_dir="${1#--run-dir=}" ;;
69
- --keep) shift; KEEP_PATTERNS+=("${1-}") ;;
70
- --keep=*) KEEP_PATTERNS+=("${1#--keep=}") ;;
71
- -h|--help)
72
- cat <<'USAGE'
73
- usage: okstra-trace-cleanup.sh --run-dir <RUN_DIR> [--list] [--keep <substr>]...
74
-
75
- --run-dir okstra run directory; closes that run's worker-agent panes.
76
- --list print "<pane_id>\t<pane_title>" per pane; no kill.
77
- --dry-run alias for --list.
78
- --keep <substr> exclude any pane whose title contains <substr> from the
79
- kill/list set (repeatable). Used to spare an in-flight
80
- report-writer at a round boundary.
81
- USAGE
82
- exit 0 ;;
83
- *)
84
- printf 'okstra-trace-cleanup.sh: unknown option: %s\n' "$1" >&2
85
- exit 2 ;;
86
- esac
87
- shift
88
- done
89
-
90
- if [[ -z "$run_dir" ]]; then
91
- printf 'okstra-trace-cleanup.sh: --run-dir <RUN_DIR> is required\n' >&2
92
- exit 2
93
- fi
94
-
95
- # Lead pane. Prefer the value the lead recorded in its own foreground pane; fall
96
- # back to the active-pane probe. Rejected if the recorded pane is gone.
97
- lead_pane=""
98
- lead_pane_file="$run_dir/state/lead-pane.id"
99
- [[ -r "$lead_pane_file" ]] && lead_pane="$(head -n1 "$lead_pane_file" 2>/dev/null || true)"
100
- if [[ -z "$lead_pane" ]] || ! tmux display-message -p -t "$lead_pane" '#{pane_id}' >/dev/null 2>&1; then
101
- if type okstra_resolve_caller_pane >/dev/null 2>&1; then
102
- lead_pane="$(okstra_resolve_caller_pane 2>/dev/null || true)"
103
- else
104
- lead_pane="$(tmux display-message -p '#{pane_id}' 2>/dev/null || true)"
105
- fi
106
- fi
107
-
108
- _title_in_okstra_scope() {
109
- local title="$1"
110
- case "$title" in
111
- # Claude/FleetView teammate panes may carry the task title instead of the
112
- # worker id. These glyph prefixes are the only stable tmux-side signal.
113
- "✳ "*|"⠂ "*)
114
- return 0 ;;
115
- *claude-worker*|*claude-executor*|*claude-verifier*)
116
- return 0 ;;
117
- *codex-worker*|*codex-executor*|*codex-verifier*)
118
- return 0 ;;
119
- *agy-worker*|*agy-executor*|*agy-verifier*)
120
- return 0 ;;
121
- *antigravity-worker*|*antigravity-executor*|*antigravity-verifier*)
122
- return 0 ;;
123
- *report-writer-worker*|*report-writer*)
124
- return 0 ;;
125
- esac
126
- return 1
127
- }
128
-
129
- # A collected pane whose current title contains any --keep substring is spared.
130
- _keep_excluded() {
131
- local pid="$1" title pat
132
- (( ${#KEEP_PATTERNS[@]} )) || return 1
133
- title=$(tmux display-message -p -t "$pid" '#{pane_title}' 2>/dev/null || true)
134
- for pat in "${KEEP_PATTERNS[@]}"; do
135
- [[ -n "$pat" && "$title" == *"$pat"* ]] && return 0
136
- done
137
- return 1
138
- }
139
-
140
- collect_okstra_panes() {
141
- local -a panes=()
142
- local pid title
143
-
144
- # Title-allowlisted worker-agent panes in the lead's WINDOW. `list-panes -t
145
- # <pane>` (no `-s`, no `-a`) resolves the window containing that pane and lists
146
- # only that window's panes. Split-pane teammates always land in the lead's
147
- # window, so this catches all of THIS run's worker panes while a second okstra
148
- # lead in another window of the same tmux session is left untouched. Skipped
149
- # when the lead pane is unknown.
150
- [[ -n "$lead_pane" ]] || return 0
151
- while IFS=$'\t' read -r pid title; do
152
- [[ -n "$pid" ]] || continue
153
- [[ "$pid" == "$lead_pane" ]] && continue
154
- if _title_in_okstra_scope "$title"; then
155
- panes+=("$pid")
156
- fi
157
- done < <(tmux list-panes -t "$lead_pane" \
158
- -F '#{pane_id}'$'\t''#{pane_title}' 2>/dev/null || true)
159
-
160
- # Drop any pane a --keep pattern spares (in-flight report-writer).
161
- if (( ${#panes[@]} )); then
162
- printf '%s\n' "${panes[@]}" | awk 'NF && !seen[$0]++' | while IFS= read -r _pid; do
163
- _keep_excluded "$_pid" && continue
164
- printf '%s\n' "$_pid"
165
- done
166
- fi
167
- }
168
-
169
- if [[ "$MODE" == "list" ]]; then
170
- while IFS= read -r pane_id; do
171
- [[ -n "$pane_id" ]] || continue
172
- # `display-message -p` resolves a *live* pane's title; for stale ids tmux
173
- # exits non-zero — fall back to an empty title rather than failing.
174
- title=$(tmux display-message -p -t "$pane_id" '#{pane_title}' 2>/dev/null || true)
175
- printf '%s\t%s\n' "$pane_id" "$title"
176
- done < <(collect_okstra_panes)
177
- exit 0
178
- fi
179
-
180
- while IFS= read -r pane_id; do
181
- [[ -n "$pane_id" ]] || continue
182
- tmux kill-pane -t "$pane_id" 2>/dev/null || true
183
- done < <(collect_okstra_panes)
184
-
185
- exit 0