okstra 0.209.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 (43) 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 +6 -1
  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_prompt_body.py +6 -1
  40. package/runtime/python/okstra_ctl/worker_prompt_policy.py +13 -0
  41. package/runtime/python/okstra_ctl/write_policy.py +16 -0
  42. package/runtime/skills/okstra-run/SKILL.md +3 -3
  43. 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
@@ -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:
@@ -718,6 +718,21 @@ _PREP_OWNERSHIP_NOTE = (
718
718
  "writer did not produce those fields.\n"
719
719
  )
720
720
 
721
+ # 이 네 필드는 report assembly 가 선택 방향 스냅샷으로 덮어쓰고 검증기가 다른 값을
722
+ # 거절한다(validation._direction_realization_errors). 계획자가 바꿀 수 없는 값에
723
+ # planner-fixable DISAGREE 를 내면 self-fix 로 풀리지 않는 차단이 된다.
724
+ _DIR_SNAPSHOT_OWNED_NOTE = (
725
+ "\nSnapshot-owned fields: `coreMechanism`, `architectureBoundaries`, "
726
+ "`planningInvariants`, and `userConstraints` above are verbatim copies of "
727
+ "the selected-direction snapshot. Report assembly overwrites them from "
728
+ "that snapshot and validation rejects any other value, so the planner "
729
+ "cannot change them. Judge whether the writer-owned realization fields "
730
+ "honour them. When a later user decision (an answered clarification) "
731
+ "departs from them, judge whether the writer-owned fields and the plan "
732
+ "narrative's `supersessionLedger` carry that decision; do not return a "
733
+ "planner-fixable DISAGREE that asks to edit these four fields.\n"
734
+ )
735
+
721
736
 
722
737
  def _step_coordinate(payload: object, field: str) -> int | None:
723
738
  if not isinstance(payload, Mapping):
@@ -784,7 +799,9 @@ def _render_analyser_split(verdicts: object) -> str:
784
799
  continue
785
800
  kind = str(verdict.get("breakageKind") or "").strip()
786
801
  label = f"{token}({kind})" if kind else token
787
- rows.append(f"- `{worker}`: `{label}`\n")
802
+ basis = _prior_vote(verdict).get("basis")
803
+ suffix = f" — `{scalar(basis)}`" if basis else ""
804
+ rows.append(f"- `{worker}`: `{label}`{suffix}\n")
788
805
  if not rows:
789
806
  return ""
790
807
  return "Analyser split:\n" + "".join(rows)
@@ -944,6 +961,25 @@ def _render_prior_round(entry: object) -> str:
944
961
  return "".join(rows)
945
962
 
946
963
 
964
+ # 검증자의 읽기 범위(`**Read scope:**`)는 `## Inputs` 에 열거된 경로만 허용한다.
965
+ # 이 절이 없으면 P-Req 의 originalRequirementId 를 패킷의 요구 문장으로 되짚을
966
+ # 수 없어 범위를 지킨 검증자는 verification-error 를 낸다.
967
+ _INPUT_FIELDS = (
968
+ ("Primary analysis packet", "analysisPacketPath"),
969
+ ("Plan narrative", "reportNarrativePath"),
970
+ )
971
+
972
+
973
+ def _render_inputs(run_manifest: Path) -> str:
974
+ payload = validated_run_authority(run_manifest).payload
975
+ rows = [
976
+ f"- {label}: `{payload[field].strip()}`\n"
977
+ for label, field in _INPUT_FIELDS
978
+ if isinstance(payload.get(field), str) and payload[field].strip()
979
+ ]
980
+ return "\n## Inputs\n\n" + "".join(rows) if rows else ""
981
+
982
+
947
983
  def _prompt(args: argparse.Namespace) -> str:
948
984
  envelope = _load_json_object(
949
985
  _prepared_items_path(args.run_manifest, require_regular=True)
@@ -963,7 +999,11 @@ def _prompt(args: argparse.Namespace) -> str:
963
999
  if isinstance(envelope.get("priorRounds"), Mapping)
964
1000
  else {}
965
1001
  )
966
- rows = ["# Plan verification queue\n", line("Item count", len(items))]
1002
+ rows = [
1003
+ "# Plan verification queue\n",
1004
+ line("Item count", len(items)),
1005
+ _render_inputs(args.run_manifest),
1006
+ ]
967
1007
  for index, item in enumerate(items, 1):
968
1008
  if not isinstance(item, Mapping):
969
1009
  continue
@@ -982,6 +1022,8 @@ def _prompt(args: argparse.Namespace) -> str:
982
1022
  rows.append(_render_prior_steps(item, all_items))
983
1023
  if item_id.startswith("P-Prep-"):
984
1024
  rows.append(_PREP_OWNERSHIP_NOTE)
1025
+ if item_id == "P-Dir-1":
1026
+ rows.append(_DIR_SNAPSHOT_OWNED_NOTE)
985
1027
  split = _render_analyser_split(
986
1028
  (envelope.get("tieSplits") or {}).get(item_id)
987
1029
  if isinstance(envelope.get("tieSplits"), Mapping)
@@ -2270,6 +2312,10 @@ def _narrow_dispatch_queue(
2270
2312
  f"queue does not contain — pass the artifact this round dispatched, "
2271
2313
  f"not another round's"
2272
2314
  )
2315
+ if getattr(args, "append", False) and _queue_holds_open_round_votes(
2316
+ verification, assigned - narrowed, args.round_number
2317
+ ):
2318
+ return narrowed
2273
2319
  verification["dispatchQueue"] = [
2274
2320
  item_id
2275
2321
  for item_id in (queue if isinstance(queue, list) else sorted(assigned))
@@ -2278,6 +2324,28 @@ def _narrow_dispatch_queue(
2278
2324
  return narrowed
2279
2325
 
2280
2326
 
2327
+ def _queue_holds_open_round_votes(
2328
+ verification: Mapping[str, Any],
2329
+ outside: set[object],
2330
+ round_number: int,
2331
+ ) -> bool:
2332
+ """critic 의 `--append` 가 아직 열린 분석자 라운드에 얹히는지.
2333
+
2334
+ 그 라운드의 큐를 tie 부분집합으로 덮으면 이어지는 `complete-round` 가
2335
+ 분석자 표가 있는 나머지 항목을 배정 밖으로 보고 거절한다.
2336
+ """
2337
+ items = verification.get("planItems")
2338
+ return any(
2339
+ isinstance(item, Mapping)
2340
+ and item.get("id") in outside
2341
+ and any(
2342
+ isinstance(vote, Mapping) and vote.get("round") == round_number
2343
+ for vote in item.get("verdicts") or []
2344
+ )
2345
+ for item in (items if isinstance(items, list) else [])
2346
+ )
2347
+
2348
+
2281
2349
  def _incoming_verdict_rows(
2282
2350
  args: argparse.Namespace,
2283
2351
  known: set[object],