okstra 0.172.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 (123) hide show
  1. package/README.md +8 -6
  2. package/docs/architecture/storage-model.md +24 -3
  3. package/docs/architecture.md +21 -35
  4. package/docs/cli.md +39 -7
  5. package/docs/container.md +1 -1
  6. package/docs/contributor-change-matrix.md +1 -1
  7. package/docs/performance-improvement-plan-v2.md +6 -5
  8. package/docs/project-structure-overview.md +33 -25
  9. package/docs/task-process/README.md +6 -4
  10. package/docs/task-process/error-analysis.md +2 -2
  11. package/docs/task-process/final-verification.md +2 -2
  12. package/docs/task-process/implementation-option-selection.md +70 -0
  13. package/docs/task-process/implementation-planning.md +24 -16
  14. package/docs/task-process/requirements-discovery.md +2 -2
  15. package/package.json +1 -1
  16. package/runtime/BUILD.json +2 -2
  17. package/runtime/agents/workers/claude-worker.md +1 -1
  18. package/runtime/agents/workers/report-writer-worker.md +30 -6
  19. package/runtime/bin/lib/okstra/cli.sh +5 -1
  20. package/runtime/bin/lib/okstra/globals.sh +2 -1
  21. package/runtime/bin/lib/okstra/usage.sh +3 -0
  22. package/runtime/bin/okstra-provider-exec.py +29 -12
  23. package/runtime/bin/okstra-trace-cleanup.sh +58 -129
  24. package/runtime/bin/okstra.sh +2 -0
  25. package/runtime/prompts/duties/direction-selection-worker.md +44 -0
  26. package/runtime/prompts/duties/planning-worker.md +12 -4
  27. package/runtime/prompts/lead/adapters/cmux.md +2 -0
  28. package/runtime/prompts/lead/context-loader.md +1 -1
  29. package/runtime/prompts/lead/convergence.md +5 -5
  30. package/runtime/prompts/lead/okstra-lead-contract.md +7 -6
  31. package/runtime/prompts/lead/plan-body-verification.md +23 -6
  32. package/runtime/prompts/lead/report-writer.md +33 -11
  33. package/runtime/prompts/profiles/_common-contract.md +3 -3
  34. package/runtime/prompts/profiles/_implementation-deliverable.md +2 -2
  35. package/runtime/prompts/profiles/_implementation-executor.md +2 -0
  36. package/runtime/prompts/profiles/_implementation-verifier.md +2 -2
  37. package/runtime/prompts/profiles/error-analysis.md +4 -4
  38. package/runtime/prompts/profiles/final-verification.md +3 -3
  39. package/runtime/prompts/profiles/forbidden-actions.json +7 -0
  40. package/runtime/prompts/profiles/implementation-option-selection.md +35 -0
  41. package/runtime/prompts/profiles/implementation-planning.md +61 -46
  42. package/runtime/prompts/profiles/implementation.md +4 -2
  43. package/runtime/prompts/profiles/improvement-discovery.md +1 -1
  44. package/runtime/prompts/profiles/release-handoff.md +1 -1
  45. package/runtime/prompts/profiles/requirements-discovery.md +3 -3
  46. package/runtime/prompts/wizard/prompts.ko.json +9 -1
  47. package/runtime/python/okstra_ctl/adapters/dispatch/__init__.py +1 -6
  48. package/runtime/python/okstra_ctl/adapters/hosts/external/relay.md +4 -4
  49. package/runtime/python/okstra_ctl/adapters/providers/claude/adapter.py +5 -0
  50. package/runtime/python/okstra_ctl/agent_invocation.py +1 -0
  51. package/runtime/python/okstra_ctl/analysis_packet.py +6 -0
  52. package/runtime/python/okstra_ctl/conformance.py +68 -0
  53. package/runtime/python/okstra_ctl/dispatch_core.py +89 -39
  54. package/runtime/python/okstra_ctl/dispatch_state.py +142 -14
  55. package/runtime/python/okstra_ctl/doctor.py +2 -2
  56. package/runtime/python/okstra_ctl/domain/worker_exec.py +5 -0
  57. package/runtime/python/okstra_ctl/exact_coverage.py +128 -0
  58. package/runtime/python/okstra_ctl/final_report_schema.py +5 -4
  59. package/runtime/python/okstra_ctl/fix_cycles.py +3 -1
  60. package/runtime/python/okstra_ctl/implementation_direction.py +836 -0
  61. package/runtime/python/okstra_ctl/implementation_options.py +479 -0
  62. package/runtime/python/okstra_ctl/pane_reclaim.py +13 -22
  63. package/runtime/python/okstra_ctl/plan_items.py +51 -3
  64. package/runtime/python/okstra_ctl/render.py +1 -0
  65. package/runtime/python/okstra_ctl/render_final_report.py +16 -19
  66. package/runtime/python/okstra_ctl/report_contract.py +45 -14
  67. package/runtime/python/okstra_ctl/report_finalize.py +68 -9
  68. package/runtime/python/okstra_ctl/report_html/render.py +4 -2
  69. package/runtime/python/okstra_ctl/report_html/router.py +4 -0
  70. package/runtime/python/okstra_ctl/report_html/view_models/implementation_option_selection.py +32 -0
  71. package/runtime/python/okstra_ctl/report_html/view_models/implementation_planning.py +25 -10
  72. package/runtime/python/okstra_ctl/report_views.py +148 -12
  73. package/runtime/python/okstra_ctl/run.py +393 -4
  74. package/runtime/python/okstra_ctl/schema_excerpt.py +1 -1
  75. package/runtime/python/okstra_ctl/scope_provenance.py +16 -10
  76. package/runtime/python/okstra_ctl/session.py +69 -12
  77. package/runtime/python/okstra_ctl/team.py +51 -25
  78. package/runtime/python/okstra_ctl/tmux.py +19 -149
  79. package/runtime/python/okstra_ctl/user_response.py +75 -0
  80. package/runtime/python/okstra_ctl/wizard.py +144 -0
  81. package/runtime/python/okstra_ctl/worker_prompt_policy.py +2 -0
  82. package/runtime/python/okstra_ctl/worker_request.py +2 -0
  83. package/runtime/python/okstra_ctl/workflow.py +29 -7
  84. package/runtime/python/okstra_ctl/worktree.py +69 -3
  85. package/runtime/python/okstra_token_usage/cli.py +1 -1
  86. package/runtime/python/okstra_token_usage/collect.py +66 -6
  87. package/runtime/schemas/final-report-v2.0.schema.json +1428 -137
  88. package/runtime/skills/okstra-setup/references/project-config.md +11 -0
  89. package/runtime/templates/reports/final-report-v2.template.md +4 -0
  90. package/runtime/templates/reports/final-verification-input.template.md +1 -1
  91. package/runtime/templates/reports/html/base.template.html +3 -2
  92. package/runtime/templates/reports/html/i18n/en.json +21 -1
  93. package/runtime/templates/reports/html/i18n/ko.json +21 -1
  94. package/runtime/templates/reports/html/macros/forms.html +21 -2
  95. package/runtime/templates/reports/html/tasks/implementation-option-selection.template.html +49 -0
  96. package/runtime/templates/reports/html/tasks/implementation-planning.template.html +36 -2
  97. package/runtime/templates/reports/i18n/en.json +13 -0
  98. package/runtime/templates/reports/implementation-input.template.md +4 -2
  99. package/runtime/templates/reports/implementation-planning-input.template.md +18 -4
  100. package/runtime/templates/reports/improvement-discovery-input.template.md +1 -1
  101. package/runtime/templates/reports/md/tasks/implementation-option-selection.template.md +13 -0
  102. package/runtime/templates/reports/md/tasks/implementation-planning.template.md +17 -0
  103. package/runtime/templates/reports/report.js +111 -4
  104. package/runtime/templates/reports/settings.template.json +0 -24
  105. package/runtime/templates/reports/task-brief.template.md +9 -3
  106. package/runtime/templates/reports/user-response.template.md +25 -4
  107. package/runtime/templates/worker-prompt-preamble.md +8 -0
  108. package/runtime/validators/lib/fixtures.sh +49 -17
  109. package/runtime/validators/validate-implementation-plan-stages.py +169 -4
  110. package/runtime/validators/validate-report-views.py +2 -2
  111. package/runtime/validators/validate-run.py +149 -498
  112. package/runtime/validators/validate_improvement_report.py +5 -1
  113. package/runtime/validators/validate_session_conformance.py +1 -1
  114. package/src/cli-registry.mjs +8 -1
  115. package/src/commands/execute/codex-run.mjs +1 -0
  116. package/src/commands/execute/render-bundle.mjs +1 -0
  117. package/src/commands/execute/team.mjs +3 -3
  118. package/src/commands/execute/worktree-status.mjs +109 -0
  119. package/src/commands/lifecycle/install.mjs +0 -2
  120. package/src/commands/report/finalize.mjs +13 -6
  121. package/runtime/bin/okstra-subagent-reclaim.sh +0 -26
  122. package/runtime/schemas/final-report-v1.0.schema.json +0 -6366
  123. package/runtime/templates/reports/final-report.template.md +0 -1258
@@ -1,21 +1,22 @@
1
1
  """Claude session helpers.
2
2
 
3
- bash session.sh 의 python 구현. claude session id 생성, resume command
4
- 파일 작성.
3
+ bash session.sh 의 python 구현. lead claude 세션 관측, resume command
4
+ 파일 작성. 세션 id 발급기는 워커 dispatch 도 쓰므로
5
+ `dispatch_state.generate_claude_session_id` 에 있다.
5
6
  """
6
7
  from __future__ import annotations
7
8
 
8
9
  import json
9
10
  import os
10
- import uuid
11
11
  from pathlib import Path
12
+ from typing import Collection
12
13
 
13
- from .dispatch_state import DispatchError, mutate_team_state
14
-
15
-
16
- def generate_claude_session_id() -> str:
17
- """UUIDv4 문자열."""
18
- return str(uuid.uuid4())
14
+ from .dispatch_state import (
15
+ DispatchError,
16
+ load_json_object,
17
+ mutate_team_state,
18
+ worker_session_ids,
19
+ )
19
20
 
20
21
 
21
22
  def _claude_projects_dir_for(cwd: Path) -> Path:
@@ -76,7 +77,11 @@ def _session_mentions(jsonl_path: Path, needle: str) -> bool:
76
77
  return False
77
78
 
78
79
 
79
- def resolve_lead_session_id_for_run(project_root: Path, run_dir: Path) -> str:
80
+ def resolve_lead_session_id_for_run(
81
+ project_root: Path,
82
+ run_dir: Path,
83
+ exclude_session_ids: Collection[str] = (),
84
+ ) -> str:
80
85
  """`run_dir` 를 다룬 lead 세션 중 가장 최근 수정된 것의 id, 없으면 ''.
81
86
 
82
87
  프로젝트 디렉토리의 최신 jsonl 을 그대로 집으면(``resolve_inproc_lead_session_id``)
@@ -84,6 +89,12 @@ def resolve_lead_session_id_for_run(project_root: Path, run_dir: Path) -> str:
84
89
  (dev-10172): 남의 세션이 `leadSessionIds` 에 들어가 run 비용이 $80 → $207 로
85
90
  부풀고, 그 세션의 PROGRESS 라인이 conformance 스캔에 섞여 오탐을 냈다.
86
91
  자기 run 의 산출물 경로를 언급한 세션만 후보로 인정해 그 오염을 막는다.
92
+
93
+ `exclude_session_ids` 는 같은 run **안**의 오염을 막는다. cmux 워커는
94
+ `agentName` 을 안 남겨 아래 필터를 통과하고, 워커 세션도 자기 run 의 산출물
95
+ 경로를 언급하므로 needle 에도 걸린다. 워커가 리드보다 늦게 끝나면 mtime
96
+ 정렬에서 먼저 잡혀 워커 세션이 리드로 기록된다 — dispatch 가 발급한 id 를
97
+ 호출자가 넘겨 그 세션들을 후보에서 뺀다.
87
98
  """
88
99
  proj_dir = _claude_projects_dir_for(project_root)
89
100
  try:
@@ -91,7 +102,10 @@ def resolve_lead_session_id_for_run(project_root: Path, run_dir: Path) -> str:
91
102
  except OSError:
92
103
  return ""
93
104
  needle = str(run_dir)
105
+ excluded = set(exclude_session_ids)
94
106
  for path in sorted(candidates, key=lambda p: p.stat().st_mtime, reverse=True):
107
+ if path.stem in excluded:
108
+ continue
95
109
  if _session_has_agent_name(path):
96
110
  continue
97
111
  if _session_mentions(path, needle):
@@ -103,17 +117,36 @@ def record_observed_lead_session(project_root: Path, team_state_path: Path) -> s
103
117
  """이 run 의 live lead 세션을 관측해 team-state 에 append(멱등). 이 run 을
104
118
  다룬 lead 세션을 못 찾거나 중복이면 '' 반환. 재발급으로 갈린 세대를 축1 이
105
119
  여기에 모은다.
120
+
121
+ 후보에서 뺄 워커 세션 id 는 append 대상인 team-state 자체가 들고 있다 —
122
+ dispatch 가 `workerDispatches[].sessionId` 에 적어 둔 값이라, 관측 시점에
123
+ 이 run 이 연 워커 세션의 목록은 그 파일 하나로 완결된다.
106
124
  """
107
- sid = resolve_lead_session_id_for_run(project_root, team_state_path.parent.parent)
125
+ try:
126
+ team_state = load_json_object(team_state_path, "team-state")
127
+ except (DispatchError, OSError):
128
+ team_state = {}
129
+ sid = resolve_lead_session_id_for_run(
130
+ project_root,
131
+ team_state_path.parent.parent,
132
+ exclude_session_ids=worker_session_ids(team_state),
133
+ )
108
134
  if not sid:
109
135
  return ""
110
136
  def add_observed_session(state: dict) -> bool:
111
137
  lead_ids = state.setdefault("leadSessionIds", [])
138
+ observed = state.setdefault("observedTeamNames", [])
139
+ # 두 키가 list 가 아니면 `sid in None` 이 TypeError 를, `str.append` 가
140
+ # AttributeError 를 낸다. 그 예외는 아래 `(DispatchError, OSError)` 도
141
+ # `observe_lead_session` 의 `OSError` 도 통과해 dispatch 를 죽인다 —
142
+ # 관측이 호출자를 깨뜨리지 않는다는 계약을 docstring 이 아니라 여기서
143
+ # 지킨다. 관측을 건너뛰면 이 run 은 관측 이전과 같은 상태로 남는다.
144
+ if not isinstance(lead_ids, list) or not isinstance(observed, list):
145
+ return False
112
146
  if sid in lead_ids:
113
147
  return False
114
148
  lead_ids.append(sid)
115
149
  team = f"session-{sid[:8]}"
116
- observed = state.setdefault("observedTeamNames", [])
117
150
  if team not in observed:
118
151
  observed.append(team)
119
152
  return True
@@ -125,6 +158,30 @@ def record_observed_lead_session(project_root: Path, team_state_path: Path) -> s
125
158
  return sid if changed else ""
126
159
 
127
160
 
161
+ def observe_lead_session(project_root: Path, team_state_path: Path) -> None:
162
+ """단계 경계에서 부르는 부수효과 — 관측 실패를 호출자에게 내지 않는다.
163
+
164
+ 리드 세션은 resume·compaction 으로 세대가 갈리므로 prepare 때 적힌 단 하나의
165
+ id 는 런 구간에 레코드가 없는 죽은 세션을 가리킬 수 있다. 리드가 반드시
166
+ 지나가는 지점마다 관측해 `leadSessionIds` 에 세대를 모은다 — 리드의 협조를
167
+ 요구하지 않는 것이 요점이다.
168
+
169
+ 잡는 범위가 OSError 뿐인 이유: 이 아래 호출들이 각자 자기 실패를 흡수하고,
170
+ 그러고도 밖으로 나오는 것이 후보 정렬의 `p.stat()` 이다 — 스캔 도중 세션
171
+ jsonl 이 사라지면 여기서 OSError 가 난다. team-state 읽기·쓰기
172
+ (`load_json_object` / `mutate_team_state`)와 세션 본문 스캔이 그 흡수
173
+ 지점이고, 비정상 team-state 가 낼 TypeError/AttributeError 는 잡는 대신 아예
174
+ 내지 않는 쪽으로 막는다 — append 대상 키(`leadSessionIds` /
175
+ `observedTeamNames`)는 `add_observed_session` 이, 제외 집합의 출처
176
+ (`workerDispatches`)는 `worker_session_ids` 가 각각 비-list 를 걸러낸다.
177
+ 더 넓게 잡으면 team-state 를 깨뜨리는 진짜 버그까지 조용히 삼킨다.
178
+ """
179
+ try:
180
+ record_observed_lead_session(project_root, team_state_path)
181
+ except OSError:
182
+ return
183
+
184
+
128
185
  def write_claude_resume_command_file(
129
186
  *,
130
187
  resume_command_path: Path,
@@ -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])
@@ -20,6 +20,7 @@ from typing import Optional
20
20
 
21
21
  from okstra_ctl.report_views import (
22
22
  PLAN_DECISION_APPROVED,
23
+ normalize_direction_selection_identity,
23
24
  serialize_user_response, UserResponseEntry, UserPlanDecision, infer_run_meta,
24
25
  parse_expected_form_options,
25
26
  )
@@ -36,6 +37,9 @@ from okstra_ctl.clarification_items import (
36
37
  _PLAN_DECISION_HEADING_RE = re.compile(r"^## PLAN DECISION\s*$", re.MULTILINE)
37
38
  _NEXT_RESPONSE_HEADING_RE = re.compile(r"^## ", re.MULTILINE)
38
39
  _ANALYSIS_REVIEW_HEADING_RE = re.compile(r"^## ANALYSIS REVIEW\s*$", re.MULTILINE)
40
+ _DIRECTION_SELECTION_HEADING_RE = re.compile(
41
+ r"^## DIRECTION SELECTION\s*$", re.MULTILINE
42
+ )
39
43
  _ANALYSIS_SIDECAR_HEADING_RE = re.compile(
40
44
  r"^## (?P<filename>user-response-[^\n]+\.md)\s*$", re.MULTILINE
41
45
  )
@@ -79,6 +83,20 @@ class AnalysisReviewRecord:
79
83
  seq: str
80
84
 
81
85
 
86
+ @dataclass(frozen=True)
87
+ class DirectionSelectionRecord:
88
+ status: str
89
+ option_id: str
90
+ option_name: str
91
+ confirmed: bool
92
+ selection_note: str
93
+ constraints: str
94
+ source_report: str
95
+ source_data: str
96
+ source_data_sha256: str
97
+ seq: str
98
+
99
+
82
100
  _ANALYSIS_REVIEW_STATUSES = frozenset({
83
101
  "accepted",
84
102
  "revision-requested",
@@ -405,6 +423,19 @@ def _field(block: str, key: str) -> Optional[str]:
405
423
  return m.group(1) if m else None
406
424
 
407
425
 
426
+ def _direction_identity_fields(block: str) -> tuple[str, str]:
427
+ match = re.search(
428
+ r"^- Option-ID:[ \t]*(?P<option_id>[^\r\n]*)\r?\n"
429
+ r"- Option-Name:[ \t]*(?P<option_name>[^\r\n]*)\r?\n"
430
+ r"- Confirmed:",
431
+ block,
432
+ re.MULTILINE,
433
+ )
434
+ if match is None:
435
+ return "", ""
436
+ return match.group("option_id"), match.group("option_name")
437
+
438
+
408
439
  def _value(block: str) -> str:
409
440
  # Value 는 "- Value:" 다음 줄들의 " > " 인용 블록.
410
441
  m = re.search(r"^- Value:\s*\n((?:\s*>.*\n?)+)", block, re.MULTILINE)
@@ -414,6 +445,50 @@ def _value(block: str) -> str:
414
445
  return "\n".join(lines).strip()
415
446
 
416
447
 
448
+ def parse_direction_selection(
449
+ sidecar_text: str,
450
+ ) -> DirectionSelectionRecord | None:
451
+ matches = list(_DIRECTION_SELECTION_HEADING_RE.finditer(sidecar_text))
452
+ if not matches:
453
+ return None
454
+ if len(matches) != 1:
455
+ raise UserResponseError(
456
+ "DIRECTION SELECTION requires exactly one block"
457
+ )
458
+ block = sidecar_text[matches[0].end():]
459
+ next_heading = _NEXT_RESPONSE_HEADING_RE.search(block)
460
+ if next_heading:
461
+ block = block[:next_heading.start()]
462
+ status = _field(block, "Status")
463
+ if status != "selected":
464
+ raise UserResponseError("DIRECTION SELECTION Status must be selected")
465
+ confirmed = _field(block, "Confirmed")
466
+ if confirmed != "true":
467
+ raise UserResponseError("DIRECTION SELECTION Confirmed must be true")
468
+ raw_option_id, raw_option_name = _direction_identity_fields(block)
469
+ try:
470
+ option_id, option_name = normalize_direction_selection_identity(
471
+ raw_option_id,
472
+ raw_option_name,
473
+ )
474
+ except ValueError as error:
475
+ raise UserResponseError(str(error)) from error
476
+ return DirectionSelectionRecord(
477
+ status=status,
478
+ option_id=option_id,
479
+ option_name=option_name,
480
+ confirmed=True,
481
+ selection_note=_quoted_review_value(block, "Selection-Note"),
482
+ constraints=_quoted_review_value(block, "Constraints"),
483
+ source_report=_sidecar_metadata_value(sidecar_text, "source-report"),
484
+ source_data=_sidecar_metadata_value(sidecar_text, "source-data"),
485
+ source_data_sha256=_sidecar_metadata_value(
486
+ sidecar_text, "source-data-sha256"
487
+ ),
488
+ seq=_sidecar_metadata_value(sidecar_text, "seq"),
489
+ )
490
+
491
+
417
492
  def parse_user_response_entries(sidecar_text: str) -> list[UserResponseEntry]:
418
493
  """Reverse of ``serialize_user_response`` for the per-response ``## C-*``
419
494
  blocks. The ``## PLAN DECISION`` block is skipped (read separately by