okstra 0.208.0 → 0.209.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/docs/architecture.md +1 -1
  2. package/docs/cli.md +9 -1
  3. package/package.json +1 -1
  4. package/runtime/BUILD.json +2 -2
  5. package/runtime/prompts/duties/business-flow-investigator.json +1 -1
  6. package/runtime/prompts/launch.template.md +1 -1
  7. package/runtime/prompts/lead/adapters/cmux.md +1 -1
  8. package/runtime/prompts/lead/convergence.md +2 -2
  9. package/runtime/prompts/lead/okstra-lead-contract.md +5 -5
  10. package/runtime/prompts/wizard/prompts.ko.json +15 -14
  11. package/runtime/python/okstra_ctl/adapters/hosts/capability_adapter.py +15 -4
  12. package/runtime/python/okstra_ctl/adapters/runtime/assembly.py +7 -3
  13. package/runtime/python/okstra_ctl/adapters/runtime/cmux.py +8 -4
  14. package/runtime/python/okstra_ctl/agent/activity.py +2 -2
  15. package/runtime/python/okstra_ctl/analysis_packet.py +7 -0
  16. package/runtime/python/okstra_ctl/business_flow/engine.py +2 -2
  17. package/runtime/python/okstra_ctl/business_flow/source.py +35 -2
  18. package/runtime/python/okstra_ctl/cmux.py +114 -41
  19. package/runtime/python/okstra_ctl/convergence.py +29 -10
  20. package/runtime/python/okstra_ctl/coverage_census.py +8 -1
  21. package/runtime/python/okstra_ctl/dispatch_checkpoints.py +93 -3
  22. package/runtime/python/okstra_ctl/dispatch_core.py +6 -1
  23. package/runtime/python/okstra_ctl/initial_prompt_materialization.py +16 -0
  24. package/runtime/python/okstra_ctl/phases/implementation_planning/authoring.py +70 -2
  25. package/runtime/python/okstra_ctl/phases/implementation_planning/instructions/plan-body-verification.md +21 -16
  26. package/runtime/python/okstra_ctl/phases/implementation_planning/plan_body.py +11 -9
  27. package/runtime/python/okstra_ctl/phases/implementation_planning/wizard.py +79 -17
  28. package/runtime/python/okstra_ctl/phases/requirements_discovery/profile.md +4 -0
  29. package/runtime/python/okstra_ctl/plan_items.py +4 -2
  30. package/runtime/python/okstra_ctl/render.py +4 -0
  31. package/runtime/python/okstra_ctl/report_finalize.py +22 -0
  32. package/runtime/python/okstra_ctl/run.py +15 -1
  33. package/runtime/python/okstra_ctl/team.py +6 -4
  34. package/runtime/python/okstra_ctl/user_response.py +22 -0
  35. package/runtime/python/okstra_ctl/wizard/cli.py +0 -2
  36. package/runtime/python/okstra_ctl/wizard/engine.py +33 -46
  37. package/runtime/python/okstra_ctl/wizard/registry.py +2 -2
  38. package/runtime/python/okstra_ctl/wizard/roles.py +2 -8
  39. package/runtime/python/okstra_ctl/wizard/steps_identity.py +7 -5
  40. package/runtime/python/okstra_ctl/wizard/steps_plan.py +1 -1
  41. package/runtime/python/okstra_ctl/worker_prompt_body.py +6 -1
  42. package/runtime/python/okstra_ctl/worker_prompt_policy.py +13 -0
  43. package/runtime/python/okstra_ctl/write_policy.py +16 -0
  44. package/runtime/skills/okstra-run/SKILL.md +3 -3
  45. package/runtime/validators/validate-run.py +9 -2
@@ -14,7 +14,7 @@ import socket
14
14
  import subprocess
15
15
  from dataclasses import dataclass
16
16
  from pathlib import Path
17
- from typing import Any, Collection, Sequence
17
+ from typing import Any, Collection, Mapping, Sequence
18
18
 
19
19
  PING_OK = "PONG"
20
20
 
@@ -124,18 +124,63 @@ def cmux_available() -> bool:
124
124
  return False
125
125
  if not _ping_answers():
126
126
  return False
127
- return bool(resolve_lead_workspace())
127
+ return bool(resolve_lead_workspace(None))
128
128
 
129
129
 
130
- def resolve_lead_workspace() -> str:
130
+ @dataclass(frozen=True)
131
+ class LeadLocation:
132
+ """prepare 가 본 리드의 cmux 위치. run manifest 에 기록된다."""
133
+
134
+ workspace_id: str
135
+ surface_id: str
136
+
137
+
138
+ # run manifest 키. 리드 프로세스의 CMUX_* 는 리드를 띄운 터미널의 것이 아닐 수
139
+ # 있다 — Claude Code 가 미리 띄워 둔 bg-spare 를 잡으면 그 프로세스가 만들어질
140
+ # 때의 workspace 를 물려받는다(실측 2026-10-04). prepare 는 사용자가 실행한
141
+ # 터미널에서 돌므로 그 시점 값을 기록하고, 이후 해석은 기록을 먼저 쓴다.
142
+ LEAD_LOCATION_KEY = "cmuxLead"
143
+
144
+
145
+ def lead_location_from_env() -> LeadLocation | None:
146
+ """이 프로세스의 cmux 위치. CMUX_* 두 값 중 하나라도 없으면 None."""
147
+ workspace = _lead_workspace_env()
148
+ surface = os.environ.get("CMUX_SURFACE_ID", "").strip()
149
+ if not workspace or not surface:
150
+ return None
151
+ return LeadLocation(workspace_id=workspace, surface_id=surface)
152
+
153
+
154
+ def lead_location_payload(location: LeadLocation) -> dict[str, str]:
155
+ return {"workspaceId": location.workspace_id, "surfaceId": location.surface_id}
156
+
157
+
158
+ def recorded_lead(manifest: Mapping[str, Any]) -> LeadLocation | None:
159
+ """prepare 가 기록한 리드 위치. 기록이 없는 run(이전 버전 manifest 포함)은 None."""
160
+ block = manifest.get(LEAD_LOCATION_KEY)
161
+ if not isinstance(block, Mapping):
162
+ return None
163
+ workspace = str(block.get("workspaceId") or "").strip()
164
+ surface = str(block.get("surfaceId") or "").strip()
165
+ if not workspace or not surface:
166
+ return None
167
+ return LeadLocation(workspace_id=workspace, surface_id=surface)
168
+
169
+
170
+ def resolve_lead_workspace(recorded: LeadLocation | None) -> str:
131
171
  """The lead's workspace UUID, or "" when it cannot be resolved.
132
172
 
133
- The UUID is read from the environment rather than from `identify`, whose
134
- refs are positional and shift as surfaces open and close. `identify` is
135
- consulted only to prove the environment is current: a process that inherits
136
- a CMUX_* block keeps it verbatim, so those variables can outlive the surface
137
- they name.
173
+ 기록된 위치가 있으면 환경보다 우선하고, 그 surface 가 아직 열려 있을 때만
174
+ 유효하다 — 닫힌 리드 옆에 워커를 열 이유가 없다.
175
+
176
+ Without one, the UUID is read from the environment rather than from
177
+ `identify`, whose refs are positional and shift as surfaces open and close.
178
+ `identify` is consulted only to prove the environment is current: a process
179
+ that inherits a CMUX_* block keeps it verbatim, so those variables can
180
+ outlive the surface they name.
138
181
  """
182
+ if recorded is not None:
183
+ return recorded.workspace_id if _recorded_surface_is_open(recorded) else ""
139
184
  workspace = _lead_workspace_env()
140
185
  if not workspace:
141
186
  return ""
@@ -148,6 +193,13 @@ def _lead_workspace_env() -> str:
148
193
  return os.environ.get("CMUX_WORKSPACE_ID", "").strip()
149
194
 
150
195
 
196
+ def _recorded_surface_is_open(recorded: LeadLocation) -> bool:
197
+ try:
198
+ return recorded.surface_id in open_surface_ids(recorded.workspace_id)
199
+ except (RuntimeError, OSError, subprocess.SubprocessError):
200
+ return False
201
+
202
+
151
203
  @dataclass(frozen=True)
152
204
  class PaneGeometry:
153
205
  """One pane as `pane.list` reports it.
@@ -379,8 +431,8 @@ def plan_worker_height_resize(
379
431
  return None
380
432
 
381
433
 
382
- def shim_free_login_path() -> str:
383
- """The user's login PATH with cmux's per-surface CLI shims removed.
434
+ def shim_free_worker_path() -> str:
435
+ """The lead's inherited PATH with cmux's per-surface CLI shims removed.
384
436
 
385
437
  A cmux pane execs its command through `login … bash --noprofile --norc`, so
386
438
  none of the user's shell rc runs and PATH is cmux's own short list. What that
@@ -389,14 +441,23 @@ def shim_free_login_path() -> str:
389
441
  CLIs — and where a provider cmux does not know has no entry at all, which is
390
442
  a plain exit 127 inside the worker wrapper.
391
443
 
392
- Restoring the login shell's PATH and dropping the shim entries gives the
393
- wrapper what it would see in an ordinary terminal, which is what its own
394
- `command -v <cli>` check expects. Nothing here is provider-specific, so
395
- adding a provider does not touch this path.
444
+ The base is the PATH the lead process inherited, because the cli-wrapper
445
+ runtime (and every automatic retry, which always runs there) spawns with it.
446
+ A pane that resolved PATH from the login shell instead could pick another
447
+ install of the same CLI: on 2026-10-04 (fontsninja-v3-site dev-11118) the
448
+ login shell put Homebrew's codex 0.153.4 first, which rejects `gpt-6.1-sol`,
449
+ while the lead's PATH found nvm's 0.160.0, so every first attempt failed and
450
+ every retry passed. The login shell's PATH is the fallback when the inherited
451
+ one is empty after filtering.
396
452
  """
397
- entries = _login_shell_path().split(os.pathsep)
453
+ path = _without_shims(os.environ.get("PATH", ""))
454
+ return path or _without_shims(_login_shell_path())
455
+
456
+
457
+ def _without_shims(path_value: str) -> str:
398
458
  return os.pathsep.join(
399
- entry for entry in entries if entry and SHIM_DIR_MARKER not in entry
459
+ entry for entry in path_value.split(os.pathsep)
460
+ if entry and SHIM_DIR_MARKER not in entry
400
461
  )
401
462
 
402
463
 
@@ -441,6 +502,7 @@ def spawn_worker_surface(
441
502
  command: Sequence[str],
442
503
  title: str,
443
504
  owned_surface_ids: Collection[str],
505
+ recorded: LeadLocation | None,
444
506
  ) -> str:
445
507
  """Start one worker beside the lead and return its surface UUID.
446
508
 
@@ -453,7 +515,7 @@ def spawn_worker_surface(
453
515
  user and may hold anything.
454
516
  """
455
517
  panes = list_panes(workspace)
456
- lead = _lead_pane(panes)
518
+ lead = _lead_pane(panes, recorded)
457
519
  placement = plan_worker_placement(
458
520
  panes,
459
521
  lead_pane_id=lead.pane_id,
@@ -463,7 +525,7 @@ def spawn_worker_surface(
463
525
  try:
464
526
  surface_uuid = _create_surface(workspace, placement, target)
465
527
  except (RuntimeError, OSError, subprocess.SubprocessError):
466
- _restore_lead_after_failed_spawn(workspace, owned_surface_ids)
528
+ _restore_lead_after_failed_spawn(workspace, owned_surface_ids, recorded)
467
529
  raise
468
530
  try:
469
531
  renamed = rename_surface(surface_uuid, title)
@@ -471,8 +533,8 @@ def spawn_worker_surface(
471
533
  raise RuntimeError(renamed.stderr.strip() or "cmux could not name the worker")
472
534
  _exec_worker(surface_uuid, cwd=cwd, command=command)
473
535
  owned = (*owned_surface_ids, surface_uuid)
474
- _size_lead_pane(workspace, owned)
475
- _equalize_worker_heights(workspace, owned)
536
+ _size_lead_pane(workspace, owned, recorded)
537
+ _equalize_worker_heights(workspace, owned, recorded)
476
538
  except (RuntimeError, OSError, subprocess.SubprocessError) as exc:
477
539
  # 호출이 반환되기 전에는 명부에 창 식별자가 없다. 여기서 정리하지
478
540
  # 않으면 대체 실행으로 전환된 뒤에도 회수할 수 없는 창이 남는다.
@@ -485,13 +547,15 @@ def spawn_worker_surface(
485
547
  f"{exc}; cleanup failed for cmux surface {surface_uuid}: {cleanup_error}"
486
548
  ) from exc
487
549
  finally:
488
- _restore_lead_after_failed_spawn(workspace, owned_surface_ids)
550
+ _restore_lead_after_failed_spawn(workspace, owned_surface_ids, recorded)
489
551
  raise
490
552
  return surface_uuid
491
553
 
492
554
 
493
555
  def _restore_lead_after_failed_spawn(
494
- workspace: str, owned_surface_ids: Collection[str]
556
+ workspace: str,
557
+ owned_surface_ids: Collection[str],
558
+ recorded: LeadLocation | None,
495
559
  ) -> None:
496
560
  """실패한 분할이 리드에서 떼어 간 폭을 돌려준다.
497
561
 
@@ -508,7 +572,7 @@ def _restore_lead_after_failed_spawn(
508
572
  중이고, 원장에 남아야 할 문장은 그쪽이다.
509
573
  """
510
574
  try:
511
- _size_lead_pane(workspace, owned_surface_ids)
575
+ _size_lead_pane(workspace, owned_surface_ids, recorded)
512
576
  except (RuntimeError, OSError, subprocess.SubprocessError):
513
577
  return
514
578
 
@@ -552,7 +616,7 @@ def close_surface(surface_uuid: str) -> None:
552
616
  return
553
617
 
554
618
 
555
- def restore_lead_width() -> None:
619
+ def restore_lead_width(recorded: LeadLocation | None) -> None:
556
620
  """Put the lead back on its target width once its workers are reclaimed.
557
621
 
558
622
  Closing a pane hands its width to whichever neighbour cmux picks, and that
@@ -573,10 +637,10 @@ def restore_lead_width() -> None:
573
637
  failed to close counts as a stranger's and keeps its width, which is the
574
638
  safe way to be wrong here.
575
639
  """
576
- workspace = resolve_lead_workspace()
640
+ workspace = resolve_lead_workspace(recorded)
577
641
  if not workspace:
578
642
  return
579
- _size_lead_pane(workspace, ())
643
+ _size_lead_pane(workspace, (), recorded)
580
644
 
581
645
 
582
646
  def socket_reachability() -> str:
@@ -605,7 +669,7 @@ def socket_reachability() -> str:
605
669
  probe.close()
606
670
 
607
671
 
608
- def unreachable_reason() -> str:
672
+ def unreachable_reason(recorded: LeadLocation | None) -> str:
609
673
  """Why cmux cannot be reached from here, or "" when it can.
610
674
 
611
675
  `environment` is the case a socket probe alone cannot see: a sandbox that
@@ -620,7 +684,7 @@ def unreachable_reason() -> str:
620
684
  return LOST_DENIED
621
685
  if reachability != SOCKET_OK:
622
686
  return LOST_GONE
623
- return LOST_NOTHING if resolve_lead_workspace() else LOST_GONE
687
+ return LOST_NOTHING if resolve_lead_workspace(recorded) else LOST_GONE
624
688
 
625
689
 
626
690
  def unreachable_advice(reason: str) -> tuple[str, str]:
@@ -783,14 +847,18 @@ def _surface_from_split_echo(stdout: str) -> str:
783
847
 
784
848
  def _exec_worker(surface_uuid: str, *, cwd: Path, command: Sequence[str]) -> None:
785
849
  line = worker_command_line(
786
- cwd=cwd, argv=command, path_value=shim_free_login_path()
850
+ cwd=cwd, argv=command, path_value=shim_free_worker_path()
787
851
  )
788
852
  started = run_cmux(["respawn-pane", "--surface", surface_uuid, "--command", line])
789
853
  if started.returncode != 0:
790
854
  raise RuntimeError(started.stderr.strip() or "cmux could not start the worker")
791
855
 
792
856
 
793
- def _size_lead_pane(workspace: str, owned_surface_ids: Collection[str]) -> None:
857
+ def _size_lead_pane(
858
+ workspace: str,
859
+ owned_surface_ids: Collection[str],
860
+ recorded: LeadLocation | None,
861
+ ) -> None:
794
862
  """Move the lead's border to its share of the workspace.
795
863
 
796
864
  This is the horizontal border okstra places. The first worker column
@@ -817,7 +885,7 @@ def _size_lead_pane(workspace: str, owned_surface_ids: Collection[str]) -> None:
817
885
  # rather than leave it where the user last saw it.
818
886
  return
819
887
  panes = _panes_from(payload)
820
- lead = _lead_pane(panes)
888
+ lead = _lead_pane(panes, recorded)
821
889
  target = lead_target_width(
822
890
  panes,
823
891
  lead_pane_id=lead.pane_id,
@@ -854,7 +922,9 @@ def _size_lead_pane(workspace: str, owned_surface_ids: Collection[str]) -> None:
854
922
 
855
923
 
856
924
  def _equalize_worker_heights(
857
- workspace: str, owned_surface_ids: Collection[str]
925
+ workspace: str,
926
+ owned_surface_ids: Collection[str],
927
+ recorded: LeadLocation | None,
858
928
  ) -> None:
859
929
  """Give every owned worker the same height in the first column.
860
930
 
@@ -866,7 +936,7 @@ def _equalize_worker_heights(
866
936
  """
867
937
  for _ in range(MAX_WORKER_HEIGHT_MOVES):
868
938
  panes = list_panes(workspace)
869
- lead = _lead_pane(panes)
939
+ lead = _lead_pane(panes, recorded)
870
940
  workers = [
871
941
  pane
872
942
  for pane in panes
@@ -887,7 +957,14 @@ def _equalize_worker_heights(
887
957
  )
888
958
 
889
959
 
890
- def _lead_pane(panes: Sequence[PaneGeometry]) -> PaneGeometry:
960
+ def _lead_pane(
961
+ panes: Sequence[PaneGeometry], recorded: LeadLocation | None
962
+ ) -> PaneGeometry:
963
+ if recorded is not None:
964
+ for pane in panes:
965
+ if recorded.surface_id in pane.surface_ids:
966
+ return pane
967
+ raise RuntimeError("cmux could not locate the lead's pane in its workspace")
891
968
  caller_pane_ref = identify_caller().get("pane_ref", "")
892
969
  for pane in panes:
893
970
  if caller_pane_ref and pane.ref == caller_pane_ref:
@@ -939,11 +1016,7 @@ def _pane_geometry(entry: dict[str, Any]) -> PaneGeometry:
939
1016
 
940
1017
 
941
1018
  def _login_shell_path() -> str:
942
- """PATH as the user's own login shell resolves it.
943
-
944
- Falls back to the inherited PATH: a worker started with a shim-filtered
945
- inherited PATH is still better than one started with cmux's bare list.
946
- """
1019
+ """PATH as the user's own login shell resolves it, or '' when it cannot run."""
947
1020
  shell = os.environ.get("SHELL", "") or "/bin/sh"
948
1021
  try:
949
1022
  result = subprocess.run(
@@ -954,9 +1027,9 @@ def _login_shell_path() -> str:
954
1027
  check=False,
955
1028
  )
956
1029
  except (OSError, subprocess.SubprocessError):
957
- return os.environ.get("PATH", "")
1030
+ return ""
958
1031
  if result.returncode != 0 or not result.stdout.strip():
959
- return os.environ.get("PATH", "")
1032
+ return ""
960
1033
  return result.stdout.strip()
961
1034
 
962
1035
 
@@ -564,7 +564,9 @@ def _parser() -> argparse.ArgumentParser:
564
564
  return parser
565
565
 
566
566
 
567
- def _emit(operation: str, action: str, path: Path) -> None:
567
+ def _emit(
568
+ operation: str, action: str, path: Path, extra: Mapping[str, Any] | None = None,
569
+ ) -> None:
568
570
  print(
569
571
  json.dumps(
570
572
  {
@@ -572,6 +574,7 @@ def _emit(operation: str, action: str, path: Path) -> None:
572
574
  "operation": operation,
573
575
  "action": action,
574
576
  "path": str(path),
577
+ **(extra or {}),
575
578
  },
576
579
  ensure_ascii=False,
577
580
  )
@@ -1078,14 +1081,21 @@ def _seed(args: argparse.Namespace) -> tuple[str, Path]:
1078
1081
  return decision.action, args.work_state
1079
1082
 
1080
1083
 
1081
- def _plan_round(args: argparse.Namespace) -> tuple[str, Path]:
1084
+ def _plan_round(
1085
+ args: argparse.Namespace,
1086
+ ) -> tuple[str, Path, dict[str, Any]]:
1082
1087
  state = load_owned_json_object(args.work_state)
1083
1088
  errors = validate_working_state(state)
1084
1089
  if errors:
1085
1090
  raise ConvergenceContractError("invalid working state: " + "; ".join(errors))
1086
1091
  plan = plan_next_round(state)
1087
1092
  write_json_atomic(args.plan, plan)
1088
- return str(plan["action"]), args.plan
1093
+ # 리드의 `phase-5.5-convergence round=<N> queue=<count>` 줄이 이 두 값을 그대로
1094
+ # 옮긴다. 리드가 계획 파일을 세면 `queueFindingIds` 와 혼동해 queue=0 을 적었다.
1095
+ return str(plan["action"]), args.plan, {
1096
+ "round": plan["round"],
1097
+ "inputQueueSize": plan["inputQueueSize"],
1098
+ }
1089
1099
 
1090
1100
 
1091
1101
  def _apply_round(args: argparse.Namespace) -> tuple[str, Path]:
@@ -1378,7 +1388,7 @@ def _group_from_fields(
1378
1388
  if not colon or not worker or not item_id:
1379
1389
  raise ConvergenceContractError("Sources must use worker:item-id entries")
1380
1390
  parsed = [(worker, item_id, scalar["evidence"])]
1381
- discovered, source_items = _group_source_provenance(parsed)
1391
+ discovered, source_items = _group_source_provenance(finding_id, parsed)
1382
1392
  origin_worker, separator, origin_item = scalar.get("origin", "").partition(":")
1383
1393
  if not separator or discovered.get(origin_worker, {}).get("itemId") != origin_item:
1384
1394
  raise ConvergenceContractError("Origin must identify one Source item")
@@ -1398,11 +1408,20 @@ def _group_from_fields(
1398
1408
 
1399
1409
 
1400
1410
  def _group_source_provenance(
1411
+ finding_id: str,
1401
1412
  parsed: list[tuple[str, str, str]],
1402
1413
  ) -> tuple[dict[str, dict[str, str]], list[dict[str, str]]]:
1403
- workers = [worker for worker, _item, _evidence in parsed]
1404
- if len(workers) != len(set(workers)):
1405
- raise ConvergenceContractError("duplicate Source worker in one finding")
1414
+ items_by_worker: dict[str, list[str]] = {}
1415
+ for worker, item, _evidence in parsed:
1416
+ items_by_worker.setdefault(worker, []).append(item)
1417
+ for worker, items in items_by_worker.items():
1418
+ if len(items) > 1:
1419
+ raise ConvergenceContractError(
1420
+ f"duplicate Source worker in one finding: {finding_id} has "
1421
+ f"{len(items)} Source lines for `{worker}` ({', '.join(items)}); "
1422
+ "a group takes one Source line per worker, so split the extra "
1423
+ "item(s) into their own group(s) instead of dropping them"
1424
+ )
1406
1425
  discovered = {
1407
1426
  worker: {"itemId": item, "evidence": evidence}
1408
1427
  for worker, item, evidence in parsed
@@ -1754,7 +1773,7 @@ def _settled_critic_result(manifest: Mapping[str, Any]) -> str:
1754
1773
  return latest["resultPath"]
1755
1774
 
1756
1775
 
1757
- def _execute(args: argparse.Namespace) -> tuple[str, Path]:
1776
+ def _execute(args: argparse.Namespace) -> tuple[Any, ...]:
1758
1777
  operations: dict[str, Any] = {
1759
1778
  "prepare-groups": _prepare_groups,
1760
1779
  "seed": _seed,
@@ -1797,7 +1816,7 @@ def main(argv: list[str] | None = None) -> int:
1797
1816
  if args.operation == "census-gapfill-prompt":
1798
1817
  print(_census_gapfill_prompt(args), end="")
1799
1818
  return 0
1800
- action, path = _execute(args)
1819
+ action, path, *extra = _execute(args)
1801
1820
  except (ConvergenceContractError, VerdictBlockError, ReverifyPromptError,
1802
1821
  CriticVerifyPromptError, CensusError,
1803
1822
  json.JSONDecodeError, ValueError) as exc:
@@ -1809,7 +1828,7 @@ def main(argv: list[str] | None = None) -> int:
1809
1828
  if args.operation == "prepare-groups":
1810
1829
  print("Convergence groups\n" + line("Status", "ready") + line("Action", action), end="")
1811
1830
  else:
1812
- _emit(args.operation, action, path)
1831
+ _emit(args.operation, action, path, *extra)
1813
1832
  return 0
1814
1833
 
1815
1834
 
@@ -242,8 +242,15 @@ def _split_verdict_section(text: str) -> tuple[list[str], str]:
242
242
  )
243
243
  if start is None:
244
244
  return [], text
245
+ level = len(lines[start]) - len(lines[start].lstrip("#"))
246
+ # Subheadings (`### EB-001`) group the verdict lines; only a heading at the
247
+ # section's own level or above closes it.
245
248
  end = next(
246
- (index for index in range(start + 1, len(lines)) if _HEADING_RE.match(lines[index])),
249
+ (
250
+ index for index in range(start + 1, len(lines))
251
+ if _HEADING_RE.match(lines[index])
252
+ and len(lines[index]) - len(lines[index].lstrip("#")) <= level
253
+ ),
247
254
  len(lines),
248
255
  )
249
256
  return lines[start + 1:end], "\n".join(lines[:start] + lines[end:])
@@ -9,18 +9,30 @@ dispatch 를 정하고 결과를 확정하는 곳은 `team dispatch` / `team awa
9
9
  워커별 체크포인트는 `initial` dispatch 만 센다. reverify·critic·plan-verify 는
10
10
  리드가 따로 알리는 이후 라운드이고, 여기서 기록하면 activity-contract run 이
11
11
  존재하지 않는 `worker-dispatched` / `worker-completed` 행을 요구받게 된다.
12
+
13
+ activity-contract v1 run 에서는 검증기가 워커별 `phase-4-dispatch` /
14
+ `phase-5-collect` 마다 같은 워커의 `worker-dispatched` / `worker-completed`
15
+ activity 를 요구한다. 체크포인트를 명령이 쓰면 리드는 "activity 먼저, PROGRESS
16
+ 다음" 순서를 지킬 수 없으므로 그 activity 도 여기서 쓴다. summary 는 고정 영어다.
12
17
  """
13
18
  from __future__ import annotations
14
19
 
20
+ import sys
15
21
  from pathlib import Path
16
22
  from typing import Any, Mapping, Sequence
17
23
 
24
+ from .agent.activity import (
25
+ ActivityProjectionError,
26
+ conversation_activity_line,
27
+ record_activity,
28
+ )
18
29
  from .dispatch_state import (
19
30
  TERMINAL_WORKER_STATUSES,
20
31
  dispatch_worker_key,
21
32
  has_recorded_team_create,
22
33
  worker_dispatch_records,
23
34
  )
35
+ from .json_boundary import JsonBoundaryError, load_owned_object
24
36
  from .lead_progress import record_checkpoint
25
37
  from .worker_prompt_body import REPORT_WRITER_WORKER_ID
26
38
 
@@ -44,6 +56,7 @@ def record_dispatch_checkpoints(
44
56
  """
45
57
  if not jobs:
46
58
  return []
59
+ records_activity = _declares_activity_contract(project_root, run_manifest, source)
47
60
  lines: list[str | None] = []
48
61
  if not has_recorded_team_create(team_state):
49
62
  lines.append(record_checkpoint(
@@ -56,6 +69,13 @@ def record_dispatch_checkpoints(
56
69
  worker=job.worker_id,
57
70
  fields=(("model", job.model_execution_value),),
58
71
  ))
72
+ if records_activity:
73
+ lines.append(_record_worker_activity(
74
+ project_root, run_manifest, source=source,
75
+ kind="worker-dispatched", agent=job.worker_id,
76
+ summary=f"Dispatched {job.worker_id} as an initial worker",
77
+ result_path=None, outcome="pending",
78
+ ))
59
79
  dispatches_writer = any(job.worker_id == REPORT_WRITER_WORKER_ID for job in jobs)
60
80
  writer_dispatched_before = bool(
61
81
  worker_dispatch_records(team_state, REPORT_WRITER_WORKER_ID)
@@ -104,18 +124,88 @@ def record_collect_checkpoints(
104
124
  통과했고, 결과가 dispatch 에 연결됐거나(`dispatch_core._finish_attempt`),
105
125
  attempt 가 `error` / `timeout` 으로 닫혔다.
106
126
  """
107
- lines = []
127
+ lines: list[str | None] = []
128
+ records_activity: bool | None = None
108
129
  for key, record in after.items():
109
130
  previous = before.get(key)
110
131
  if previous is not None and _attempt(previous) == _attempt(record):
111
132
  continue
133
+ worker = dispatch_worker_key(record)
134
+ status = str(record.get("status"))
112
135
  lines.append(record_checkpoint(
113
136
  project_root, run_manifest, "phase-5-collect", source=source,
114
- worker=dispatch_worker_key(record),
115
- fields=(("status", str(record.get("status"))),),
137
+ worker=worker, fields=(("status", status),),
116
138
  ))
139
+ if records_activity is None:
140
+ records_activity = _declares_activity_contract(
141
+ project_root, run_manifest, source
142
+ )
143
+ if records_activity:
144
+ lines.append(_record_worker_activity(
145
+ project_root, run_manifest, source=source,
146
+ kind="worker-completed", agent=worker,
147
+ summary=f"Collected the {worker} result: status={status}",
148
+ result_path=_project_relative(project_root, record.get("resultPath")),
149
+ outcome="completed" if status == "completed" else "failed",
150
+ ))
117
151
  return [line for line in lines if line]
118
152
 
119
153
 
154
+ def _declares_activity_contract(
155
+ project_root: Path, run_manifest: str | Path, source: str,
156
+ ) -> bool:
157
+ path = Path(run_manifest)
158
+ if not path.is_absolute():
159
+ path = project_root.resolve() / path
160
+ try:
161
+ manifest = load_owned_object(path, artifact="run manifest")
162
+ except JsonBoundaryError as exc:
163
+ print(f"{source}: could not read activity contract: {exc}", file=sys.stderr)
164
+ return False
165
+ return manifest.get("activityContractVersion") == 1
166
+
167
+
168
+ def _project_relative(project_root: Path, value: Any) -> str | None:
169
+ if not isinstance(value, str) or not value:
170
+ return None
171
+ path = Path(value)
172
+ root = project_root.resolve()
173
+ return str(path.relative_to(root)) if path.is_relative_to(root) else value
174
+
175
+
176
+ def _record_worker_activity(
177
+ project_root: Path,
178
+ run_manifest: str | Path,
179
+ *,
180
+ source: str,
181
+ kind: str,
182
+ agent: str,
183
+ summary: str,
184
+ result_path: str | None,
185
+ outcome: str,
186
+ ) -> str | None:
187
+ """실패 처리는 `record_checkpoint` 와 같다: stderr 에만 남기고, 누락은 검증기가 보고한다."""
188
+ root = project_root.resolve()
189
+ path = Path(run_manifest)
190
+ try:
191
+ event = record_activity(
192
+ root, path if path.is_absolute() else root / path,
193
+ {
194
+ "kind": kind,
195
+ "agent": agent,
196
+ "summary": summary,
197
+ "planItemIds": [],
198
+ "resultPath": result_path,
199
+ "commands": [],
200
+ "evidenceRefs": [],
201
+ "outcome": outcome,
202
+ },
203
+ )
204
+ except (ActivityProjectionError, OSError) as exc:
205
+ print(f"{source}: could not record `{kind}` for `{agent}`: {exc}", file=sys.stderr)
206
+ return None
207
+ return conversation_activity_line(event.details)
208
+
209
+
120
210
  def _attempt(record: Mapping[str, Any]) -> int:
121
211
  return int(record.get("attempt", 1))
@@ -4,6 +4,7 @@ from __future__ import annotations
4
4
  import json
5
5
  import re
6
6
  import subprocess
7
+ import sys
7
8
  import time
8
9
  from dataclasses import dataclass, field, replace
9
10
  from datetime import datetime, timezone
@@ -1992,7 +1993,10 @@ def _ensure_runtime_chain(plan: DispatchPlan) -> DispatchPlan:
1992
1993
  if plan.runtime_chain:
1993
1994
  return plan
1994
1995
  from .adapters.runtime.assembly import runtime_chain as build_chain
1995
- return replace(plan, runtime_chain=build_chain(plan.default_backend))
1996
+ return replace(
1997
+ plan,
1998
+ runtime_chain=build_chain(plan.default_backend, manifest=plan.manifest),
1999
+ )
1996
2000
 
1997
2001
 
1998
2002
  def _spawn_request(
@@ -2040,6 +2044,7 @@ def _start_job(plan: DispatchPlan, job: WorkerJob) -> WorkerHandle:
2040
2044
  print(
2041
2045
  f"DEGRADED {job.role}: {degraded_from} -> {runtime.surface} "
2042
2046
  f"({runtime.fallback_reason or 'no reason reported'})",
2047
+ file=sys.stderr,
2043
2048
  flush=True,
2044
2049
  )
2045
2050
  completed = None
@@ -722,6 +722,7 @@ def _prompt_body(
722
722
  worker.model,
723
723
  plan,
724
724
  _optional_approved_plan_path(context) if plan.include_approved_plan else "",
725
+ _selected_direction_path(context) if plan.include_selected_direction else "",
725
726
  )
726
727
 
727
728
 
@@ -1543,6 +1544,21 @@ def _optional_approved_plan_path(context: _MaterializationContext) -> str:
1543
1544
  return ""
1544
1545
 
1545
1546
 
1547
+ def _selected_direction_path(context: _MaterializationContext) -> str:
1548
+ """The instruction-set snapshot path when prepare wrote one, otherwise empty.
1549
+
1550
+ Only a plan opened from `--selected-direction` has the snapshot; a legacy
1551
+ rerun has none and its prompt drops the line.
1552
+ """
1553
+ packet = instruction_path(context.manifest, context.active_context, "analysisPacketPath")
1554
+ if not packet:
1555
+ return ""
1556
+ snapshot = Path(packet).parent / "selected-direction.json"
1557
+ if not _resolve_input_path(context.project_root, snapshot).is_file():
1558
+ return ""
1559
+ return str(snapshot)
1560
+
1561
+
1546
1562
  def _required_stage_number(context: _MaterializationContext) -> str:
1547
1563
  value = _string_value(_active_run_field(context, "stage"))
1548
1564
  if not value: