okstra 0.209.0 → 0.209.2

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 (44) 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 +4 -4
  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 +93 -25
  19. package/runtime/python/okstra_ctl/convergence.py +29 -10
  20. package/runtime/python/okstra_ctl/dispatch_checkpoints.py +93 -3
  21. package/runtime/python/okstra_ctl/dispatch_core.py +18 -7
  22. package/runtime/python/okstra_ctl/initial_prompt_materialization.py +16 -0
  23. package/runtime/python/okstra_ctl/phases/implementation_planning/authoring.py +70 -2
  24. package/runtime/python/okstra_ctl/phases/implementation_planning/instructions/plan-body-verification.md +21 -16
  25. package/runtime/python/okstra_ctl/phases/implementation_planning/plan_body.py +11 -9
  26. package/runtime/python/okstra_ctl/phases/implementation_planning/wizard.py +79 -17
  27. package/runtime/python/okstra_ctl/plan_items.py +4 -2
  28. package/runtime/python/okstra_ctl/render.py +4 -0
  29. package/runtime/python/okstra_ctl/report_finalize.py +19 -7
  30. package/runtime/python/okstra_ctl/run.py +15 -1
  31. package/runtime/python/okstra_ctl/team.py +6 -4
  32. package/runtime/python/okstra_ctl/user_response.py +22 -0
  33. package/runtime/python/okstra_ctl/wizard/cli.py +0 -2
  34. package/runtime/python/okstra_ctl/wizard/engine.py +33 -46
  35. package/runtime/python/okstra_ctl/wizard/registry.py +2 -2
  36. package/runtime/python/okstra_ctl/wizard/roles.py +2 -8
  37. package/runtime/python/okstra_ctl/wizard/steps_identity.py +7 -5
  38. package/runtime/python/okstra_ctl/wizard/steps_plan.py +1 -1
  39. package/runtime/python/okstra_ctl/worker_audit_ledger.py +35 -24
  40. package/runtime/python/okstra_ctl/worker_prompt_body.py +6 -1
  41. package/runtime/python/okstra_ctl/worker_prompt_policy.py +13 -0
  42. package/runtime/python/okstra_ctl/write_policy.py +16 -0
  43. package/runtime/skills/okstra-run/SKILL.md +3 -3
  44. 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.
@@ -450,6 +502,7 @@ def spawn_worker_surface(
450
502
  command: Sequence[str],
451
503
  title: str,
452
504
  owned_surface_ids: Collection[str],
505
+ recorded: LeadLocation | None,
453
506
  ) -> str:
454
507
  """Start one worker beside the lead and return its surface UUID.
455
508
 
@@ -462,7 +515,7 @@ def spawn_worker_surface(
462
515
  user and may hold anything.
463
516
  """
464
517
  panes = list_panes(workspace)
465
- lead = _lead_pane(panes)
518
+ lead = _lead_pane(panes, recorded)
466
519
  placement = plan_worker_placement(
467
520
  panes,
468
521
  lead_pane_id=lead.pane_id,
@@ -472,7 +525,7 @@ def spawn_worker_surface(
472
525
  try:
473
526
  surface_uuid = _create_surface(workspace, placement, target)
474
527
  except (RuntimeError, OSError, subprocess.SubprocessError):
475
- _restore_lead_after_failed_spawn(workspace, owned_surface_ids)
528
+ _restore_lead_after_failed_spawn(workspace, owned_surface_ids, recorded)
476
529
  raise
477
530
  try:
478
531
  renamed = rename_surface(surface_uuid, title)
@@ -480,8 +533,8 @@ def spawn_worker_surface(
480
533
  raise RuntimeError(renamed.stderr.strip() or "cmux could not name the worker")
481
534
  _exec_worker(surface_uuid, cwd=cwd, command=command)
482
535
  owned = (*owned_surface_ids, surface_uuid)
483
- _size_lead_pane(workspace, owned)
484
- _equalize_worker_heights(workspace, owned)
536
+ _size_lead_pane(workspace, owned, recorded)
537
+ _equalize_worker_heights(workspace, owned, recorded)
485
538
  except (RuntimeError, OSError, subprocess.SubprocessError) as exc:
486
539
  # 호출이 반환되기 전에는 명부에 창 식별자가 없다. 여기서 정리하지
487
540
  # 않으면 대체 실행으로 전환된 뒤에도 회수할 수 없는 창이 남는다.
@@ -494,13 +547,15 @@ def spawn_worker_surface(
494
547
  f"{exc}; cleanup failed for cmux surface {surface_uuid}: {cleanup_error}"
495
548
  ) from exc
496
549
  finally:
497
- _restore_lead_after_failed_spawn(workspace, owned_surface_ids)
550
+ _restore_lead_after_failed_spawn(workspace, owned_surface_ids, recorded)
498
551
  raise
499
552
  return surface_uuid
500
553
 
501
554
 
502
555
  def _restore_lead_after_failed_spawn(
503
- workspace: str, owned_surface_ids: Collection[str]
556
+ workspace: str,
557
+ owned_surface_ids: Collection[str],
558
+ recorded: LeadLocation | None,
504
559
  ) -> None:
505
560
  """실패한 분할이 리드에서 떼어 간 폭을 돌려준다.
506
561
 
@@ -517,7 +572,7 @@ def _restore_lead_after_failed_spawn(
517
572
  중이고, 원장에 남아야 할 문장은 그쪽이다.
518
573
  """
519
574
  try:
520
- _size_lead_pane(workspace, owned_surface_ids)
575
+ _size_lead_pane(workspace, owned_surface_ids, recorded)
521
576
  except (RuntimeError, OSError, subprocess.SubprocessError):
522
577
  return
523
578
 
@@ -561,7 +616,7 @@ def close_surface(surface_uuid: str) -> None:
561
616
  return
562
617
 
563
618
 
564
- def restore_lead_width() -> None:
619
+ def restore_lead_width(recorded: LeadLocation | None) -> None:
565
620
  """Put the lead back on its target width once its workers are reclaimed.
566
621
 
567
622
  Closing a pane hands its width to whichever neighbour cmux picks, and that
@@ -582,10 +637,10 @@ def restore_lead_width() -> None:
582
637
  failed to close counts as a stranger's and keeps its width, which is the
583
638
  safe way to be wrong here.
584
639
  """
585
- workspace = resolve_lead_workspace()
640
+ workspace = resolve_lead_workspace(recorded)
586
641
  if not workspace:
587
642
  return
588
- _size_lead_pane(workspace, ())
643
+ _size_lead_pane(workspace, (), recorded)
589
644
 
590
645
 
591
646
  def socket_reachability() -> str:
@@ -614,7 +669,7 @@ def socket_reachability() -> str:
614
669
  probe.close()
615
670
 
616
671
 
617
- def unreachable_reason() -> str:
672
+ def unreachable_reason(recorded: LeadLocation | None) -> str:
618
673
  """Why cmux cannot be reached from here, or "" when it can.
619
674
 
620
675
  `environment` is the case a socket probe alone cannot see: a sandbox that
@@ -629,7 +684,7 @@ def unreachable_reason() -> str:
629
684
  return LOST_DENIED
630
685
  if reachability != SOCKET_OK:
631
686
  return LOST_GONE
632
- return LOST_NOTHING if resolve_lead_workspace() else LOST_GONE
687
+ return LOST_NOTHING if resolve_lead_workspace(recorded) else LOST_GONE
633
688
 
634
689
 
635
690
  def unreachable_advice(reason: str) -> tuple[str, str]:
@@ -799,7 +854,11 @@ def _exec_worker(surface_uuid: str, *, cwd: Path, command: Sequence[str]) -> Non
799
854
  raise RuntimeError(started.stderr.strip() or "cmux could not start the worker")
800
855
 
801
856
 
802
- 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:
803
862
  """Move the lead's border to its share of the workspace.
804
863
 
805
864
  This is the horizontal border okstra places. The first worker column
@@ -826,7 +885,7 @@ def _size_lead_pane(workspace: str, owned_surface_ids: Collection[str]) -> None:
826
885
  # rather than leave it where the user last saw it.
827
886
  return
828
887
  panes = _panes_from(payload)
829
- lead = _lead_pane(panes)
888
+ lead = _lead_pane(panes, recorded)
830
889
  target = lead_target_width(
831
890
  panes,
832
891
  lead_pane_id=lead.pane_id,
@@ -863,7 +922,9 @@ def _size_lead_pane(workspace: str, owned_surface_ids: Collection[str]) -> None:
863
922
 
864
923
 
865
924
  def _equalize_worker_heights(
866
- workspace: str, owned_surface_ids: Collection[str]
925
+ workspace: str,
926
+ owned_surface_ids: Collection[str],
927
+ recorded: LeadLocation | None,
867
928
  ) -> None:
868
929
  """Give every owned worker the same height in the first column.
869
930
 
@@ -875,7 +936,7 @@ def _equalize_worker_heights(
875
936
  """
876
937
  for _ in range(MAX_WORKER_HEIGHT_MOVES):
877
938
  panes = list_panes(workspace)
878
- lead = _lead_pane(panes)
939
+ lead = _lead_pane(panes, recorded)
879
940
  workers = [
880
941
  pane
881
942
  for pane in panes
@@ -896,7 +957,14 @@ def _equalize_worker_heights(
896
957
  )
897
958
 
898
959
 
899
- 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")
900
968
  caller_pane_ref = identify_caller().get("pane_ref", "")
901
969
  for pane in panes:
902
970
  if caller_pane_ref and pane.ref == caller_pane_ref:
@@ -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
 
@@ -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
@@ -114,6 +115,7 @@ from .seeding import installed_version
114
115
  from .worker_audit_ledger import (
115
116
  check_worker_results_audit,
116
117
  parse_worker_result_name,
118
+ result_file_audit_failures,
117
119
  )
118
120
  from .worker_artifacts import worker_provider_id
119
121
  from .worker_prompt_body import REPORT_WRITER_WORKER_ID
@@ -121,7 +123,7 @@ from .worker_prompt_headers import (
121
123
  WorkerPromptHeaderError,
122
124
  resolve_errors_log_path,
123
125
  )
124
- from .worker_artifact_paths import audit_sidecar_rel
126
+ from .worker_artifact_paths import WorkerArtifactPathError, audit_sidecar_rel
125
127
  from .wrapper_status import (
126
128
  log_path_for_prompt,
127
129
  mutation_snapshot_path_for_prompt,
@@ -1992,7 +1994,10 @@ def _ensure_runtime_chain(plan: DispatchPlan) -> DispatchPlan:
1992
1994
  if plan.runtime_chain:
1993
1995
  return plan
1994
1996
  from .adapters.runtime.assembly import runtime_chain as build_chain
1995
- return replace(plan, runtime_chain=build_chain(plan.default_backend))
1997
+ return replace(
1998
+ plan,
1999
+ runtime_chain=build_chain(plan.default_backend, manifest=plan.manifest),
2000
+ )
1996
2001
 
1997
2002
 
1998
2003
  def _spawn_request(
@@ -2040,6 +2045,7 @@ def _start_job(plan: DispatchPlan, job: WorkerJob) -> WorkerHandle:
2040
2045
  print(
2041
2046
  f"DEGRADED {job.role}: {degraded_from} -> {runtime.surface} "
2042
2047
  f"({runtime.fallback_reason or 'no reason reported'})",
2048
+ file=sys.stderr,
2043
2049
  flush=True,
2044
2050
  )
2045
2051
  completed = None
@@ -2727,11 +2733,16 @@ def _audit_sidecar_failures(job: WorkerJob) -> tuple[str, ...]:
2727
2733
  """
2728
2734
  parsed = parse_worker_result_name(job.worker_result_path.name)
2729
2735
  if parsed is None:
2730
- return (
2731
- f"worker result `{job.worker_result_path.name}` is not a canonical "
2732
- f"`<role>-worker-<task-type>-<seq>.md` name, so the audit-sidecar "
2733
- f"contract could not be checked",
2734
- )
2736
+ # reverify·critic 결과는 `<role>-worker-reverify-r<N>-…` 처럼 run 단위
2737
+ # 스캔이 못 읽는 이름이다. 사이드카 이름은 같은 규칙으로 나오므로 이 파일만 본다.
2738
+ try:
2739
+ audit_path = Path(audit_sidecar_rel(str(job.worker_result_path)))
2740
+ except WorkerArtifactPathError:
2741
+ return (
2742
+ f"worker result `{job.worker_result_path.name}` has no "
2743
+ f"`-worker-` token, so the audit-sidecar contract could not be checked",
2744
+ )
2745
+ return tuple(result_file_audit_failures(job.worker_result_path, audit_path))
2735
2746
  return tuple(
2736
2747
  check_worker_results_audit(
2737
2748
  job.worker_result_path.parent.parent,
@@ -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: