okstra 0.177.0 → 0.178.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 (41) 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 +2 -2
  14. package/runtime/prompts/lead/report-writer.md +5 -1
  15. package/runtime/prompts/profiles/_common-contract.md +1 -1
  16. package/runtime/python/okstra_ctl/adapters/hosts/claude-code/relay.md +5 -6
  17. package/runtime/python/okstra_ctl/adapters/providers/antigravity/adapter.py +23 -1
  18. package/runtime/python/okstra_ctl/agent_invocation.py +17 -0
  19. package/runtime/python/okstra_ctl/agent_prompt_cli.py +13 -0
  20. package/runtime/python/okstra_ctl/dispatch_core.py +96 -17
  21. package/runtime/python/okstra_ctl/dispatch_state.py +57 -0
  22. package/runtime/python/okstra_ctl/model_cli.py +11 -2
  23. package/runtime/python/okstra_ctl/model_discovery.py +12 -0
  24. package/runtime/python/okstra_ctl/pane_reclaim.py +49 -43
  25. package/runtime/python/okstra_ctl/render.py +53 -0
  26. package/runtime/python/okstra_ctl/report_html/render.py +7 -4
  27. package/runtime/python/okstra_ctl/report_views.py +35 -0
  28. package/runtime/python/okstra_ctl/run.py +88 -2
  29. package/runtime/python/okstra_ctl/team.py +84 -14
  30. package/runtime/python/okstra_ctl/tmux.py +2 -3
  31. package/runtime/python/okstra_ctl/user_response.py +20 -4
  32. package/runtime/python/okstra_ctl/worker_runner.py +2 -2
  33. package/runtime/python/okstra_ctl/write_policy.py +9 -1
  34. package/runtime/schemas/final-report-v2.0.schema.json +24 -1
  35. package/runtime/skills/okstra-run/SKILL.md +3 -3
  36. package/runtime/templates/reports/final-report-v2.template.md +2 -1
  37. package/runtime/templates/reports/html/base.template.html +1 -2
  38. package/runtime/validators/validate-report-views.py +30 -17
  39. package/runtime/validators/validate-run.py +256 -78
  40. package/runtime/validators/validate_session_conformance.py +1 -1
  41. package/runtime/bin/okstra-trace-cleanup.sh +0 -185
@@ -1123,6 +1123,19 @@ _ANALYSIS_REVIEW_STATUSES = frozenset({
1123
1123
  })
1124
1124
 
1125
1125
  PLAN_DECISION_APPROVED = "approved"
1126
+ @dataclass(frozen=True)
1127
+ class UserReportAuthoring:
1128
+ """사용자가 리드의 최종 리포트 직접 저작을 허가했는지에 대한 답.
1129
+
1130
+ 승인이어도 ``reason`` 이 필수다 — 리포트 헤더가 이 사유를 그대로 실어
1131
+ 나중에 읽는 사람이 이 run 이 왜 report-writer 경로를 벗어났는지 본다."""
1132
+ status: str
1133
+ reason: str = ""
1134
+
1135
+
1136
+ REPORT_AUTHORING_APPROVED = "approved"
1137
+ _REPORT_AUTHORING_STATUSES = frozenset({REPORT_AUTHORING_APPROVED, "denied"})
1138
+
1126
1139
  _PLAN_DECISION_STATUSES = frozenset({
1127
1140
  PLAN_DECISION_APPROVED,
1128
1141
  "revision-requested",
@@ -1208,6 +1221,25 @@ def _serialize_plan_decision(decision: UserPlanDecision) -> str:
1208
1221
  return chunk
1209
1222
 
1210
1223
 
1224
+ def _serialize_report_authoring(decision: UserReportAuthoring) -> str:
1225
+ """The user's answer on letting the lead author the final report itself.
1226
+
1227
+ Same shape as PLAN DECISION because it is the same kind of fact: a decision
1228
+ only the user may make, written into the sidecar the user owns. A reason is
1229
+ required even on approval — the report carries it forward, so a later reader
1230
+ sees why this run left the report-writer path.
1231
+ """
1232
+ if decision.status not in _REPORT_AUTHORING_STATUSES:
1233
+ raise ValueError(f"invalid REPORT AUTHORING status: {decision.status}")
1234
+ if not decision.reason.strip():
1235
+ raise ValueError("REPORT AUTHORING requires a Reason")
1236
+ return (
1237
+ "\n## REPORT AUTHORING\n"
1238
+ f"- Status: {decision.status}\n"
1239
+ f"{_quoted_sidecar_field('Reason', decision.reason)}"
1240
+ )
1241
+
1242
+
1211
1243
  def _serialize_direction_selection(selection: UserDirectionSelection) -> str:
1212
1244
  option_id, option_name = normalize_direction_selection_identity(
1213
1245
  selection.option_id, selection.option_name
@@ -1233,6 +1265,7 @@ def serialize_user_response(
1233
1265
  plan_decision: UserPlanDecision | None = None,
1234
1266
  analysis_review: UserResponseAnalysisReview | None = None,
1235
1267
  direction_selection: UserDirectionSelection | None = None,
1268
+ report_authoring: UserReportAuthoring | None = None,
1236
1269
  ) -> str:
1237
1270
  """Return the canonical markdown text the HTML 'Export user
1238
1271
  response' button must produce. Used by validators to confirm that
@@ -1285,6 +1318,8 @@ def serialize_user_response(
1285
1318
  body_chunks.append(_serialize_analysis_review(analysis_review))
1286
1319
  if direction_selection is not None:
1287
1320
  body_chunks.append(_serialize_direction_selection(direction_selection))
1321
+ if report_authoring is not None:
1322
+ body_chunks.append(_serialize_report_authoring(report_authoring))
1288
1323
  return head + "".join(body_chunks)
1289
1324
 
1290
1325
 
@@ -1929,12 +1929,19 @@ def _build_static_execution_manifest(
1929
1929
  plan: AssignmentPlan,
1930
1930
  context: AssignmentContext,
1931
1931
  lead: ResolvedAssignment,
1932
+ translator: ResolvedAssignment,
1932
1933
  ) -> ExecutionManifest:
1933
1934
  participants: list[ParticipantAssignment] = []
1934
1935
  roles: list[RoleExecution] = []
1936
+ # The translator is resolved outside the role plan, like the lead, and both
1937
+ # are named in `invocationAssignments`. Without a role execution here,
1938
+ # `agent-prompt materialize --audience translator` has no canonical
1939
+ # identity to bind to and refuses — so a `reportLanguage: ko` run could
1940
+ # never write its translation sidecar and rendered the English source.
1935
1941
  assignments = (
1936
1942
  lead,
1937
1943
  *(row for row in plan.assignments if row.role != "leader"),
1944
+ translator,
1938
1945
  )
1939
1946
  for index, assignment in enumerate(assignments, start=1):
1940
1947
  participant_ref = f"participant-{index:03d}"
@@ -2145,8 +2152,14 @@ def _canonical_selection_provider_ids(
2145
2152
  and "implementer" not in scopes.global_
2146
2153
  )
2147
2154
  if implementer_uses_bundled_default:
2155
+ # `--executor` names the provider that implements, so it belongs in the
2156
+ # roster the same way the bundled default does. Reading only the default
2157
+ # left `--executor <provider>` with no assignment of its own: the run
2158
+ # rendered without that provider, and asking for it with `--workers`
2159
+ # was refused as not being in the roster.
2148
2160
  selected.append(
2149
- _default("OKSTRA_DEFAULT_EXECUTOR", "claude")
2161
+ (inp.executor or "").strip().lower()
2162
+ or _default("OKSTRA_DEFAULT_EXECUTOR", "claude")
2150
2163
  )
2151
2164
  if _needs_profile_worker_candidates(profile, selection, scopes):
2152
2165
  selected.extend(
@@ -2302,6 +2315,7 @@ class RoleAssignment:
2302
2315
  role: str
2303
2316
  provider: str
2304
2317
  model_display: str
2318
+ model_id: str
2305
2319
  model_execution_value: str
2306
2320
  runner: str
2307
2321
  host_runtime: str
@@ -2309,9 +2323,14 @@ class RoleAssignment:
2309
2323
  worker_id: str = ""
2310
2324
 
2311
2325
  def to_model_payload(self) -> dict[str, object]:
2326
+ # `model` carries the catalog model id, not the display name: the run
2327
+ # manifest's role executions record `modelId`, and the two are compared
2328
+ # to bind an assignment to its execution. A display name that differs
2329
+ # from its id (every antigravity and kimi model) made that comparison
2330
+ # fail for the whole provider.
2312
2331
  return {
2313
2332
  "provider": self.provider,
2314
- "model": self.model_display,
2333
+ "model": self.model_id,
2315
2334
  "modelExecutionValue": self.model_execution_value,
2316
2335
  "runner": self.runner,
2317
2336
  "hostRuntime": self.host_runtime,
@@ -2351,6 +2370,7 @@ class _ModelBindings:
2351
2370
  lead_assignment: RoleAssignment
2352
2371
  worker_assignments: tuple[RoleAssignment, ...]
2353
2372
  invocation_assignments: dict[str, dict[str, object]]
2373
+ translator: ResolvedAssignment
2354
2374
 
2355
2375
 
2356
2376
  @dataclass(frozen=True)
@@ -2628,6 +2648,7 @@ def _resolve_model_bindings(
2628
2648
  resolved=translator_meta,
2629
2649
  role="translator",
2630
2650
  )
2651
+ _reject_split_worker_models(executor_assignment, worker_assignments)
2631
2652
  invocation_assignments = _build_invocation_assignments(
2632
2653
  lead_assignment,
2633
2654
  worker_assignments,
@@ -2656,6 +2677,7 @@ def _resolve_model_bindings(
2656
2677
  lead_assignment=lead_assignment,
2657
2678
  worker_assignments=worker_assignments,
2658
2679
  invocation_assignments=invocation_assignments,
2680
+ translator=translator_meta,
2659
2681
  )
2660
2682
 
2661
2683
 
@@ -2774,6 +2796,8 @@ def _project_model_bindings(
2774
2796
  provider: _legacy_projection_from_plan(provider, plan, context)
2775
2797
  for provider in ("claude", "codex", "antigravity")
2776
2798
  }
2799
+ _reject_executor_outside_roster(executor_assignment, workers)
2800
+ _reject_split_worker_models(executor_assignment, workers)
2777
2801
  invocation_assignments = _build_invocation_assignments(
2778
2802
  lead_assignment,
2779
2803
  workers,
@@ -2808,6 +2832,7 @@ def _project_model_bindings(
2808
2832
  lead_assignment=lead_assignment,
2809
2833
  worker_assignments=workers,
2810
2834
  invocation_assignments=invocation_assignments,
2835
+ translator=translator,
2811
2836
  )
2812
2837
 
2813
2838
 
@@ -2858,6 +2883,64 @@ def _selected_execution_provider_ids(
2858
2883
  return tuple(dict.fromkeys(selected))
2859
2884
 
2860
2885
 
2886
+ def _reject_executor_outside_roster(
2887
+ executor: RoleAssignment | None,
2888
+ workers: tuple[RoleAssignment, ...],
2889
+ ) -> None:
2890
+ """Refuse an executor whose provider the roster never dispatches.
2891
+
2892
+ An implementation run opens its executor with `--workers <provider>`, so a
2893
+ provider absent from the roster has no invocation to open. The legacy
2894
+ selection path says so outright; the canonical path derived its roster from
2895
+ role models alone, so `--executor <provider>` rendered fine and the run only
2896
+ failed later, at `requested worker(s) are not in this run roster`.
2897
+ """
2898
+ if executor is None:
2899
+ return
2900
+ roster = {row.worker_id for row in workers if row.worker_id}
2901
+ if executor.worker_id in roster:
2902
+ return
2903
+ raise PrepareError(
2904
+ f"--executor {executor.worker_id} is not in this run's roster "
2905
+ f"({', '.join(sorted(roster)) or 'empty'}); the executor is dispatched "
2906
+ f"as a worker, so give it a roster slot — "
2907
+ f"--role-model verifier={executor.worker_id}/<model> — or pick an "
2908
+ f"executor already in the roster."
2909
+ )
2910
+
2911
+
2912
+ def _reject_split_worker_models(
2913
+ executor: RoleAssignment | None,
2914
+ workers: tuple[RoleAssignment, ...],
2915
+ ) -> None:
2916
+ """Refuse one worker id standing for two roles on two different models.
2917
+
2918
+ The roster names a worker by provider, so an implementation run whose
2919
+ executor and verifier are the same provider shares that id — but
2920
+ `invocationAssignments["initial/<id>"]` can hold only one model, and it
2921
+ holds the verifier's. Dispatch then looks the executor's role execution up
2922
+ by that assignment, finds no row with a matching execution value, and stops
2923
+ at `v2 execution identity does not match a canonical role execution`. The
2924
+ render used to succeed and only the dispatch failed, by which point the run
2925
+ had already claimed its stage.
2926
+ """
2927
+ if executor is None:
2928
+ return
2929
+ peer = next(
2930
+ (row for row in workers if row.worker_id == executor.worker_id),
2931
+ None,
2932
+ )
2933
+ if peer is None or peer.model_execution_value == executor.model_execution_value:
2934
+ return
2935
+ raise PrepareError(
2936
+ f"worker {executor.worker_id!r} is the executor on "
2937
+ f"{executor.model_execution_value!r} and a verifier on "
2938
+ f"{peer.model_execution_value!r}; one worker id carries one model. "
2939
+ f"Give both roles the same model, or pick a different --executor "
2940
+ f"provider."
2941
+ )
2942
+
2943
+
2861
2944
  def _resolve_executor_assignment(
2862
2945
  inp: PrepareInputs,
2863
2946
  workers: list[str],
@@ -2930,6 +3013,7 @@ def _role_assignment(
2930
3013
  role=role,
2931
3014
  provider=resolved.provider_id,
2932
3015
  model_display=resolved.display_name,
3016
+ model_id=resolved.model_id,
2933
3017
  model_execution_value="unknown",
2934
3018
  runner="cli-wrapper",
2935
3019
  host_runtime=resolved.host_runtime,
@@ -2940,6 +3024,7 @@ def _role_assignment(
2940
3024
  role=role,
2941
3025
  provider=resolved.provider_id,
2942
3026
  model_display=resolved.display_name,
3027
+ model_id=resolved.model_id,
2943
3028
  model_execution_value=binding.resolved_execution_value,
2944
3029
  runner=(
2945
3030
  "native-session"
@@ -4086,6 +4171,7 @@ def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
4086
4171
  assignment_plan,
4087
4172
  assignment_context,
4088
4173
  models.lead,
4174
+ models.translator,
4089
4175
  )
4090
4176
  dynamic_roles = tuple(
4091
4177
  requirement.role
@@ -2,8 +2,12 @@
2
2
 
3
3
  Under cmux this is every lead's door onto cmux surfaces, because okstra owns the
4
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.
5
+ runs as a cli-wrapper subprocess — so there is no pane for either closing
6
+ command to act on. Which backend a run uses is read from its run manifest.
7
+
8
+ `reclaim` is the round boundary and `teardown` is the end of the run: the first
9
+ closes nothing but the finished dispatches' panes, the second takes every
10
+ recorded pane and writes off whatever never finished.
7
11
  """
8
12
  from __future__ import annotations
9
13
 
@@ -18,7 +22,11 @@ from . import cmux
18
22
  from .adapters.dispatch import provider_worker_wrappers
19
23
  from .adapters.dispatch.cmux import dispatch_port_for_terminal_backend
20
24
  from .application.dispatch_assignments import dispatch_assignments
21
- from .dispatch_state import TEARDOWN_BEFORE_TERMINAL_REASON, mutate_team_state
25
+ from .dispatch_state import (
26
+ TEARDOWN_BEFORE_TERMINAL_REASON,
27
+ TERMINAL_WORKER_STATUSES,
28
+ mutate_team_state,
29
+ )
22
30
  from .dispatch_core import (
23
31
  BACKEND_CLI_WRAPPER,
24
32
  BACKEND_CMUX_PANE,
@@ -34,7 +42,6 @@ from .session import observe_lead_session
34
42
 
35
43
 
36
44
  _SUPPORTED_WRAPPERS = provider_worker_wrappers(default_provider_registry())
37
- _TERMINAL_STATUSES = {"completed", "timeout", "error", "not-run"}
38
45
 
39
46
 
40
47
  def main(argv: Sequence[str] | None = None) -> int:
@@ -47,6 +54,8 @@ def main(argv: Sequence[str] | None = None) -> int:
47
54
  return _await(args)
48
55
  if args.command == "teardown":
49
56
  return _teardown(args)
57
+ if args.command == "reclaim":
58
+ return _reclaim(args)
50
59
  except DispatchError as exc:
51
60
  print(f"okstra team: {exc}", file=sys.stderr)
52
61
  return 2
@@ -62,6 +71,7 @@ def _parser() -> argparse.ArgumentParser:
62
71
  _add_dispatch_parser(sub)
63
72
  _add_await_parser(sub)
64
73
  _add_teardown_parser(sub)
74
+ _add_reclaim_parser(sub)
65
75
  return parser
66
76
 
67
77
 
@@ -85,7 +95,19 @@ def _add_await_parser(sub) -> None:
85
95
 
86
96
 
87
97
  def _add_teardown_parser(sub) -> None:
88
- parser = sub.add_parser("teardown", help="reclaim this run's worker panes")
98
+ parser = sub.add_parser(
99
+ "teardown", help="close every recorded pane at the end of the run"
100
+ )
101
+ _add_run_args(parser)
102
+ parser.add_argument("--dry-run", action="store_true")
103
+ parser.add_argument("--json", action="store_true")
104
+
105
+
106
+ def _add_reclaim_parser(sub) -> None:
107
+ parser = sub.add_parser(
108
+ "reclaim",
109
+ help="close the finished dispatches' panes at a round boundary",
110
+ )
89
111
  _add_run_args(parser)
90
112
  parser.add_argument("--dry-run", action="store_true")
91
113
  parser.add_argument("--json", action="store_true")
@@ -161,8 +183,47 @@ def _teardown(args) -> int:
161
183
  team_state = _load_json(team_state_path, "team-state")
162
184
  panes = _reclaimable_panes(manifest, team_state)
163
185
  if args.dry_run:
164
- _emit_teardown(args.json, panes)
186
+ _emit_panes(args.json, panes)
187
+ return 0
188
+ _close_panes(manifest, panes)
189
+ _mark_teardown_errors(team_state_path)
190
+ _emit_panes(args.json, panes)
191
+ return 0
192
+
193
+
194
+ def _reclaim(args) -> int:
195
+ """Close the finished dispatches' surfaces at a round boundary.
196
+
197
+ Two things teardown does are wrong here. It closes every recorded surface,
198
+ which mid-round would kill the workers still running; and it writes off every
199
+ non-terminal dispatch as an error, which would drop a worker out of the retry
200
+ path it has not reached yet. So this shares the closing and the reporting and
201
+ nothing else.
202
+ """
203
+ manifest = _load_manifest(args.project_root, args.run_manifest)
204
+ _validate_team_manifest(manifest)
205
+ project_root = Path(args.project_root).resolve()
206
+ team_state_path = _resolve_project_path(
207
+ project_root, _require_string(manifest, "teamStatePath")
208
+ )
209
+ team_state = _load_json(team_state_path, "team-state")
210
+ panes = _reclaimable_panes(manifest, team_state, finished_only=True)
211
+ if args.dry_run:
212
+ _emit_panes(args.json, panes)
165
213
  return 0
214
+ _close_panes(manifest, panes)
215
+ _emit_panes(args.json, panes)
216
+ return 0
217
+
218
+
219
+ def _close_panes(manifest: Mapping[str, Any], panes: list[dict[str, str]]) -> None:
220
+ """Close the given surfaces, then give the lead its width back.
221
+
222
+ The width recovery belongs here rather than to teardown alone. cmux hands the
223
+ freed width to a neighbour it picks, and that neighbour is not always the
224
+ lead — so a round boundary that closed panes and stopped there leaves the
225
+ lead squeezed for exactly the stretch the user spends reading it.
226
+ """
166
227
  from .adapters.runtime.assembly import port_for, runtime_chain
167
228
  from .domain.worker_runtime import RuntimeHandle, SURFACE_CMUX_PANE
168
229
 
@@ -178,9 +239,6 @@ def _teardown(args) -> int:
178
239
  chain[0].restore_lead()
179
240
  except (OSError, subprocess.SubprocessError, RuntimeError) as exc:
180
241
  print(f"okstra team: could not restore the lead's width: {exc}", file=sys.stderr)
181
- _mark_teardown_errors(team_state_path)
182
- _emit_teardown(args.json, panes)
183
- return 0
184
242
 
185
243
 
186
244
  def _observe_lead_session_from_manifest(
@@ -240,10 +298,19 @@ def _await_payload(plan: DispatchPlan, completed: bool) -> dict[str, Any]:
240
298
 
241
299
 
242
300
  def _reclaimable_panes(
243
- manifest: Mapping[str, Any], team_state: Mapping[str, Any]
301
+ manifest: Mapping[str, Any],
302
+ team_state: Mapping[str, Any],
303
+ *,
304
+ finished_only: bool = False,
244
305
  ) -> list[dict[str, str]]:
245
306
  """Everything this run owns and may close.
246
307
 
308
+ `finished_only` is what separates a round boundary from the end of the run.
309
+ Teardown closes every recorded surface because no dispatch is expected to
310
+ continue past it. Mid-round the live workers' surfaces must survive, so
311
+ reclaim asks for the finished ones only — closing an `in-progress` surface
312
+ kills that worker and the round has no result to show for it.
313
+
247
314
  The recorded ids are the only candidates. There is no per-pane tag API to
248
315
  sweep with, and scanning by title would be worse than nothing: cmux labels
249
316
  its own agent surfaces with the same glyph the harness uses for a teammate
@@ -254,8 +321,11 @@ def _reclaimable_panes(
254
321
  seen: set[str] = set()
255
322
  panes: list[dict[str, str]] = []
256
323
  for record in team_state.get("workerDispatches", []):
257
- if isinstance(record, dict):
258
- _append_pane(panes, seen, str(record.get("paneId", "")), "worker")
324
+ if not isinstance(record, dict):
325
+ continue
326
+ if finished_only and record.get("status") not in TERMINAL_WORKER_STATUSES:
327
+ continue
328
+ _append_pane(panes, seen, str(record.get("paneId", "")), "worker")
259
329
  if _is_cmux_run(manifest):
260
330
  return _still_open_surfaces(panes)
261
331
  return panes
@@ -295,7 +365,7 @@ def _mark_teardown_errors(team_state_path: Path) -> None:
295
365
  def mark(payload: dict[str, Any]) -> bool:
296
366
  changed = False
297
367
  for record in payload.get("workerDispatches", []):
298
- if isinstance(record, dict) and record.get("status") not in _TERMINAL_STATUSES:
368
+ if isinstance(record, dict) and record.get("status") not in TERMINAL_WORKER_STATUSES:
299
369
  record["status"] = "error"
300
370
  record["reason"] = TEARDOWN_BEFORE_TERMINAL_REASON
301
371
  changed = True
@@ -304,7 +374,7 @@ def _mark_teardown_errors(team_state_path: Path) -> None:
304
374
  mutate_team_state(team_state_path, mark)
305
375
 
306
376
 
307
- def _emit_teardown(as_json: bool, panes: list[dict[str, str]]) -> None:
377
+ def _emit_panes(as_json: bool, panes: list[dict[str, str]]) -> None:
308
378
  if as_json:
309
379
  _print_json({"panes": panes})
310
380
  return
@@ -16,9 +16,8 @@ from typing import Optional, Sequence
16
16
 
17
17
  # container watcher/tail pane 전용 태그. 이 태그가 붙은 pane 은 세션 종료 후에도
18
18
  # 생존한다 — watcher/tail 의 "세션 후 생존" 불변식이다. 예전에는 SessionEnd 의
19
- # `okstra-trace-cleanup.sh --reap` 이 다른 태그만 스캔한다는 사실이 그 생존을
20
- # 지탱했지만, 지금은 그 모드와 훅 자체가 없어 pane 을 세션 경계에서 회수하는
21
- # 주체가 아예 없다. 회수는 `down` / `stop-watcher` 의 스코프 reap 뿐이다.
19
+ # 태그 스캔이 다른 태그만 본다는 사실이 그 생존을 지탱했지만, 지금은 그 스캔과
20
+ # 훅과 스크립트 자체가 없어 pane 을 세션 경계에서 회수하는 주체가 아예 없다. 회수는 `down` / `stop-watcher` 의 스코프 reap 뿐이다.
22
21
  CONTAINER_TAG_OPTION = "@okstra_container_run"
23
22
 
24
23
 
@@ -21,7 +21,8 @@ from typing import Optional
21
21
  from okstra_ctl.report_views import (
22
22
  PLAN_DECISION_APPROVED,
23
23
  normalize_direction_selection_identity,
24
- serialize_user_response, UserResponseEntry, UserPlanDecision, infer_run_meta,
24
+ serialize_user_response, UserResponseEntry, UserPlanDecision,
25
+ UserReportAuthoring, infer_run_meta,
25
26
  parse_expected_form_options,
26
27
  )
27
28
  from okstra_ctl.report_view_artifacts import user_responses_dir_for_report
@@ -669,7 +670,8 @@ def show_open_rows(report_path: Path) -> dict:
669
670
 
670
671
  def write_sidecar(report_path: Path, answers: list[dict],
671
672
  plan_decision: Optional[dict], created_at: str,
672
- task_key: str = "") -> Path:
673
+ task_key: str = "",
674
+ report_authoring: Optional[dict] = None) -> Path:
673
675
  run_meta = infer_run_meta(report_path, task_key=task_key or None)
674
676
  out_dir = user_responses_dir_for_report(report_path)
675
677
  out_dir.mkdir(parents=True, exist_ok=True)
@@ -690,9 +692,15 @@ def write_sidecar(report_path: Path, answers: list[dict],
690
692
  status=plan_decision["status"],
691
693
  implementation_option=plan_decision.get("implementationOption", ""),
692
694
  reason=plan_decision.get("reason", ""))
695
+ authoring = None
696
+ if report_authoring and report_authoring.get("status"):
697
+ authoring = UserReportAuthoring(
698
+ status=report_authoring["status"],
699
+ reason=report_authoring.get("reason", ""))
693
700
  sidecar.write_text(
694
701
  serialize_user_response(run_meta=run_meta, entries=list(merged.values()),
695
- created_at=created_at, plan_decision=decision),
702
+ created_at=created_at, plan_decision=decision,
703
+ report_authoring=authoring),
696
704
  encoding="utf-8")
697
705
  return sidecar
698
706
 
@@ -715,6 +723,11 @@ def main(argv: Optional[list[str]] = None) -> int:
715
723
  pw.add_argument(
716
724
  "--plan-decision", default="",
717
725
  help='JSON plan decision, e.g. {"status":"rejected","reason":"..."}')
726
+ pw.add_argument(
727
+ "--report-authoring", default="",
728
+ help='JSON report-authoring permission, e.g. '
729
+ '{"status":"approved","reason":"report-writer failed twice"}. '
730
+ 'Only the user may grant this; the lead cannot write it for itself.')
718
731
  pw.add_argument("--task-key", default="", help="task-key from list/show context")
719
732
 
720
733
  ns = parser.parse_args(argv)
@@ -728,9 +741,12 @@ def main(argv: Optional[list[str]] = None) -> int:
728
741
  if ns.cmd == "write":
729
742
  answers = json.loads(ns.answers)
730
743
  decision = json.loads(ns.plan_decision) if ns.plan_decision else None
744
+ authoring = (
745
+ json.loads(ns.report_authoring) if ns.report_authoring else None
746
+ )
731
747
  created_at = dt.datetime.now(dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
732
748
  p = write_sidecar(Path(ns.report), answers, decision, created_at,
733
- task_key=ns.task_key)
749
+ task_key=ns.task_key, report_authoring=authoring)
734
750
  json.dump({"sidecar": str(p)}, sys.stdout, ensure_ascii=False)
735
751
  return 0
736
752
  return 1
@@ -225,8 +225,8 @@ class _AbnormalExit:
225
225
  their default disposition ends the process outright, and nothing in this
226
226
  file runs (measured — a bash ``trap … EXIT`` does fire on SIGTERM, which is
227
227
  why the shell wrappers needed no equivalent of this class). Those two are
228
- the common abnormal exits: a pane kill, ``okstra-trace-cleanup.sh``, session
229
- teardown. Without this the sidecar stays at ``started`` and
228
+ the common abnormal exits: a pane close, ``okstra team reclaim`` /
229
+ ``okstra team teardown``, session teardown. Without this the sidecar stays at ``started`` and
230
230
  ``worker_liveness`` reads a dead worker as a working one.
231
231
 
232
232
  SIGKILL and a host crash remain uncovered because nothing can cover them. A
@@ -175,7 +175,15 @@ def planned_paths_from_run_manifest(
175
175
  active_value = manifest.get("activeRunContextPath")
176
176
  if not isinstance(active_value, str) or not active_value:
177
177
  raise WritePolicyError("implementer write policy has no active run context")
178
- active = _read_json(_rooted(project_root, active_value), "active run context")
178
+ # `compact_active_run_context` drops `sourceArtifacts` and stores path
179
+ # hints instead; the hydrator is what puts the block back. Reading the file
180
+ # raw always saw `None` here and reported the run as having no approved
181
+ # stage authority, which blocked every implementer dispatch.
182
+ from .path_hints import hydrate_active_run_context
183
+
184
+ active = hydrate_active_run_context(
185
+ _read_json(_rooted(project_root, active_value), "active run context")
186
+ )
179
187
  source = active.get("sourceArtifacts")
180
188
  run_inputs_value = source.get("runInputsPath") if isinstance(source, Mapping) else None
181
189
  run = active.get("run")
@@ -191,6 +191,27 @@
191
191
  ],
192
192
  "description": "Lead-authored fallback is only valid for release-handoff or recorded report-writer dispatch failure."
193
193
  },
194
+ "leadAuthoredFallback": {
195
+ "type": "object",
196
+ "description": "Why this run took the lead-authored fallback and who permitted it. Present exactly when reportAuthor is `Okstra lead` on a task type other than release-handoff. The approval passes the gate but does not retire it: this block is what a later reader sees, so the run does not read as a normal report-writer run once the sidecar is out of view.",
197
+ "required": [
198
+ "dispatchFailureReason",
199
+ "approvalSidecar"
200
+ ],
201
+ "additionalProperties": false,
202
+ "properties": {
203
+ "dispatchFailureReason": {
204
+ "type": "string",
205
+ "minLength": 1,
206
+ "description": "The reason recorded on the failed report-writer dispatch row, verbatim."
207
+ },
208
+ "approvalSidecar": {
209
+ "type": "string",
210
+ "minLength": 1,
211
+ "description": "Project-relative path of the user-responses sidecar carrying the approving `## REPORT AUTHORING` block."
212
+ }
213
+ }
214
+ },
194
215
  "leadModel": {
195
216
  "type": "string",
196
217
  "minLength": 1
@@ -6999,7 +7020,9 @@
6999
7020
  "enum": [
7000
7021
  "Claude Code",
7001
7022
  "Codex",
7002
- "Antigravity"
7023
+ "Antigravity",
7024
+ "Grok",
7025
+ "Kimi"
7003
7026
  ]
7004
7027
  },
7005
7028
  "role": {
@@ -239,9 +239,9 @@ okstra config set pr-template-path "<value>" --scope global
239
239
 
240
240
  If an action has an unknown `command`, `key`, or `scope`, stop and report the wizard output instead of inventing a command.
241
241
 
242
- Before rendering the next phase's bundle — and between worker rounds within a phase (reverify/critic/gapverify batches), after you have collected that round's results and token usage and before you dispatch the next round — reclaim the prior round's completed teammate panes so they do not accumulate, in two passes and adding `--keep report-writer-worker` to **both** whenever the report writer is still in flight. First source the count: `$HOME/.okstra/bin/okstra-trace-cleanup.sh --list --run-dir "<RUN_DIR>" [--keep report-writer-worker]` never kills and prints one `<pane_id>\t<pane_title>` line per pane it would reclaim — count those lines as `<n>`. Then run the same command **without** `--list` to perform the reclaim, and emit `PROGRESS: phase-batch-cleanup panes=<n>` with that count at the batch boundary. Call both passes after collecting results and before the next dispatch so no in-flight worker pane is caught. This `tmux kill-pane`s the harness teammate panes; `shutdown_request` alone only idles the agent and never frees the pane, so it stays part of the run-end sequence for roster/token hygiene. `<RUN_DIR>` is the current (or just-finished) run's directory; its recorded `state/lead-pane.id` scopes the lead's session and the lead pane is never killed. In a non-tmux session there are no panes and the script is a silent no-op.
242
+ Before rendering the next phase's bundle — and between worker rounds within a phase (reverify/critic/gapverify batches), after you have collected that round's results and token usage and before you dispatch the next round — close the panes of the dispatches that finished in the prior round so they do not accumulate, in two passes. First count: `okstra team reclaim --project-root <projectRoot> --run-manifest <RUN_MANIFEST_PATH> --dry-run` closes nothing and prints one `<paneId>\t<kind>` line per pane it would close — count those lines as `<n>`. Then run the same command **without** `--dry-run` to close them, and emit `PROGRESS: phase-batch-cleanup panes=<n>` with that count at the batch boundary. The command reads each dispatch's recorded status, so an in-progress worker keeps its pane whichever moment you call it. It closes only the panes okstra opened and recorded — a pane the harness opened for its own teammate carries no recorded id and is not okstra's to close. `shutdown_request` alone only idles the agent and frees no pane, so it stays part of the run-end sequence for roster/token hygiene. A `cli-wrapper` run holds no pane at all, so `<n>` is `0` — still emit the checkpoint.
243
243
 
244
- Before you ask the user for any approval, clarification, or decision after workers have been dispatched, run the same reclaim first: `$HOME/.okstra/bin/okstra-trace-cleanup.sh --list --run-dir "<RUN_DIR>" [--keep report-writer-worker]` to count the panes, then the same command without `--list` to close them, emit `PROGRESS: phase-gate-cleanup panes=<n>`, and `TaskStop` each completed worker. A `TaskStop` by itself idles the task but leaves the pane open — the `trace-cleanup` call is what actually closes it. This keeps a user gate from being shown while finished worker panes remain; in-flight workers and an in-flight report writer are preserved.
244
+ Before you ask the user for any approval, clarification, or decision after workers have been dispatched, run the same two passes first: `okstra team reclaim … --dry-run` to count the panes, then the same command without `--dry-run` to close them, emit `PROGRESS: phase-gate-cleanup panes=<n>`, and `TaskStop` each completed worker. A `TaskStop` by itself idles the task but leaves the pane open — the `team reclaim` call is what closes it. This keeps a user gate from being shown while finished worker panes remain; in-progress dispatches keep their panes.
245
245
 
246
246
  Build the `okstra render-bundle` invocation from `outcome.renderArgv`, passing every token verbatim and in order (including empty strings — they are intentional `use phase default` markers).
247
247
 
@@ -359,7 +359,7 @@ Queue = the topologically-sorted stage list from splitting `orchestration.chainS
359
359
 
360
360
  1. Call Step 5's `render-bundle` with the same arguments but `--stage N` (the base commit is auto-computed by prepare from the predecessor's done `head_commit`, so do not pass it by hand). Step 5's blocking local conformance waiver offer·concurrent-run detection·git-reconcile gates apply identically to each stage's `render-bundle`.
361
361
  2. As in Step 6, become the host-native Okstra lead and run that stage's Phase 1–7 inline. Phase 6's lead post-stage persistence appends that stage's `status:"done"` row to `runs/<plan-task-key>/consumers.jsonl` (per the implementation profile directive).
362
- 3. After confirming that `done` row was written, reclaim the completed teammate panes of the stage you just finished: run `$HOME/.okstra/bin/okstra-trace-cleanup.sh --run-dir "<the run dir of the stage you just completed>"` (add `--keep report-writer-worker` if the report writer is still in flight). Then move to the next stage. A `status:"failed"` row instead of `done` means the stage ended `FAIL` — reclaim the panes the same way, then stop the queue per "Stage ended FAIL" above.
362
+ 3. After confirming that `done` row was written, close the panes of the stage you just finished: run `okstra team teardown --project-root <projectRoot> --run-manifest <that stage's RUN_MANIFEST_PATH>`. That stage's run is over, so this is the run-end command rather than the round-boundary one. Then move to the next stage. A `status:"failed"` row instead of `done` means the stage ended `FAIL` — close the panes the same way, then stop the queue per "Stage ended FAIL" above.
363
363
  4. One-line report at each stage start/finish: `stage N/<total> start` / `stage N done → next K`.
364
364
 
365
365
  Once the whole queue is consumed, end the chain and report completion to the user.
@@ -34,7 +34,8 @@ schema-version: {{ schemaVersion | yaml_scalar }}
34
34
  - Task Type: `{{ header.taskType }}`
35
35
  - Report Owner: `{{ header.reportOwner }}`
36
36
  - Report Author: `{{ header.reportAuthor }}`
37
- - Lead Model: `{{ header.leadModel | model_detail }}`
37
+ {% if header.leadAuthoredFallback %}- Lead-authored fallback: report-writer dispatch failed — `{{ header.leadAuthoredFallback.dispatchFailureReason }}`; permitted by the user in `{{ header.leadAuthoredFallback.approvalSidecar }}`
38
+ {% endif %}- Lead Model: `{{ header.leadModel | model_detail }}`
38
39
  - Okstra Version: `{{ header.okstraVersion }}`
39
40
 
40
41
  ## AI Handoff Summary
@@ -121,8 +121,7 @@
121
121
  "seq": runMeta.seq,
122
122
  "source-report": runMeta.source_report,
123
123
  "source-data": sourceData,
124
- "source-data-sha256": dataSha256,
125
- "source-md-sha256": markdownSha256
124
+ "source-data-sha256": dataSha256
126
125
  } | tojson }}</script>
127
126
  <script>{{ js | safe }}</script>
128
127
  </body>