okstra 0.173.0 → 0.174.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 (62) hide show
  1. package/docs/architecture/storage-model.md +13 -3
  2. package/docs/architecture.md +5 -21
  3. package/docs/cli.md +3 -2
  4. package/docs/container.md +1 -1
  5. package/docs/contributor-change-matrix.md +1 -1
  6. package/docs/project-structure-overview.md +13 -13
  7. package/docs/task-process/README.md +1 -1
  8. package/docs/task-process/implementation-planning.md +1 -1
  9. package/package.json +1 -1
  10. package/runtime/BUILD.json +2 -2
  11. package/runtime/agents/workers/claude-worker.md +1 -1
  12. package/runtime/bin/lib/okstra/globals.sh +1 -1
  13. package/runtime/bin/okstra-provider-exec.py +29 -12
  14. package/runtime/bin/okstra-trace-cleanup.sh +58 -129
  15. package/runtime/prompts/lead/adapters/cmux.md +2 -0
  16. package/runtime/prompts/lead/okstra-lead-contract.md +1 -1
  17. package/runtime/prompts/lead/plan-body-verification.md +3 -3
  18. package/runtime/prompts/lead/report-writer.md +6 -6
  19. package/runtime/prompts/profiles/_common-contract.md +2 -2
  20. package/runtime/prompts/profiles/_implementation-executor.md +2 -0
  21. package/runtime/prompts/profiles/_implementation-verifier.md +2 -2
  22. package/runtime/prompts/profiles/error-analysis.md +1 -1
  23. package/runtime/prompts/profiles/implementation-planning.md +12 -9
  24. package/runtime/prompts/profiles/implementation.md +2 -1
  25. package/runtime/prompts/profiles/release-handoff.md +1 -1
  26. package/runtime/python/okstra_ctl/adapters/dispatch/__init__.py +1 -6
  27. package/runtime/python/okstra_ctl/adapters/hosts/external/relay.md +4 -4
  28. package/runtime/python/okstra_ctl/adapters/providers/claude/adapter.py +5 -0
  29. package/runtime/python/okstra_ctl/conformance.py +68 -0
  30. package/runtime/python/okstra_ctl/dispatch_core.py +89 -39
  31. package/runtime/python/okstra_ctl/dispatch_state.py +142 -14
  32. package/runtime/python/okstra_ctl/doctor.py +2 -2
  33. package/runtime/python/okstra_ctl/domain/worker_exec.py +5 -0
  34. package/runtime/python/okstra_ctl/final_report_schema.py +5 -4
  35. package/runtime/python/okstra_ctl/pane_reclaim.py +13 -22
  36. package/runtime/python/okstra_ctl/render_final_report.py +15 -19
  37. package/runtime/python/okstra_ctl/report_contract.py +0 -1
  38. package/runtime/python/okstra_ctl/report_finalize.py +68 -9
  39. package/runtime/python/okstra_ctl/run.py +43 -2
  40. package/runtime/python/okstra_ctl/schema_excerpt.py +1 -1
  41. package/runtime/python/okstra_ctl/scope_provenance.py +1 -1
  42. package/runtime/python/okstra_ctl/session.py +69 -12
  43. package/runtime/python/okstra_ctl/team.py +51 -25
  44. package/runtime/python/okstra_ctl/tmux.py +19 -149
  45. package/runtime/python/okstra_ctl/worker_request.py +2 -0
  46. package/runtime/python/okstra_ctl/worktree.py +69 -3
  47. package/runtime/python/okstra_token_usage/cli.py +1 -1
  48. package/runtime/python/okstra_token_usage/collect.py +66 -6
  49. package/runtime/skills/okstra-setup/references/project-config.md +11 -0
  50. package/runtime/templates/reports/settings.template.json +0 -24
  51. package/runtime/validators/lib/fixtures.sh +49 -17
  52. package/runtime/validators/validate-implementation-plan-stages.py +63 -3
  53. package/runtime/validators/validate-run.py +14 -473
  54. package/runtime/validators/validate_session_conformance.py +1 -1
  55. package/src/cli-registry.mjs +8 -1
  56. package/src/commands/execute/team.mjs +3 -3
  57. package/src/commands/execute/worktree-status.mjs +109 -0
  58. package/src/commands/lifecycle/install.mjs +0 -2
  59. package/src/commands/report/finalize.mjs +13 -6
  60. package/runtime/bin/okstra-subagent-reclaim.sh +0 -26
  61. package/runtime/schemas/final-report-v1.0.schema.json +0 -6366
  62. package/runtime/templates/reports/final-report.template.md +0 -1258
@@ -1,9 +1,9 @@
1
1
  """Neutral okstra team CLI for pane-backed worker dispatch.
2
2
 
3
- Outside cmux this is the door onto tmux panes for hosts whose descriptor owns
4
- the team lifecycle. Under cmux it is every lead's door onto cmux surfaces,
5
- because okstra owns the panes there rather than the host. Which backend a run
6
- uses is read from its run manifest.
3
+ Under cmux this is every lead's door onto cmux surfaces, because okstra owns the
4
+ panes there rather than the host. Outside cmux a worker owns no pane at all — it
5
+ runs as a cli-wrapper subprocess — so there is nothing for teardown to reclaim.
6
+ Which backend a run uses is read from its run manifest.
7
7
  """
8
8
  from __future__ import annotations
9
9
 
@@ -15,14 +15,13 @@ from pathlib import Path
15
15
  from typing import Any, Mapping, Sequence
16
16
 
17
17
  from . import cmux
18
- from . import tmux
19
18
  from .adapters.dispatch import provider_worker_wrappers
20
19
  from .adapters.dispatch.cmux import dispatch_port_for_terminal_backend
21
20
  from .application.dispatch_assignments import dispatch_assignments
22
21
  from .dispatch_state import TEARDOWN_BEFORE_TERMINAL_REASON, mutate_team_state
23
22
  from .dispatch_core import (
23
+ BACKEND_CLI_WRAPPER,
24
24
  BACKEND_CMUX_PANE,
25
- BACKEND_TMUX_PANE,
26
25
  DispatchError,
27
26
  DispatchPlan,
28
27
  await_dispatches,
@@ -31,6 +30,7 @@ from .dispatch_core import (
31
30
  from .ports.worker_dispatch import WorkerDispatchRequest
32
31
  from .registry.host_registry import default_host_registry
33
32
  from .registry.provider_registry import default_provider_registry
33
+ from .session import observe_lead_session
34
34
 
35
35
 
36
36
  _SUPPORTED_WRAPPERS = provider_worker_wrappers(default_provider_registry())
@@ -100,6 +100,7 @@ def _dispatch(args) -> int:
100
100
  if args.jobs_file and args.workers:
101
101
  raise DispatchError("--jobs-file and --workers cannot be combined")
102
102
  manifest = _load_manifest(args.project_root, args.run_manifest)
103
+ _observe_lead_session_from_manifest(Path(args.project_root).resolve(), manifest)
103
104
  terminal_backend = _manifest_backend(manifest)
104
105
  lead_runtime = _require_string(manifest, "leadRuntime")
105
106
  fallback_port = default_host_registry().resolve(
@@ -137,6 +138,7 @@ def _dispatch(args) -> int:
137
138
  def _await(args) -> int:
138
139
  manifest = _load_manifest(args.project_root, args.run_manifest)
139
140
  _validate_team_manifest(manifest)
141
+ _observe_lead_session_from_manifest(Path(args.project_root).resolve(), manifest)
140
142
  plan = _plan_for_existing(Path(args.project_root), Path(args.workspace_root), Path(args.run_manifest), manifest)
141
143
  code = await_dispatches(
142
144
  plan,
@@ -157,21 +159,46 @@ def _teardown(args) -> int:
157
159
  project_root = Path(args.project_root).resolve()
158
160
  team_state_path = _resolve_project_path(project_root, _require_string(manifest, "teamStatePath"))
159
161
  team_state = _load_json(team_state_path, "team-state")
160
- run_dir = _resolve_project_path(project_root, _require_string(manifest, "runDirectoryPath"))
161
- panes = _reclaimable_panes(manifest, team_state, run_dir)
162
+ panes = _reclaimable_panes(manifest, team_state)
162
163
  if args.dry_run:
163
164
  _emit_teardown(args.json, panes)
164
165
  return 0
165
- reclaim = cmux.close_surface if _is_cmux_run(manifest) else tmux.kill_pane
166
- for pane in panes:
167
- reclaim(pane["paneId"])
168
166
  if _is_cmux_run(manifest):
167
+ for pane in panes:
168
+ cmux.close_surface(pane["paneId"])
169
169
  _restore_lead_width()
170
170
  _mark_teardown_errors(team_state_path)
171
171
  _emit_teardown(args.json, panes)
172
172
  return 0
173
173
 
174
174
 
175
+ def _observe_lead_session_from_manifest(
176
+ project_root: Path, manifest: Mapping[str, Any]
177
+ ) -> None:
178
+ """Collect this run's lead session generations at a phase boundary.
179
+
180
+ Resume and compaction split a lead across several session files, so the one
181
+ id written at prepare time can name a session holding no record of the run.
182
+ Dispatch and await are the boundaries a lead cannot route around, which is
183
+ why the observation hangs off them instead of a call the lead has to make.
184
+
185
+ `--dry-run` observes too, because the hook sits ahead of the dry-run return
186
+ on purpose: moving it behind would lose the generation a lead was on when a
187
+ dispatch failed. What gets appended is not a rehearsal either — that lead
188
+ really did cross this boundary, whatever the dispatch went on to do.
189
+
190
+ A manifest missing `teamStatePath` leaves nothing to observe into; that is
191
+ the command's own failure to report, not this side effect's.
192
+ """
193
+ try:
194
+ team_state_path = _resolve_project_path(
195
+ project_root, _require_string(manifest, "teamStatePath")
196
+ )
197
+ except DispatchError:
198
+ return
199
+ observe_lead_session(project_root, team_state_path)
200
+
201
+
175
202
  def _plan_for_existing(
176
203
  project_root: Path, workspace_root: Path, run_manifest_path: Path, manifest: Mapping[str, Any]
177
204
  ) -> DispatchPlan:
@@ -202,15 +229,16 @@ def _await_payload(plan: DispatchPlan, completed: bool) -> dict[str, Any]:
202
229
 
203
230
 
204
231
  def _reclaimable_panes(
205
- manifest: Mapping[str, Any], team_state: Mapping[str, Any], run_dir: Path
232
+ manifest: Mapping[str, Any], team_state: Mapping[str, Any]
206
233
  ) -> list[dict[str, str]]:
207
234
  """Everything this run owns and may close.
208
235
 
209
- Under cmux the recorded ids are the only candidates. There is no per-pane tag
210
- API to sweep with, and scanning by title would be worse than nothing: cmux
211
- labels its own agent surfaces with the same glyph okstra's tmux cleanup
212
- treats as a teammate marker, so a sweep could close the lead. Only surfaces
213
- okstra created are recorded, so only those can be closed.
236
+ The recorded ids are the only candidates. There is no per-pane tag API to
237
+ sweep with, and scanning by title would be worse than nothing: cmux labels
238
+ its own agent surfaces with the same glyph the harness uses for a teammate
239
+ pane, so a sweep could close the lead. Only surfaces okstra created are
240
+ recorded, so only those can be closed. A cli-wrapper run records no surface
241
+ at all, which is why it reclaims nothing.
214
242
  """
215
243
  seen: set[str] = set()
216
244
  panes: list[dict[str, str]] = []
@@ -219,17 +247,15 @@ def _reclaimable_panes(
219
247
  _append_pane(panes, seen, str(record.get("paneId", "")), "worker")
220
248
  if _is_cmux_run(manifest):
221
249
  return _still_open_surfaces(panes)
222
- lead_pane = tmux.resolve_caller_pane()
223
- for pane in tmux.list_run_panes(run_dir, lead_pane=lead_pane):
224
- _append_pane(panes, seen, pane.pane_id, pane.kind)
225
250
  return panes
226
251
 
227
252
 
228
253
  def _still_open_surfaces(panes: list[dict[str, str]]) -> list[dict[str, str]]:
229
254
  """The recorded surfaces cmux still shows.
230
255
 
231
- `workerDispatches` is append-only and nothing prunes it, so a surface closed
232
- at an earlier round boundary stays recorded for the rest of the run. Taking
256
+ Nothing prunes `workerDispatches` — a repeat of one `dispatchId` replaces
257
+ that row in place, and no row is ever dropped — so a surface closed at an
258
+ earlier round boundary stays recorded for the rest of the run. Taking
233
259
  the ledger as the residual set makes the run-end cleanup gate offer to close
234
260
  panes that left the screen rounds ago, on a workspace holding none.
235
261
 
@@ -294,10 +320,10 @@ def _manifest_backend(manifest: Mapping[str, Any]) -> str:
294
320
  """The backend prepare recorded for this run.
295
321
 
296
322
  Deliberately not a flag on this command: the manifest already answers it,
297
- and a flag would be a second answer free to disagree. A manifest written
298
- before the field existed reads as tmux, which is what those runs used.
323
+ and a flag would be a second answer free to disagree. A manifest with no
324
+ recorded backend reads as cli-wrapper, the pane-less path.
299
325
  """
300
- return str(manifest.get("terminalBackend") or "") or BACKEND_TMUX_PANE
326
+ return str(manifest.get("terminalBackend") or "") or BACKEND_CLI_WRAPPER
301
327
 
302
328
 
303
329
  def _is_cmux_run(manifest: Mapping[str, Any]) -> bool:
@@ -1,39 +1,32 @@
1
- """tmux command helpers for okstra-owned panes."""
1
+ """tmux command helpers for okstra-owned container and rerun sessions.
2
+
3
+ Worker dispatch does not come through here. Workers land in a cmux surface or,
4
+ outside cmux, in a cli-wrapper subprocess — neither owns a tmux pane. What is
5
+ left is the two places that use tmux purely as a way to hold a long-lived
6
+ background process: the container watcher/tail panes and `cmd-rerun.sh`'s
7
+ detached spawn.
8
+ """
2
9
  from __future__ import annotations
3
10
 
4
- import os
5
11
  import shlex
6
12
  import shutil
7
13
  import subprocess
8
- from dataclasses import dataclass
9
- from pathlib import Path
10
14
  from typing import Optional, Sequence
11
15
 
12
16
 
13
- # container watcher/tail pane 전용 태그. SessionEnd `--reap`
14
- # (scripts/okstra-trace-cleanup.sh `_tag_in_scope`)는 `@okstra_trace_run` /
15
- # `@okstra_worker_run` 만 스캔하므로, 이 태그가 붙은 pane 은 세션 종료 후에도
16
- # 생존한다 — watcher/tail 의 "세션 후 생존" 불변식의 핵심.
17
+ # container watcher/tail pane 전용 태그. 이 태그가 붙은 pane 은 세션 종료 후에도
18
+ # 생존한다 — watcher/tail 의 "세션 후 생존" 불변식이다. 예전에는 SessionEnd 의
19
+ # `okstra-trace-cleanup.sh --reap` 이 다른 태그만 스캔한다는 사실이 그 생존을
20
+ # 지탱했지만, 지금은 그 모드와 훅 자체가 없어 pane 을 세션 경계에서 회수하는
21
+ # 주체가 아예 없다. 회수는 `down` / `stop-watcher` 의 스코프 reap 뿐이다.
17
22
  CONTAINER_TAG_OPTION = "@okstra_container_run"
18
23
 
19
24
 
20
- @dataclass(frozen=True)
21
- class TmuxPane:
22
- pane_id: str
23
- title: str
24
- run_dir: str
25
- kind: str
26
-
27
-
28
25
  def _shell_quote(s: str) -> str:
29
26
  """POSIX shell 안전 인용. shlex.quote 가 모든 메타문자를 처리한다."""
30
27
  return shlex.quote(s)
31
28
 
32
29
 
33
- def _canonical_run_dir(run_dir: Path) -> str:
34
- return str(run_dir.resolve())
35
-
36
-
37
30
  def build_tmux_command(*, session_name: str, cwd: str, run_seq: int,
38
31
  argv: list, okstra_script: str,
39
32
  extra_env: Optional[dict] = None) -> list:
@@ -69,79 +62,6 @@ def run_tmux(
69
62
  )
70
63
 
71
64
 
72
- def resolve_caller_pane(start_pid: int | None = None) -> str:
73
- try:
74
- panes = run_tmux(["list-panes", "-a", "-F", "#{pane_pid} #{pane_id}"])
75
- except (OSError, subprocess.SubprocessError):
76
- return ""
77
- if panes.returncode != 0 or not panes.stdout.strip():
78
- return ""
79
- pane_by_pid = _pane_ids_by_pid(panes.stdout)
80
- pid = str(start_pid or os.getpid())
81
- for _ in range(16):
82
- if not pid or pid == "0":
83
- return ""
84
- if pid in pane_by_pid:
85
- return pane_by_pid[pid]
86
- pid = _parent_pid(pid)
87
- return ""
88
-
89
-
90
- def _pane_ids_by_pid(output: str) -> dict[str, str]:
91
- result: dict[str, str] = {}
92
- for line in output.splitlines():
93
- parts = line.split(maxsplit=1)
94
- if len(parts) == 2:
95
- result[parts[0]] = parts[1]
96
- return result
97
-
98
-
99
- def _parent_pid(pid: str) -> str:
100
- try:
101
- result = subprocess.run(
102
- ["ps", "-o", "ppid=", "-p", pid],
103
- capture_output=True,
104
- text=True,
105
- timeout=3,
106
- check=False,
107
- )
108
- except (OSError, subprocess.SubprocessError):
109
- return ""
110
- if result.returncode != 0:
111
- return ""
112
- return result.stdout.strip()
113
-
114
-
115
- def split_worker_pane(
116
- *,
117
- target_pane: str,
118
- cwd: Path,
119
- command: Sequence[str],
120
- title: str,
121
- run_dir: Path,
122
- ) -> str:
123
- result = run_tmux(
124
- [
125
- "split-window",
126
- "-h",
127
- "-P",
128
- "-F",
129
- "#{pane_id}",
130
- "-c",
131
- str(cwd),
132
- "-t",
133
- target_pane,
134
- shlex.join(command),
135
- ]
136
- )
137
- if result.returncode != 0:
138
- raise RuntimeError(result.stderr.strip() or "tmux split-window failed")
139
- pane_id = result.stdout.strip()
140
- set_pane_title(pane_id, title)
141
- tag_pane(pane_id, run_dir)
142
- return pane_id
143
-
144
-
145
65
  def new_detached_session(
146
66
  session_name: str, cwd: str, first_cmd: str | None = None
147
67
  ) -> str:
@@ -175,9 +95,9 @@ def split_container_pane(
175
95
  ) -> Optional[str]:
176
96
  """container watcher/tail pane 을 split 하고 container 전용 태그만 부착한다.
177
97
 
178
- reap 가 스캔하는 두 worker/trace 태그는 절대 부착하지 않는다 — tag_pane 의
179
- 기본 경로를 거치지 않고 CONTAINER_TAG_OPTION 으로 직접 set-option 한다.
180
- session_pane 가 빈값/None(tmux 미사용 경로)이면 raise 없이 None 반환(degrade).
98
+ reap 가 스캔하는 trace 태그는 절대 부착하지 않는다 — CONTAINER_TAG_OPTION 으로
99
+ 직접 set-option 한다. session_pane 가 빈값/None(tmux 미사용 경로)이면 raise
100
+ 없이 None 반환(degrade).
181
101
  """
182
102
  if not session_pane:
183
103
  return None
@@ -196,9 +116,9 @@ def split_container_pane(
196
116
  def tag_container_pane(pane_id: str, scope_value: str) -> None:
197
117
  """container 전용 태그(CONTAINER_TAG_OPTION)만 부착한다.
198
118
 
199
- reap 가 스캔하는 worker/trace 태그(tag_pane 기본 경로)는 절대 거치지 않는다 —
200
- 이 태그가 붙은 pane 은 세션 종료 후에도 생존하고 `down`/`stop-watcher` 의
201
- 스코프 reap 로만 회수된다. holder pane 도 이 태그로 묶어 회수 대상에 포함한다."""
119
+ reap 가 스캔하는 trace 태그는 절대 거치지 않는다 — 이 태그가 붙은 pane 은
120
+ 세션 종료 후에도 생존하고 `down`/`stop-watcher` 의 스코프 reap 로만
121
+ 회수된다. holder pane 도 이 태그로 묶어 회수 대상에 포함한다."""
202
122
  run_tmux(["set-option", "-p", "-t", pane_id, CONTAINER_TAG_OPTION, scope_value])
203
123
 
204
124
 
@@ -208,56 +128,6 @@ def set_pane_title(pane_id: str, title: str) -> None:
208
128
  raise RuntimeError(result.stderr.strip() or "tmux select-pane failed")
209
129
 
210
130
 
211
- def tag_pane(
212
- pane_id: str, run_dir: Path, *, option: str = "@okstra_worker_run"
213
- ) -> None:
214
- result = run_tmux(
215
- ["set-option", "-p", "-t", pane_id, option, _canonical_run_dir(run_dir)]
216
- )
217
- if result.returncode != 0:
218
- raise RuntimeError(result.stderr.strip() or "tmux set-option failed")
219
-
220
-
221
- def capture_pane(pane_id: str, *, last_lines: int = 200) -> str:
222
- result = run_tmux(
223
- ["capture-pane", "-p", "-t", pane_id, "-S", f"-{last_lines}"]
224
- )
225
- if result.returncode != 0:
226
- return ""
227
- return result.stdout
228
-
229
-
230
- def list_run_panes(run_dir: Path, *, lead_pane: str = "") -> list[TmuxPane]:
231
- canonical = _canonical_run_dir(run_dir)
232
- try:
233
- result = run_tmux(
234
- [
235
- "list-panes",
236
- "-a",
237
- "-F",
238
- "#{pane_id}\t#{pane_title}\t#{@okstra_worker_run}\t#{@okstra_trace_run}",
239
- ]
240
- )
241
- except (OSError, subprocess.SubprocessError):
242
- return []
243
- if result.returncode != 0:
244
- return []
245
- return _parse_run_panes(result.stdout, canonical, lead_pane)
246
-
247
-
248
- def _parse_run_panes(output: str, canonical: str, lead_pane: str) -> list[TmuxPane]:
249
- panes: list[TmuxPane] = []
250
- for line in output.splitlines():
251
- pane_id, title, worker_run, trace_run = (line.split("\t") + [""] * 4)[:4]
252
- if not pane_id or pane_id == lead_pane:
253
- continue
254
- if worker_run == canonical:
255
- panes.append(TmuxPane(pane_id, title, worker_run, "worker"))
256
- elif trace_run == canonical:
257
- panes.append(TmuxPane(pane_id, title, trace_run, "trace"))
258
- return panes
259
-
260
-
261
131
  def kill_pane(pane_id: str) -> None:
262
132
  try:
263
133
  run_tmux(["kill-pane", "-t", pane_id])
@@ -35,6 +35,7 @@ def build_request(
35
35
  worktree_path: Path | None,
36
36
  role: str,
37
37
  idle_timeout_seconds: int,
38
+ session_id: str = "",
38
39
  ) -> WorkerExecRequest:
39
40
  """One dispatch, with every value the strategies will not re-derive.
40
41
 
@@ -55,6 +56,7 @@ def build_request(
55
56
  auto_approve=True, write_scope=write_scope(root, worktree, role)
56
57
  ),
57
58
  idle_timeout_seconds=idle_timeout_seconds,
59
+ session_id=session_id,
58
60
  )
59
61
 
60
62
 
@@ -77,6 +77,27 @@ DEFAULT_WORKTREE_SYNC_DIRS: tuple[str, ...] = (
77
77
  )
78
78
 
79
79
 
80
+ # Sync dirs materialised as a REAL directory whose children are symlinked one
81
+ # by one, instead of a single symlink standing in for the whole directory.
82
+ #
83
+ # Why the split exists: git does not follow a symlink, so a symlinked directory
84
+ # is one *file* to it. A project that ignores host config by its contents
85
+ # (`.claude/*`) matches every child but never the bare `.claude` path, so the
86
+ # symlink lands in `git status` as
87
+ # `?? .claude` while the same directory is invisible in the main checkout. Any
88
+ # plan step asserting a clean worktree then fails on okstra's own provisioning.
89
+ # Linking the children instead reproduces the main checkout's shape, so
90
+ # whatever the project's ignore rules do there, they do here too.
91
+ #
92
+ # Only `.claude` qualifies. The other sync dirs are shared okstra state that
93
+ # okstra WRITES into, and a directory symlink is what makes a newly created
94
+ # top-level entry land in the main checkout rather than diverging inside the
95
+ # worktree. `.claude` is host configuration okstra reads; its one okstra write
96
+ # is the fixed `settings.local.json` child seeded by
97
+ # `_seed_worktree_settings_symlink`.
98
+ CHILD_LINKED_SYNC_DIRS: tuple[str, ...] = (".claude",)
99
+
100
+
80
101
  # Project-root-relative FILES (not dirs) symlinked from MAIN → task worktree
81
102
  # at provision time. Same symlink semantics as `DEFAULT_WORKTREE_SYNC_DIRS`:
82
103
  # every task sees the live shared file. The split exists because the original
@@ -522,16 +543,27 @@ def is_ancestor(cwd, commit: str, head: str) -> bool:
522
543
  return _git(Path(cwd), "merge-base", "--is-ancestor", commit, head).returncode == 0
523
544
 
524
545
 
525
- def is_dirty_excluding_okstra(cwd) -> bool:
526
- """True iff the worktree has changes outside okstra-owned paths.
546
+ def dirty_entries_excluding_okstra(cwd) -> list[str]:
547
+ """`git status --short` rows for changes outside okstra-owned paths.
527
548
 
528
549
  okstra-owned = `okstra_clean_gate_excludes` (e.g. `.okstra`, synced dirs)
529
550
  plus `nested_worktree_excludes` (stage worktrees nested under `cwd`).
551
+
552
+ This is the clean-worktree question every okstra gate asks, and the one a
553
+ plan step must ask through `okstra worktree-status` rather than through a
554
+ bare `git status --porcelain`: okstra provisions `.okstra` plus the synced
555
+ entries into every task worktree, so a bare status is never empty there and
556
+ an assertion built on it fails on okstra's own scaffolding.
530
557
  """
531
558
  owned = (*okstra_clean_gate_excludes(Path(cwd)), *nested_worktree_excludes(cwd))
532
559
  excludes = [f":(exclude){p}" for p in owned]
533
560
  out = _git(Path(cwd), "status", "--short", "--", ".", *excludes).stdout
534
- return bool(out.strip())
561
+ return [line for line in out.splitlines() if line.strip()]
562
+
563
+
564
+ def is_dirty_excluding_okstra(cwd) -> bool:
565
+ """True iff the worktree has changes outside okstra-owned paths."""
566
+ return bool(dirty_entries_excluding_okstra(cwd))
535
567
 
536
568
 
537
569
  class MergeError(RuntimeError):
@@ -580,6 +612,9 @@ def _link_sync_dirs(source_root: Path, worktree_path: Path) -> list[str]:
580
612
  version-controlled files.
581
613
  - Parent directories are created as needed for nested entries.
582
614
 
615
+ Entries listed in `CHILD_LINKED_SYNC_DIRS` are materialised as a real
616
+ directory of per-child symlinks instead (see that constant).
617
+
583
618
  Returns a list of human-readable notes (one per linked entry) so the
584
619
  caller can include them in the provisioning note.
585
620
  """
@@ -592,6 +627,10 @@ def _link_sync_dirs(source_root: Path, worktree_path: Path) -> list[str]:
592
627
  if dst.exists() or dst.is_symlink():
593
628
  continue
594
629
  dst.parent.mkdir(parents=True, exist_ok=True)
630
+ if rel in CHILD_LINKED_SYNC_DIRS and src.is_dir():
631
+ _link_dir_children(src, dst)
632
+ notes.append(rel)
633
+ continue
595
634
  try:
596
635
  os.symlink(src, dst)
597
636
  except FileExistsError:
@@ -600,6 +639,33 @@ def _link_sync_dirs(source_root: Path, worktree_path: Path) -> list[str]:
600
639
  return notes
601
640
 
602
641
 
642
+ def _link_dir_children(src: Path, dst: Path) -> None:
643
+ """Create `dst` as a real directory whose entries symlink to `src`'s
644
+ children, so git sees the same directory shape it sees in the main
645
+ checkout (`CHILD_LINKED_SYNC_DIRS`).
646
+
647
+ Each link points at the MAIN checkout's child rather than its resolved
648
+ target, so a child that is itself a symlink (e.g. `.claude/settings.local.json`
649
+ → `~/.okstra/templates/settings.local.json`) keeps following whatever the
650
+ main checkout currently points at. An unreadable source or a child that
651
+ cannot be linked degrades to "that child is absent in this worktree" —
652
+ provisioning is not worth failing over host config.
653
+ """
654
+ dst.mkdir(parents=True, exist_ok=True)
655
+ try:
656
+ children = sorted(src.iterdir())
657
+ except OSError:
658
+ return
659
+ for child in children:
660
+ link = dst / child.name
661
+ if link.exists() or link.is_symlink():
662
+ continue
663
+ try:
664
+ os.symlink(child, link)
665
+ except OSError:
666
+ continue
667
+
668
+
603
669
  def _link_sync_files(source_root: Path, worktree_path: Path) -> list[str]:
604
670
  """File-level counterpart to `_link_sync_dirs` (FU-V2).
605
671
 
@@ -57,7 +57,7 @@ def main() -> int:
57
57
  "from the freshly computed usageSummary, then re-render the "
58
58
  "sibling final-report markdown via the renderer. The data.json "
59
59
  "is the SSOT; the markdown is regenerated from it. "
60
- "See schemas/final-report-v1.0.schema.json for the data shape."
60
+ "See schemas/final-report-v2.0.schema.json for the data shape."
61
61
  ),
62
62
  )
63
63
  parser.add_argument(
@@ -21,6 +21,7 @@ from .antigravity import (
21
21
  )
22
22
  from .paths import claude_project_dir, utc_now
23
23
  from .pricing import antigravity_cost_usd, provider_cost_usd
24
+ from okstra_ctl.dispatch_state import worker_session_ids
24
25
  from okstra_ctl.models import provider_wrappers
25
26
  from okstra_ctl.wrapper_status import read_wrapper_status, status_path_for_prompt
26
27
 
@@ -864,22 +865,66 @@ def collect_claude_runtime_usage(
864
865
  f"lead session jsonl not found under {claude_project_dir(cwd)} (sessionId={lead_sid})"
865
866
  )
866
867
 
867
- # Workers — match by prefix and aggregate every session that belongs to
868
- # the same role (re-dispatches with `-002`, convergence `-reverify-r1`,
869
- # implementation `-executor`, report-writer `-impl` / `-2`, etc.).
868
+ # Workers — dispatch 가 기록한 세션 id 로 짚은 세션과 agentName prefix 로
869
+ # 찾은 세션의 합집합을 합산한다(재배치 `-002`, convergence `-reverify-r1`,
870
+ # implementation `-executor`, report-writer `-impl` / `-2` 등).
871
+ sessions_dir = claude_project_dir(cwd)
872
+ # sid 로 귀속한 세션 id 전체 — 아래 unattributed 폴드에서 빼는 데 쓴다.
873
+ attributed_sids: set[str] = set()
870
874
  for worker in state.get("workers", []):
871
875
  worker_id = worker.get("workerId")
872
876
  agent = worker.get("agent")
873
877
  prefixes = match_prefixes(worker_id) if worker_id else []
874
878
 
879
+ # pane 워커는 별도 `claude -p` 프로세스라 jsonl 에 agentName 도 teamName
880
+ # 도 안 남긴다 — 아래 prefix 경로로는 영원히 매칭되지 않는다. dispatch 가
881
+ # 발급해 적어 둔 이 id 가 그 세션을 짚는 유일한 결정적 단서다. 재시도는
882
+ # attempt 마다 새 세션이므로 기록된 id 를 전부 합산한다.
883
+ # 헬퍼에서 worker_id=None 은 "run 전체"라, 이름 없는 워커에 그대로 넘기면
884
+ # 그 워커가 run 의 모든 세션을 흡수한다.
885
+ dispatched_sids = worker_session_ids(state, worker_id) if worker_id else []
875
886
  matched: list[tuple[str, Path, dict]] = []
887
+ matched_sids: set[str] = set()
888
+ for sid in dispatched_sids:
889
+ path = sessions_dir / f"{sid}.jsonl"
890
+ if not path.is_file():
891
+ continue
892
+ totals = claude_session_totals(path, since=run_since, until=run_until,
893
+ incremental=incremental)
894
+ # 창 밖 세션은 여기서 버린다 — agentName 발견 경로가 위에서 같은
895
+ # `startedAt` 검사로 거르는 것과 같은 이유다. 넣으면 0 토큰 totals 가
896
+ # usage_block 을 타고 "이 워커는 0 을 썼다"로 보고되어, unavailable 로
897
+ # 남아야 할 상태를 허위 0 이 덮는다.
898
+ if not totals.get("startedAt"):
899
+ continue
900
+ matched.append((sid, path, totals))
901
+ matched_sids.add(sid)
902
+ attributed_sids.add(sid)
903
+
904
+ # 조건부 폴백이 아니라 합집합이다. 한 워커의 attempt 1 이 pane(sid 로만
905
+ # 찾힌다), attempt 2 가 in-process 서브에이전트(agentName 으로만 찾힌다)일
906
+ # 수 있고, `if not matched:` 로 두면 sid 가 하나라도 맞는 순간 attempt 2 의
907
+ # 토큰이 통째로 누락된다. 양쪽에서 온 같은 세션은 sid 로 한 번만 센다.
876
908
  for agent_name, entries in by_agent.items():
877
- if agent_matches(agent_name, prefixes):
878
- matched.extend(entries)
909
+ if not agent_matches(agent_name, prefixes):
910
+ continue
911
+ for entry in entries:
912
+ if entry[0] in matched_sids:
913
+ continue
914
+ # team-needle 경로는 창 검사 없이 by_agent 를 채운다. 같은 리드
915
+ # 세션의 다음 run 이 띄운 워커도 같은 needle 에 걸리는데, 창 밖이라
916
+ # startedAt 이 없어 아래 정렬에서 맨 앞에 서고 `sessionId` 를
917
+ # 차지한다 — 토큰은 0 이라 합계는 안 틀리고 리포트가 가리키는
918
+ # 세션만 남의 것이 된다.
919
+ if not entry[2].get("startedAt"):
920
+ continue
921
+ matched.append(entry)
922
+ matched_sids.add(entry[0])
879
923
 
880
924
  if not matched:
881
925
  worker["usage"] = na_block(
882
- f"no Claude subagent jsonl found with agentName matching prefixes {prefixes}"
926
+ "no Claude session jsonl in the run window for dispatched "
927
+ f"sessionIds {dispatched_sids} or agentName prefixes {prefixes}"
883
928
  )
884
929
  continue
885
930
 
@@ -919,6 +964,21 @@ def collect_claude_runtime_usage(
919
964
  # session the harness never tagged with `name`. Without this, the run-level
920
965
  # Worker total reads 0 and the report validator hard-fails a legitimate run.
921
966
  # Attribution is aggregate, not per-worker; usageSummary records it openly.
967
+ #
968
+ # 먼저 sid 로 귀속된 세션을 뺀다. 이 두 집합은 `agentName` 유무로 배타적이었지만
969
+ # (by_agent 는 있어야 들어가고 unattributed 는 없어야 들어간다) sid 경로는
970
+ # agentName 을 안 보므로 그 배타성이 더는 성립하지 않는다. team needle 에
971
+ # 걸리면서 agentName 이 없는 세션이 동시에 기록된 dispatch sid 이기도 하면,
972
+ # 빼지 않을 경우 그 토큰이 워커 usage 와 이 폴드 양쪽에 들어가 `_populate_usage_summary`
973
+ # 의 workerTotalTokens 를 부풀린다. 두 리스트는 인덱스가 대응하므로 함께 거른다.
974
+ kept = [
975
+ (sid, totals)
976
+ for sid, totals in zip(unattributed_sessions, unattributed_totals)
977
+ if sid not in attributed_sids
978
+ ]
979
+ unattributed_sessions = [sid for sid, _ in kept]
980
+ unattributed_totals = [totals for _, totals in kept]
981
+
922
982
  unattributed_usage = None
923
983
  if unattributed_totals:
924
984
  unattributed_usage = usage_block(
@@ -16,6 +16,17 @@ shared state. The built-in default is `.project-docs`, `.scratch`,
16
16
  okstra-owned context and writes still stay under `<PROJECT_ROOT>/.okstra/**`
17
17
  unless the task brief explicitly authorizes a non-okstra path.
18
18
 
19
+ `.claude` is the one entry materialised as a real directory whose children are
20
+ symlinked one by one, instead of a single symlink for the whole directory. git
21
+ does not follow a symlink, so a symlinked directory is one file to it, and a
22
+ project that ignores host config by its contents (`.claude/*`) matches every
23
+ child but never the bare `.claude` path — the directory would be invisible in
24
+ the main checkout and `?? .claude` in the task worktree. Linking the children
25
+ reproduces the main checkout's shape, so the project's own ignore rules decide
26
+ the outcome in both places. The other entries stay whole-directory symlinks
27
+ because okstra writes into them, and the directory symlink is what makes a
28
+ newly created top-level entry land in the main checkout.
29
+
19
30
  To override per-project, add a `worktreeSyncDirs` array to `project.json`.
20
31
  Empty array disables the feature; the field is preserved across the runtime's
21
32
  auto-upserts (only `projectId`, `projectRoot`, `createdAt`, `updatedAt` are