okstra 0.189.3 → 0.190.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 (178) hide show
  1. package/README.md +1 -1
  2. package/dist/cli-registry.mjs +9 -0
  3. package/dist/cli-registry.mjs.map +1 -1
  4. package/dist/commands/chat/chat.mjs +54 -4
  5. package/dist/commands/chat/chat.mjs.map +1 -1
  6. package/dist/commands/lifecycle/install.mjs +12 -5
  7. package/dist/commands/lifecycle/install.mjs.map +1 -1
  8. package/dist/commands/lifecycle/uninstall.mjs +2 -1
  9. package/dist/commands/lifecycle/uninstall.mjs.map +1 -1
  10. package/dist/lib/host-config.d.mts +5 -3
  11. package/dist/lib/host-config.mjs +89 -31
  12. package/dist/lib/host-config.mjs.map +1 -1
  13. package/docs/architecture/storage-model.md +4 -1
  14. package/docs/architecture.md +3 -3
  15. package/docs/cli.md +11 -9
  16. package/docs/for-ai/skills/okstra-brief-gen.md +1 -1
  17. package/docs/for-ai/skills/okstra-chat.md +3 -3
  18. package/docs/for-ai/skills/okstra-inspect.md +11 -1
  19. package/docs/for-ai/skills/okstra-rollup.md +1 -0
  20. package/docs/for-ai/skills/okstra-run.md +5 -4
  21. package/docs/for-ai/skills/okstra-user-response.md +1 -1
  22. package/docs/project-structure-overview.md +17 -5
  23. package/docs/task-process/README.md +1 -1
  24. package/docs/task-process/error-analysis.md +2 -2
  25. package/docs/task-process/final-verification.md +1 -1
  26. package/docs/task-process/implementation.md +1 -1
  27. package/docs/task-process/release-handoff.md +8 -6
  28. package/docs/task-process/requirements-discovery.md +1 -1
  29. package/package.json +1 -1
  30. package/runtime/BUILD.json +2 -2
  31. package/runtime/agents/workers/claude-worker.md +4 -0
  32. package/runtime/bin/okstra-report-translate.py +10 -4
  33. package/runtime/prompts/launch.template.md +11 -5
  34. package/runtime/prompts/lead/adapters/cmux.md +4 -7
  35. package/runtime/prompts/lead/context-loader.md +2 -0
  36. package/runtime/prompts/lead/convergence.md +20 -83
  37. package/runtime/prompts/lead/okstra-lead-contract.md +24 -6
  38. package/runtime/prompts/lead/plan-body-verification.md +10 -3
  39. package/runtime/prompts/lead/report-writer.md +15 -5
  40. package/runtime/prompts/lead/team-contract.md +15 -9
  41. package/runtime/prompts/profiles/_common-contract.md +2 -1
  42. package/runtime/prompts/profiles/_implementation-deliverable.md +2 -0
  43. package/runtime/prompts/profiles/_implementation-diff-review.md +4 -0
  44. package/runtime/prompts/profiles/_implementation-executor.md +9 -5
  45. package/runtime/prompts/profiles/_implementation-self-check.md +2 -0
  46. package/runtime/prompts/profiles/_implementation-verifier.md +3 -4
  47. package/runtime/prompts/profiles/_stage-discipline.md +12 -5
  48. package/runtime/prompts/profiles/final-verification.md +1 -1
  49. package/runtime/prompts/profiles/implementation-planning.md +1 -1
  50. package/runtime/prompts/profiles/implementation.md +2 -0
  51. package/runtime/prompts/profiles/release-handoff.md +4 -3
  52. package/runtime/prompts/wizard/prompts.ko.json +2 -1
  53. package/runtime/python/okstra_ctl/adapters/dispatch/cmux.py +6 -0
  54. package/runtime/python/okstra_ctl/adapters/hosts/antigravity/adapter.py +1 -0
  55. package/runtime/python/okstra_ctl/adapters/hosts/antigravity/relay.md +2 -2
  56. package/runtime/python/okstra_ctl/adapters/hosts/capability_adapter.py +9 -0
  57. package/runtime/python/okstra_ctl/adapters/hosts/claude-code/adapter.py +1 -0
  58. package/runtime/python/okstra_ctl/adapters/hosts/claude-code/relay.md +3 -3
  59. package/runtime/python/okstra_ctl/adapters/hosts/codex/adapter.py +1 -0
  60. package/runtime/python/okstra_ctl/adapters/hosts/codex/relay.md +17 -3
  61. package/runtime/python/okstra_ctl/adapters/hosts/external/adapter.py +1 -0
  62. package/runtime/python/okstra_ctl/adapters/hosts/external/relay.md +3 -3
  63. package/runtime/python/okstra_ctl/adapters/hosts/grok/adapter.py +1 -0
  64. package/runtime/python/okstra_ctl/adapters/hosts/grok/relay.md +2 -2
  65. package/runtime/python/okstra_ctl/adapters/hosts/kimi/adapter.py +1 -0
  66. package/runtime/python/okstra_ctl/adapters/hosts/kimi/relay.md +2 -2
  67. package/runtime/python/okstra_ctl/adapters/providers/codex/adapter.py +3 -3
  68. package/runtime/python/okstra_ctl/agent/invocation.py +107 -5
  69. package/runtime/python/okstra_ctl/agent/prompt_cli/cli.py +16 -0
  70. package/runtime/python/okstra_ctl/agent/prompt_cli/dynamic_verifier.py +3 -2
  71. package/runtime/python/okstra_ctl/agent/prompt_cli/jobs.py +87 -0
  72. package/runtime/python/okstra_ctl/agent/prompt_cli/materialize.py +72 -3
  73. package/runtime/python/okstra_ctl/analysis_packet.py +47 -6
  74. package/runtime/python/okstra_ctl/cmux.py +34 -13
  75. package/runtime/python/okstra_ctl/consumers.py +30 -7
  76. package/runtime/python/okstra_ctl/convergence.py +110 -21
  77. package/runtime/python/okstra_ctl/convergence_provenance.py +83 -0
  78. package/runtime/python/okstra_ctl/dispatch_core.py +127 -5
  79. package/runtime/python/okstra_ctl/dispatch_state.py +17 -9
  80. package/runtime/python/okstra_ctl/domain/host.py +5 -0
  81. package/runtime/python/okstra_ctl/domain/wizard/interaction.py +1 -0
  82. package/runtime/python/okstra_ctl/error_log_write.py +44 -4
  83. package/runtime/python/okstra_ctl/execution_mutation_audit.py +84 -7
  84. package/runtime/python/okstra_ctl/fixed_text.py +25 -7
  85. package/runtime/python/okstra_ctl/group_context.py +475 -10
  86. package/runtime/python/okstra_ctl/handoff.py +109 -32
  87. package/runtime/python/okstra_ctl/handoff_verification.py +80 -0
  88. package/runtime/python/okstra_ctl/initial_prompt_materialization.py +52 -10
  89. package/runtime/python/okstra_ctl/lead_progress.py +291 -0
  90. package/runtime/python/okstra_ctl/model_io/renderers.py +60 -1
  91. package/runtime/python/okstra_ctl/model_io_cli.py +6 -1
  92. package/runtime/python/okstra_ctl/ports/worker_dispatch.py +4 -0
  93. package/runtime/python/okstra_ctl/recap.py +179 -17
  94. package/runtime/python/okstra_ctl/reconcile.py +55 -4
  95. package/runtime/python/okstra_ctl/render.py +34 -8
  96. package/runtime/python/okstra_ctl/report_assembly.py +17 -10
  97. package/runtime/python/okstra_ctl/report_finalize.py +204 -11
  98. package/runtime/python/okstra_ctl/report_html/common.py +241 -113
  99. package/runtime/python/okstra_ctl/report_html/context_links.py +121 -0
  100. package/runtime/python/okstra_ctl/report_html/models.py +11 -5
  101. package/runtime/python/okstra_ctl/report_html/render.py +69 -17
  102. package/runtime/python/okstra_ctl/report_html/view_models/change_impact_analysis.py +8 -1
  103. package/runtime/python/okstra_ctl/report_html/view_models/error_analysis.py +8 -1
  104. package/runtime/python/okstra_ctl/report_html/view_models/feature_analysis.py +11 -1
  105. package/runtime/python/okstra_ctl/report_html/view_models/final_verification.py +13 -1
  106. package/runtime/python/okstra_ctl/report_html/view_models/implementation.py +9 -1
  107. package/runtime/python/okstra_ctl/report_html/view_models/implementation_option_selection.py +29 -2
  108. package/runtime/python/okstra_ctl/report_html/view_models/implementation_planning.py +14 -9
  109. package/runtime/python/okstra_ctl/report_html/view_models/improvement_discovery.py +8 -1
  110. package/runtime/python/okstra_ctl/report_html/view_models/project_analysis.py +15 -1
  111. package/runtime/python/okstra_ctl/report_html/view_models/release_handoff.py +7 -1
  112. package/runtime/python/okstra_ctl/report_html/view_models/requirements_discovery.py +9 -1
  113. package/runtime/python/okstra_ctl/report_translation.py +58 -1
  114. package/runtime/python/okstra_ctl/run.py +54 -14
  115. package/runtime/python/okstra_ctl/stage_targets.py +66 -0
  116. package/runtime/python/okstra_ctl/verdict_blocks.py +39 -12
  117. package/runtime/python/okstra_ctl/wizard/__init__.py +148 -0
  118. package/runtime/python/okstra_ctl/wizard/__main__.py +13 -0
  119. package/runtime/python/okstra_ctl/wizard/cli.py +176 -0
  120. package/runtime/python/okstra_ctl/wizard/confirmation.py +331 -0
  121. package/runtime/python/okstra_ctl/wizard/engine.py +363 -0
  122. package/runtime/python/okstra_ctl/wizard/ids.py +411 -0
  123. package/runtime/python/okstra_ctl/wizard/prompts.py +202 -0
  124. package/runtime/python/okstra_ctl/wizard/registry.py +836 -0
  125. package/runtime/python/okstra_ctl/wizard/render.py +189 -0
  126. package/runtime/python/okstra_ctl/wizard/roles.py +737 -0
  127. package/runtime/python/okstra_ctl/wizard/sources.py +703 -0
  128. package/runtime/python/okstra_ctl/wizard/state.py +689 -0
  129. package/runtime/python/okstra_ctl/wizard/statefile.py +145 -0
  130. package/runtime/python/okstra_ctl/wizard/steps_analysis.py +558 -0
  131. package/runtime/python/okstra_ctl/wizard/steps_identity.py +828 -0
  132. package/runtime/python/okstra_ctl/wizard/steps_options.py +340 -0
  133. package/runtime/python/okstra_ctl/wizard/steps_plan.py +959 -0
  134. package/runtime/python/okstra_ctl/wizard/steps_roles.py +672 -0
  135. package/runtime/python/okstra_ctl/worker_prompt_contract.py +43 -3
  136. package/runtime/python/okstra_ctl/worker_prompt_headers.py +14 -0
  137. package/runtime/python/okstra_ctl/workflow.py +2 -2
  138. package/runtime/python/okstra_ctl/worktree/__init__.py +2 -0
  139. package/runtime/python/okstra_ctl/worktree/cleanliness.py +7 -4
  140. package/runtime/python/okstra_ctl/worktree/git_ops.py +7 -0
  141. package/runtime/python/okstra_ctl/worktree/naming.py +15 -6
  142. package/runtime/python/okstra_ctl/worktree/provision.py +2 -1
  143. package/runtime/python/okstra_ctl/worktree_registry.py +27 -0
  144. package/runtime/python/okstra_ctl/wrapper_status.py +4 -0
  145. package/runtime/schemas/final-report-v2.0.schema.json +4 -0
  146. package/runtime/schemas/final-report-v3.0.schema.json +4 -0
  147. package/runtime/skills/okstra-brief-gen/SKILL.md +22 -3
  148. package/runtime/skills/okstra-chat/SKILL.md +8 -6
  149. package/runtime/skills/okstra-inspect/SKILL.md +2 -2
  150. package/runtime/skills/okstra-inspect/facets/recap.md +52 -4
  151. package/runtime/skills/okstra-rollup/SKILL.md +1 -1
  152. package/runtime/skills/okstra-run/SKILL.md +17 -7
  153. package/runtime/skills/okstra-schedule-gen/SKILL.md +2 -0
  154. package/runtime/skills/okstra-user-response/SKILL.md +1 -1
  155. package/runtime/templates/reports/brief.template.md +15 -7
  156. package/runtime/templates/reports/final-verification-input.template.md +2 -0
  157. package/runtime/templates/reports/group-context.template.md +7 -0
  158. package/runtime/templates/reports/html/base.template.html +13 -3
  159. package/runtime/templates/reports/html/i18n/en.json +43 -5
  160. package/runtime/templates/reports/html/i18n/ko.json +43 -5
  161. package/runtime/templates/reports/html/macros/forms.html +4 -4
  162. package/runtime/templates/reports/html/tasks/final-verification.template.html +11 -1
  163. package/runtime/templates/reports/html/tasks/implementation-option-selection.template.html +16 -15
  164. package/runtime/templates/reports/html/tasks/implementation-planning.template.html +5 -5
  165. package/runtime/templates/reports/html/tasks/release-handoff.template.html +2 -2
  166. package/runtime/templates/reports/html/tasks/requirements-discovery.template.html +1 -1
  167. package/runtime/templates/reports/implementation-planning-input.template.md +3 -0
  168. package/runtime/templates/reports/improvement-discovery-input.template.md +3 -0
  169. package/runtime/templates/reports/release-handoff-input.template.md +5 -3
  170. package/runtime/templates/reports/schedule.template.md +13 -4
  171. package/runtime/templates/reports/task-brief.template.md +1 -1
  172. package/runtime/templates/reverify-output-contract.md +5 -0
  173. package/runtime/templates/worker-error-contract.md +2 -0
  174. package/runtime/templates/worker-prompt-preamble.md +1 -1
  175. package/runtime/validators/validate-report-views.py +30 -1
  176. package/runtime/validators/validate-run.py +194 -25
  177. package/runtime/validators/validate_session_conformance.py +102 -2
  178. package/runtime/python/okstra_ctl/wizard.py +0 -7168
@@ -19,6 +19,7 @@ from typing import Any, Dict, List, Optional
19
19
  from .json_boundary import JsonBoundaryError, load_owned_object
20
20
  from .jsonl import append_jsonl
21
21
  from .run_context import consumers_mutex
22
+ from .release_gate import release_handoff_allowed
22
23
 
23
24
  CONSUMERS_FILENAME = "consumers.jsonl"
24
25
 
@@ -271,11 +272,18 @@ def _append_row(plan_run_root: Path, record: Dict[str, Any]) -> None:
271
272
 
272
273
 
273
274
  def append_verified(plan_run_root: Path, *, impl_task_key: str, stage: int,
274
- verdict: str, report_path: str) -> None:
275
+ verdict: str, report_path: str, head_commit: str = "",
276
+ final_verdict: dict | None = None) -> None:
275
277
  """단독-stage final-verification 결과 기록. 같은 report_path 재기록은 멱등,
276
278
  다른 report_path 는 재검증으로 append 한다 (읽기는 last-wins)."""
277
279
  with consumers_mutex(plan_run_root):
278
- for row in read_consumers(plan_run_root):
280
+ rows = read_consumers(plan_run_root)
281
+ done = latest_done_by_stage(rows).get(stage, {})
282
+ if head_commit and (done.get("head_commit") != head_commit
283
+ or done.get("impl_task_key") != impl_task_key
284
+ or last_lifecycle_status_by_stage(rows).get(stage) != "done"):
285
+ raise ValueError("verification commit no longer matches completed implementation")
286
+ for row in rows:
279
287
  if (row.get("impl_task_key") == impl_task_key
280
288
  and row.get("stage") == stage
281
289
  and row.get("status") == "verified"
@@ -285,6 +293,8 @@ def append_verified(plan_run_root: Path, *, impl_task_key: str, stage: int,
285
293
  "impl_task_key": impl_task_key, "stage": stage,
286
294
  "status": "verified", "verdict": verdict,
287
295
  "report_path": report_path,
296
+ "head_commit": head_commit,
297
+ "final_verdict": final_verdict or {},
288
298
  })
289
299
 
290
300
 
@@ -302,12 +312,25 @@ def append_pr(plan_run_root: Path, *, impl_task_key: str, stages: List[int],
302
312
 
303
313
 
304
314
  def verified_accepted_stages(rows: List[Dict[str, Any]]) -> set:
305
- """stage → 마지막 verified 행의 verdict 가 accepted 인 stage 집합 (last-wins)."""
306
- last: Dict[int, str] = {}
315
+ """마지막 구현 이후 동일 커밋을 검증한 단계만 승인 후보로 남긴다."""
316
+ last: Dict[int, dict] = {}
317
+ done = latest_done_by_stage(rows)
307
318
  for r in rows:
308
- if r.get("status") == "verified" and isinstance(r.get("stage"), int):
309
- last[r["stage"]] = (r.get("verdict") or "").strip().lower()
310
- return {n for n, v in last.items() if v == "accepted"}
319
+ stage = r.get("stage")
320
+ if not isinstance(stage, int):
321
+ continue
322
+ if r.get("status") in STAGE_LIFECYCLE_STATUSES:
323
+ last.pop(stage, None)
324
+ elif r.get("status") == "verified":
325
+ last[stage] = r
326
+ return {
327
+ n for n, row in last.items()
328
+ if row.get("head_commit")
329
+ and row["head_commit"] == done.get(n, {}).get("head_commit")
330
+ and row.get("impl_task_key") == done.get(n, {}).get("impl_task_key")
331
+ and row.get("verdict") == (row.get("final_verdict") or {}).get("verdictToken")
332
+ and release_handoff_allowed({"finalVerdict": row.get("final_verdict") or {}})
333
+ }
311
334
 
312
335
 
313
336
  def pr_covered_stages(rows: List[Dict[str, Any]]) -> set:
@@ -35,6 +35,7 @@ from .convergence_migration import (
35
35
  )
36
36
  from .convergence_provenance import (
37
37
  GROUPS_BASENAME_RE,
38
+ discarded_worker_errors,
38
39
  provenance_errors,
39
40
  worker_result_suffix,
40
41
  )
@@ -360,9 +361,11 @@ def _parser() -> argparse.ArgumentParser:
360
361
  collect.add_argument("--result", action="append", default=[],
361
362
  metavar="<worker>=<path>",
362
363
  help="one worker's reverify result file (repeatable)")
363
- collect.add_argument("--dispatch", action="append", default=[], required=True,
364
- metavar="<worker>=<status>:<durationMs>",
365
- help="terminal status per dispatched worker (repeatable)")
364
+ collect.add_argument("--run-manifest", type=Path, required=True,
365
+ help="the current run manifest; each planned worker's "
366
+ "terminal outcome and duration are read from its "
367
+ "attempts, and a still-`started` attempt refuses "
368
+ "the collect until `okstra team await` closes it")
366
369
  collect.add_argument("--output", type=Path, required=True)
367
370
 
368
371
  apply = subparsers.add_parser("apply-round", help="apply structured round results")
@@ -877,6 +880,11 @@ def _seed(args: argparse.Namespace) -> tuple[str, Path]:
877
880
  raise ConvergenceContractError(
878
881
  "grouping cites source items no worker reported: " + "; ".join(provenance)
879
882
  )
883
+ discarded = discarded_worker_errors(grouped_input, execution_manifest)
884
+ if discarded:
885
+ raise ConvergenceContractError(
886
+ "grouping cites workers whose result was discarded: " + "; ".join(discarded)
887
+ )
880
888
  state = seed_working_state(
881
889
  grouped_input,
882
890
  execution_manifest=execution_manifest,
@@ -981,27 +989,98 @@ def _split_pair(raw: str, label: str) -> tuple[str, str]:
981
989
  return key.strip(), value.strip()
982
990
 
983
991
 
984
- def _dispatch_rows(raw_dispatches: list[str]) -> list[dict[str, Any]]:
985
- """`dispatches[]`, from what only the lead knows.
992
+ _ATTEMPT_DISPATCH_STATUS = {
993
+ "ok": "completed",
994
+ "failed-no-mutation": "error",
995
+ "contract-failed-unattributed": "error",
996
+ "mutation-present-unresolved": "error",
997
+ }
986
998
 
987
- Terminal status and duration are not in the worker's response; they belong
988
- to the dispatch. Deriving them from the presence of a result file would call
989
- a timed-out worker `completed` whenever it managed to write something.
999
+
1000
+ def _dispatch_rows_from_manifest(
1001
+ plan: Mapping[str, Any],
1002
+ manifest: Any,
1003
+ run_manifest_path: Path,
1004
+ ) -> list[dict[str, Any]]:
1005
+ """`dispatches[]` 를 execution manifest 의 attempt 원장에서 도출한다.
1006
+
1007
+ 종전에는 리드가 `--dispatch <worker>=<status>:<durationMs>` 로 상태와
1008
+ 소요시간을 손으로 넘겼다. 실측(2026-09-06, fontsninja-v3-site
1009
+ final-verification 001): 재검증 결과 3건이 라운드 결과에 `completed`,
1010
+ `durationMs: 0` 으로 기록됐는데 같은 attempt 들은 매니페스트에서 `started`
1011
+ 로 남아 있었다 — 결과 파일이 있으니 리드가 상태를 적었고, attempt 를 닫는
1012
+ 유일한 경로(`team await`)는 돌지 않았다. 원장에서 읽으면 두 기록이 갈릴
1013
+ 수 없고, 미종결 attempt 가 있으면 여기서 멈춰 `team await` 를 강제한다.
1014
+
1015
+ 상태 대응: `ok` → completed, 그 밖의 terminal 은 error (wrapper 가 timeout
1016
+ 으로 닫은 attempt 는 `changeSummary.timeout` 이 참이면 timeout). 이 라운드의
1017
+ invocation 이 없는 워커는 not-run.
990
1018
  """
1019
+ round_number = plan.get("round")
1020
+ if not isinstance(round_number, int) or round_number < 1:
1021
+ raise ConvergenceContractError("round plan `round` must be a positive integer")
1022
+ kind = f"reverify-r{round_number}"
1023
+ source_roles = {
1024
+ role.role_execution_ref: role.source_role_execution_ref or role.role_execution_ref
1025
+ for role in getattr(manifest, "role_executions", ())
1026
+ }
1027
+ latest: dict[str, Any] = {}
1028
+ for attempt in getattr(manifest, "attempts", ()):
1029
+ current = latest.get(attempt.invocation_ref)
1030
+ if current is None or attempt.attempt > current.attempt:
1031
+ latest[attempt.invocation_ref] = attempt
991
1032
  rows: list[dict[str, Any]] = []
992
- for raw in raw_dispatches:
993
- worker, value = _split_pair(raw, "--dispatch")
994
- status, _, duration = value.partition(":")
995
- if not duration.isdigit():
1033
+ for row in plan.get("dispatches") or []:
1034
+ if not isinstance(row, Mapping):
1035
+ continue
1036
+ worker = str(row.get("worker") or "")
1037
+ role_ref = row.get("sourceRoleExecutionRef")
1038
+ matches = [
1039
+ invocation for invocation in getattr(manifest, "invocations", ())
1040
+ if invocation.dispatch_kind == kind
1041
+ and (
1042
+ source_roles.get(invocation.role_execution_ref) == role_ref
1043
+ if isinstance(role_ref, str) and role_ref
1044
+ else invocation.invocation_ref.endswith(f"-{worker}")
1045
+ )
1046
+ ]
1047
+ attempts = [latest[inv.invocation_ref] for inv in matches if inv.invocation_ref in latest]
1048
+ if not attempts:
1049
+ rows.append({"worker": worker, "status": "not-run", "durationMs": 0})
1050
+ continue
1051
+ attempt = max(attempts, key=lambda item: (item.started_at, item.attempt))
1052
+ if attempt.finished_at is None or attempt.status == "started":
996
1053
  raise ConvergenceContractError(
997
- f"--dispatch for `{worker}` must end in :<durationMs>, got: {value}"
1054
+ f"attempt `{attempt.invocation_ref}#{attempt.attempt}` for `{worker}` "
1055
+ f"is still `started`; run `okstra team await --run-manifest "
1056
+ f"{run_manifest_path}` so the attempt closes before collecting — a "
1057
+ "round result written over an open attempt is a status the ledger "
1058
+ "never recorded"
998
1059
  )
1060
+ status = _ATTEMPT_DISPATCH_STATUS.get(attempt.status, "error")
1061
+ if status == "error" and bool((attempt.change_summary or {}).get("timeout")):
1062
+ status = "timeout"
999
1063
  rows.append({
1000
- "worker": worker, "status": status, "durationMs": int(duration)
1064
+ "worker": worker,
1065
+ "status": status,
1066
+ "durationMs": _duration_ms(attempt.started_at, attempt.finished_at),
1001
1067
  })
1002
1068
  return rows
1003
1069
 
1004
1070
 
1071
+ def _duration_ms(started_at: str, finished_at: str) -> int:
1072
+ from datetime import datetime
1073
+
1074
+ def _parse(value: str) -> datetime:
1075
+ return datetime.fromisoformat(value.replace("Z", "+00:00"))
1076
+
1077
+ try:
1078
+ delta = _parse(finished_at) - _parse(started_at)
1079
+ except ValueError:
1080
+ return 0
1081
+ return max(0, int(delta.total_seconds() * 1000))
1082
+
1083
+
1005
1084
  def _planned_finding_ids(plan: dict[str, Any]) -> dict[str, list[str]]:
1006
1085
  dispatches = plan.get("dispatches")
1007
1086
  if not isinstance(dispatches, list):
@@ -1049,15 +1128,25 @@ def _worker_votes(
1049
1128
  def _collect_results(args: argparse.Namespace) -> tuple[str, Path]:
1050
1129
  plan = load_owned_json_object(args.plan)
1051
1130
  planned = _planned_finding_ids(plan)
1052
- dispatches = _dispatch_rows(args.dispatch)
1053
- undispatched = sorted(set(planned) - {row["worker"] for row in dispatches})
1054
- if undispatched:
1131
+ manifest, _authority = read_execution_manifest_view(args.run_manifest)
1132
+ if getattr(manifest, "legacy", False):
1055
1133
  raise ConvergenceContractError(
1056
- f"the round plan dispatched {undispatched} but no --dispatch status "
1057
- f"was given for them; a planned worker with no recorded outcome is "
1058
- f"indistinguishable from one that was never asked"
1134
+ "collect-results reads dispatch outcomes from a v2 execution manifest; "
1135
+ "a legacy v1 roster has no attempt ledger to read"
1059
1136
  )
1137
+ dispatches = _dispatch_rows_from_manifest(plan, manifest, args.run_manifest)
1060
1138
  votes = _worker_votes(args.result, planned, args.mode == "adversarial")
1139
+ returned_without_completion = sorted(
1140
+ worker for worker in votes
1141
+ if next((row["status"] for row in dispatches if row["worker"] == worker), None)
1142
+ != "completed"
1143
+ )
1144
+ if returned_without_completion:
1145
+ raise ConvergenceContractError(
1146
+ f"{returned_without_completion} returned a result but the ledger did not "
1147
+ "close their attempt as ok — a discarded or failed attempt's file is not "
1148
+ "a vote"
1149
+ )
1061
1150
  by_finding: dict[str, dict[str, Any]] = {}
1062
1151
  for worker, parsed in votes.items():
1063
1152
  for finding_id, vote in parsed.items():
@@ -1297,7 +1386,7 @@ def _prepare_groups(args: argparse.Namespace) -> tuple[str, Path]:
1297
1386
  "config": {"enabled": bool(config.get("enabled", True)),
1298
1387
  "adversarial": bool(config.get("adversarial", False)),
1299
1388
  "maxRounds": int(config.get("maxRounds", 2)),
1300
- "effectiveMaxRounds": int(config.get("effectiveMaxRounds", 2)),
1389
+ "effectiveMaxRounds": int(config.get("effectiveMaxRounds", config.get("maxRounds", 2))),
1301
1390
  "verificationMode": str(config.get("verificationMode", "lightweight"))},
1302
1391
  "workers": workers, "groups": groups}
1303
1392
  schema_errors = validate_schema(
@@ -233,3 +233,86 @@ def run_dir_provenance_errors(run_dir: Path) -> list[str]:
233
233
  )
234
234
  )
235
235
  return errors
236
+
237
+
238
+ # 변이 감사가 결과를 폐기하고 재시도까지 막는 terminal 상태. 이 상태의 결과 파일은
239
+ # 디스크에 남아 있어 파일 존재만 보는 provenance 검사는 통과시킨다.
240
+ DISCARDED_ATTEMPT_STATUSES = frozenset(
241
+ {"contract-failed-unattributed", "mutation-present-unresolved"}
242
+ )
243
+
244
+
245
+ def discarded_workers(document: object, execution_manifest: object) -> dict[str, str]:
246
+ """``{workerId: "<invocationRef>#<attempt> <status>"}`` — 초기 attempt 가 전부
247
+ 폐기돼 채택할 결과가 없는 분석 워커.
248
+
249
+ 한 워커의 초기 invocation 이 둘 이상이면(교정 디스패치는 새 invocation 이다)
250
+ 그중 하나라도 마지막 attempt 가 ``ok`` 면 채택 가능한 결과가 있는 것이므로
251
+ 폐기로 보지 않는다. legacy 매니페스트나 v1 roster 는 판정하지 않는다.
252
+ """
253
+ if not isinstance(document, dict) or not isinstance(document.get("workers"), list):
254
+ return {}
255
+ if execution_manifest is None or getattr(execution_manifest, "legacy", False):
256
+ return {}
257
+ initial_refs: dict[str, set[str]] = {}
258
+ for invocation in getattr(execution_manifest, "invocations", ()):
259
+ if invocation.dispatch_kind == "initial":
260
+ initial_refs.setdefault(invocation.role_execution_ref, set()).add(
261
+ invocation.invocation_ref
262
+ )
263
+ latest: dict[str, object] = {}
264
+ for attempt in getattr(execution_manifest, "attempts", ()):
265
+ current = latest.get(attempt.invocation_ref)
266
+ if current is None or attempt.attempt > current.attempt:
267
+ latest[attempt.invocation_ref] = attempt
268
+ result: dict[str, str] = {}
269
+ for row in document["workers"]:
270
+ if not isinstance(row, dict):
271
+ continue
272
+ worker = row.get("workerId")
273
+ role_ref = row.get("sourceRoleExecutionRef")
274
+ if not _nonempty_string(worker) or not _nonempty_string(role_ref):
275
+ continue
276
+ rows = [latest[ref] for ref in sorted(initial_refs.get(role_ref, ())) if ref in latest]
277
+ if not rows or any(item.status == "ok" for item in rows):
278
+ continue
279
+ discarded = [item for item in rows if item.status in DISCARDED_ATTEMPT_STATUSES]
280
+ if discarded:
281
+ item = discarded[-1]
282
+ result[worker] = f"{item.invocation_ref}#{item.attempt} {item.status}"
283
+ return result
284
+
285
+
286
+ def discarded_worker_errors(document: object, execution_manifest: object) -> list[str]:
287
+ """Groups that cite a worker whose only initial attempts were discarded.
288
+
289
+ The mutation audit discards a result by setting the attempt terminal status
290
+ and clearing ``resultPath``; the file itself stays in ``worker-results/``, so
291
+ ``provenance_errors`` (which reads the file) still passes. Observed 2026-09-06
292
+ (`fontsninja-v3-site` final-verification 001): the claude verifier's initial
293
+ attempt was ``contract-failed-unattributed`` yet 9 of 10 groups cited it and
294
+ 3 named it ``originWorker`` — the ledger said "discarded" while convergence
295
+ said "adopted".
296
+ """
297
+ discarded = discarded_workers(document, execution_manifest)
298
+ if not discarded or not isinstance(document, dict):
299
+ return []
300
+ errors: list[str] = []
301
+ for group in document.get("groups") or []:
302
+ if not isinstance(group, dict):
303
+ continue
304
+ cited: set[str] = set()
305
+ origin = group.get("originWorker")
306
+ if isinstance(origin, str) and origin in discarded:
307
+ cited.add(origin)
308
+ discovered_by = group.get("discoveredBy")
309
+ if isinstance(discovered_by, dict):
310
+ cited.update(worker for worker in discovered_by if worker in discarded)
311
+ for worker in sorted(cited):
312
+ errors.append(
313
+ f"group {group.get('findingId')} cites `{worker}`, whose initial "
314
+ f"attempt {discarded[worker]} was discarded by the mutation audit — "
315
+ "a discarded result may not seed convergence; re-dispatch the worker "
316
+ "as a new invocation and cite that result"
317
+ )
318
+ return errors
@@ -11,6 +11,8 @@ import os
11
11
  from pathlib import Path
12
12
  from typing import Any, Mapping, Sequence
13
13
 
14
+ from .dispatch_state import abandon_agent_dispatch_attempt
15
+ from .agent.invocation import invocation_input_digest
14
16
  from .dispatch_state import (
15
17
  CompletedWithoutResultError,
16
18
  BACKEND_CLI_WRAPPER,
@@ -896,9 +898,17 @@ def _select_workers(
896
898
  *,
897
899
  team_state: Mapping[str, Any] | None = None,
898
900
  ) -> list[str]:
899
- recommended = _string_list(manifest.get("recommendedWorkers"))
900
- if not recommended:
901
- raise DispatchError("run manifest has no recommendedWorkers entries")
901
+ # 번역자 디스패치는 roster 를 읽지 않는다 — 근거는 prepare 가 심는 canonical
902
+ # translator role execution 이다. 그래서 빈 roster 검사보다 앞에 둔다.
903
+ # 종전에는 뒤에 있어서, roster 가 비는 phase 에서 이 분기에 닿을 수 없었다:
904
+ # `release-handoff` 는 의도적으로 single-lead 라 `_resolve_roster` 가 빈
905
+ # 목록을 강제하는데(`run.py`), `reportLanguage` 가 en 이 아니면 render-views 는
906
+ # 번역 사이드카를 요구한다. 두 요구가 겹치면 예약 경로는 "run manifest has no
907
+ # recommendedWorkers entries" 로, jobs-file 경로는 "jobs file translator must
908
+ # use canonical reservation dispatch" 로 막혀 번역자를 띄울 방법이 없었다
909
+ # (실측 2026-09-07, fontsninja-v3-site dev-10626 release-handoff 001 — 리드가
910
+ # `okstra report-translate write` 로 우회했고, 그 run 의 매니페스트에는
911
+ # translator role execution 과 invocationAssignments.translator 가 둘 다 있다).
902
912
  if tuple(requested_workers) == ("translator",):
903
913
  provider = _canonical_translator_provider(manifest)
904
914
  if provider is None:
@@ -911,6 +921,9 @@ def _select_workers(
911
921
  f"{provider}"
912
922
  )
913
923
  return ["translator"]
924
+ recommended = _string_list(manifest.get("recommendedWorkers"))
925
+ if not recommended:
926
+ raise DispatchError("run manifest has no recommendedWorkers entries")
914
927
  if options.cli_wrapper_assignments_only:
915
928
  return _select_cli_wrapper_assignments(
916
929
  recommended,
@@ -1365,6 +1378,36 @@ def _invocation_job_fields(
1365
1378
  return fields
1366
1379
 
1367
1380
 
1381
+ def _initial_prompt_delivery_mode(
1382
+ manifest: Mapping[str, Any],
1383
+ ) -> PromptDeliveryMode:
1384
+ """이 run 의 어댑터가 선언한 초기 프롬프트 배송 방식.
1385
+
1386
+ 선언은 `leadAdapter.initialPromptDeliveryMode` 에 있고, 그 값은 render 가
1387
+ 호스트 descriptor(어댑터가 워커 디스패치를 가져갔으면 그 어댑터)에서
1388
+ 옮겨 적은 것이다. 선언이 없는 매니페스트는 이 필드가 생기기 전에 준비된
1389
+ run 이므로 그때의 동작인 `eager-include` 로 간다. 아는 값이 아니면 멈춘다 —
1390
+ 프롬프트를 발행하고 나면 그 프롬프트는 불변이라, 잘못 고른 배송 방식은
1391
+ 나중에 고칠 수 없다.
1392
+ """
1393
+ adapter = manifest.get("leadAdapter")
1394
+ declared = (
1395
+ _string_value(adapter.get("initialPromptDeliveryMode"))
1396
+ if isinstance(adapter, Mapping)
1397
+ else ""
1398
+ )
1399
+ if not declared:
1400
+ return PromptDeliveryMode.EAGER_INCLUDE
1401
+ try:
1402
+ return PromptDeliveryMode(declared)
1403
+ except ValueError:
1404
+ allowed = ", ".join(mode.value for mode in PromptDeliveryMode)
1405
+ raise DispatchError(
1406
+ "leadAdapter.initialPromptDeliveryMode is not a delivery mode: "
1407
+ f"{declared!r} (allowed: {allowed})"
1408
+ ) from None
1409
+
1410
+
1368
1411
  def _materialize_roster_prompts(
1369
1412
  project_root: Path,
1370
1413
  workspace_root: Path,
@@ -1384,7 +1427,7 @@ def _materialize_roster_prompts(
1384
1427
  project_root=project_root,
1385
1428
  run_manifest_path=manifest_path,
1386
1429
  runtime_root=workspace_root,
1387
- delivery_mode=PromptDeliveryMode.EAGER_INCLUDE,
1430
+ delivery_mode=_initial_prompt_delivery_mode(manifest),
1388
1431
  workers=tuple(
1389
1432
  InitialPromptWorkerRequest(fact.worker_id, fact.model)
1390
1433
  for fact in worker_facts
@@ -1640,7 +1683,9 @@ def _record_execution_attempt(plan: DispatchPlan, job: WorkerJob) -> None:
1640
1683
  duty_id=job.duty_id,
1641
1684
  dispatch_kind=job.dispatch_kind,
1642
1685
  round=_dispatch_round(job.dispatch_kind),
1643
- input_digest=job.prompt_digest,
1686
+ input_digest=invocation_input_digest(
1687
+ _load_json_object(job.prompt_metadata_path, "agent metadata"), plan.project_root,
1688
+ ),
1644
1689
  ),
1645
1690
  Attempt(
1646
1691
  invocation_ref=job.invocation_ref,
@@ -1763,12 +1808,15 @@ def _job_for_attempt(job: WorkerJob, attempt: int) -> WorkerJob:
1763
1808
  raise DispatchError(f"cannot materialize retry invocation: {exc}") from exc
1764
1809
  if retry.invocation_ref != job.invocation_ref:
1765
1810
  raise DispatchError("retry invocation reference changed")
1811
+ metadata = _load_json_object(retry.metadata_path, "retry agent metadata")
1766
1812
  return replace(
1767
1813
  job,
1768
1814
  prompt_path=retry.prompt_path,
1769
1815
  prompt_metadata_path=retry.metadata_path,
1770
1816
  invocation_id=retry.invocation_id,
1771
1817
  attempt=retry.attempt,
1818
+ instruction_digest=metadata["digests"]["instructionDigest"],
1819
+ prompt_digest=metadata["digests"]["promptDigest"],
1772
1820
  )
1773
1821
 
1774
1822
 
@@ -1932,6 +1980,8 @@ def _advance_running_dispatches(plan: DispatchPlan, records: Sequence[Mapping[st
1932
1980
  status_path = _optional_path(record.get("statusSidecarPath"))
1933
1981
  status = read_wrapper_status(status_path) if status_path else None
1934
1982
  if status is None or not status.is_terminal:
1983
+ if status is not None and _wrapper_process_is_gone(status):
1984
+ _settle_dead_dispatch(plan, record, status)
1935
1985
  continue
1936
1986
  outcome = _outcome_from_status(record, status)
1937
1987
  retry_allowed = _finish_record(plan, record, outcome)
@@ -1942,6 +1992,78 @@ def _advance_running_dispatches(plan: DispatchPlan, records: Sequence[Mapping[st
1942
1992
  _retry_from_record(plan, record, outcome)
1943
1993
 
1944
1994
 
1995
+ def _wrapper_process_is_gone(status) -> bool:
1996
+ """wrapper 가 terminal 상태를 쓰기 전에 죽었는가.
1997
+
1998
+ cmux-pane·cli-wrapper 의 liveness 는 status 사이드카 하나다(`_liveness_mode`).
1999
+ wrapper 는 종료 트랩에서 `stage: exited` 를 쓰므로, 프로세스는 없는데 상태가
2000
+ `started` 면 트랩이 돌지 못한 죽음이다 — pane 이 닫혔거나 호스트가 죽였거나
2001
+ import 오류로 로그 한 줄 못 남긴 경우. 실측(2026-09-06, fontsninja-v3-site
2002
+ final-verification 001): critic wrapper 로그가 시작 110초 뒤 끊겼는데 attempt
2003
+ 는 32분 뒤 리드가 손으로 abandon 할 때까지 `started` 였다.
2004
+
2005
+ pid 가 사라진 뒤 상태를 한 번 더 읽는다 — 트랩이 상태를 쓰고 프로세스가
2006
+ 빠져나가는 그 사이에 첫 읽기가 끼었을 수 있다. 그때는 다음 폴링이 terminal
2007
+ 을 읽으므로 여기서 죽음으로 보지 않는다.
2008
+ """
2009
+ pid = status.pid
2010
+ if pid is None or pid <= 0:
2011
+ return False
2012
+ try:
2013
+ os.kill(pid, 0)
2014
+ except ProcessLookupError:
2015
+ # expected-miss: 프로세스 부재가 이 함수가 묻는 답이다 — 예외가 곧 "죽었다"
2016
+ # 는 결과이고, 그 판정을 아래 재읽기가 확인한다.
2017
+ process_gone = True
2018
+ except PermissionError:
2019
+ # 다른 사용자의 살아 있는 프로세스. 시그널 0 은 거부돼도 존재는 확인된다.
2020
+ process_gone = False
2021
+ else:
2022
+ process_gone = False
2023
+ if not process_gone:
2024
+ return False
2025
+ refreshed = read_wrapper_status(status.path)
2026
+ return refreshed is not None and not refreshed.is_terminal
2027
+
2028
+
2029
+ def _settle_dead_dispatch(plan: DispatchPlan, record: Mapping[str, Any], status) -> None:
2030
+ """죽은 wrapper 의 dispatch 를 원장에 맞게 닫는다.
2031
+
2032
+ `source-readonly` attempt 는 `abandon_agent_dispatch_attempt` 로 닫는다 — 그
2033
+ 정책은 아무것도 쓰지 않았다는 단언이 정책상 참이라 추측이 없다. `project-mutation`
2034
+ attempt 는 열어 둔 채 dispatch 행만 `error` 로 바꾼다 — 반쯤 바뀐 트리 위에서
2035
+ 다시 돌지 않게 하려면 변이 감사가 그 attempt 를 판정해야 한다. 어느 쪽도 자동
2036
+ 재시도는 하지 않는다: pane 이 죽은 뒤의 재실행은 아무도 보고 있지 않은 작업이다.
2037
+ """
2038
+ attempt = int(record.get("attempt", 1))
2039
+ job = _job_from_record(plan.project_root, record)
2040
+ reason = (
2041
+ f"wrapper pid {status.pid} exited without writing a terminal status "
2042
+ f"(stage `{status.stage}`)"
2043
+ )
2044
+ contract = _persisted_write_contract(plan, job)
2045
+ mode = contract[0].source_mode if contract is not None else "unknown"
2046
+ closed = None
2047
+ if mode == "source-readonly" and job.invocation_id:
2048
+ closed = abandon_agent_dispatch_attempt(
2049
+ project_root=plan.project_root,
2050
+ run_manifest_path=plan.manifest_path,
2051
+ invocation_ref=job.invocation_id,
2052
+ reason=reason,
2053
+ )
2054
+ else:
2055
+ reason += "; attempt left open for the mutation audit"
2056
+ _update_dispatch_status(plan.team_state_path, job, attempt, "error", reason)
2057
+ details = _event_execution_identity(job, attempt)
2058
+ details.update({
2059
+ "pid": status.pid,
2060
+ "sourceMode": mode,
2061
+ "attemptClosed": closed is not None,
2062
+ "reason": reason,
2063
+ })
2064
+ _append_event(plan, "worker-dead-detected", details)
2065
+
2066
+
1945
2067
  def _retry_from_record(
1946
2068
  plan: DispatchPlan, record: Mapping[str, Any], outcome: WorkerOutcome
1947
2069
  ) -> None:
@@ -35,6 +35,7 @@ from .agent.invocation import (
35
35
  InvocationMetadataIdentity,
36
36
  agent_model_assignment_from_payload,
37
37
  invocation_metadata_identity,
38
+ invocation_input_digest,
38
39
  v2_role_assignment_authority_errors,
39
40
  verify_agent_invocation,
40
41
  )
@@ -113,11 +114,6 @@ LIVENESS_WRAPPER_STATUS = "wrapper-status"
113
114
  # so the string lives here rather than in whichever module wrote it first.
114
115
  TEARDOWN_BEFORE_TERMINAL_REASON = "teardown before terminal status"
115
116
 
116
- # The worktree argument grants the wrapper `--add-dir` write access outside
117
- # project-root. Only the phases whose workers mutate a stage worktree get it;
118
- # analysis phases write their artifacts under project-root (see the contract in
119
- # scripts/okstra-codex-exec.sh).
120
- WORKTREE_TASK_TYPES = frozenset({"implementation", "final-verification"})
121
117
  WORKER_STATUSES = frozenset(
122
118
  {"in-progress", "completed", "timeout", "error", "not-run"}
123
119
  )
@@ -362,8 +358,6 @@ def resolve_required_path(
362
358
  def worktree_path(
363
359
  manifest: Mapping[str, Any], active_context: Mapping[str, Any]
364
360
  ) -> str:
365
- if manifest.get("taskType") not in WORKTREE_TASK_TYPES:
366
- return ""
367
361
  worktree = active_context.get("executorWorktree")
368
362
  if not isinstance(worktree, Mapping):
369
363
  return ""
@@ -706,7 +700,6 @@ def record_verified_agent_dispatch(
706
700
  return record
707
701
  if execution_identity is not None:
708
702
  dispatch_kind = require_string(metadata, "dispatchKind")
709
- prompt_digest = require_string(digests, "promptDigest")
710
703
  if write_policy is None or write_enforcement is None:
711
704
  raise DispatchError("agent write contract was not prepared")
712
705
  record_invocation_attempt(
@@ -720,7 +713,7 @@ def record_verified_agent_dispatch(
720
713
  duty_id=execution_identity.duty_id,
721
714
  dispatch_kind=dispatch_kind,
722
715
  round=_execution_dispatch_round(dispatch_kind),
723
- input_digest=prompt_digest,
716
+ input_digest=invocation_input_digest(metadata, project_root),
724
717
  ),
725
718
  Attempt(
726
719
  invocation_ref=execution_identity.invocation_ref,
@@ -2149,6 +2142,21 @@ def worker_jobs_from_file(
2149
2142
  resolve_project_path(project_root, str(jobs_file)),
2150
2143
  "jobs file",
2151
2144
  )
2145
+ return worker_jobs_from_payload(
2146
+ project_root, payload, manifest=manifest, active_context=active_context,
2147
+ backend=backend, idle_timeout_seconds=idle_timeout_seconds,
2148
+ default_dispatch_kind=default_dispatch_kind, resolve_wrapper=resolve_wrapper,
2149
+ default_provider=default_provider,
2150
+ )
2151
+
2152
+
2153
+ def worker_jobs_from_payload(
2154
+ project_root: Path, payload: Mapping[str, Any], *,
2155
+ manifest: Mapping[str, Any], active_context: Mapping[str, Any] | None = None,
2156
+ backend: str, idle_timeout_seconds: int | None, default_dispatch_kind: str,
2157
+ resolve_wrapper: Callable[[str], Path], default_provider: Callable[[str], str],
2158
+ ) -> list[WorkerJob]:
2159
+ """파일 소비자와 생성기가 동일한 배치 계약을 검사한다."""
2152
2160
  dispatch_kind = string_value(payload.get("dispatchKind")) or default_dispatch_kind
2153
2161
  workers = payload.get("workers")
2154
2162
  if not isinstance(workers, list):
@@ -51,6 +51,11 @@ class HostDescriptor:
51
51
  session_accounting: str
52
52
  has_claude_session: bool
53
53
  relay_contract: str
54
+ # 이 호스트가 초기 워커 프롬프트를 어떻게 싣겠다고 선언하는가. 값은
55
+ # `relay.md` 의 `initialPromptDeliveryMode` 행과 같아야 한다 — 그 표를
56
+ # 읽고 dispatch 를 판단하는 것은 리드이고, 실제로 프롬프트를 만드는 것은
57
+ # 이 값을 받는 `initial_prompt_materialization` 이다.
58
+ initial_prompt_delivery_mode: str = "eager-include"
54
59
 
55
60
 
56
61
  @dataclass(frozen=True)
@@ -11,6 +11,7 @@ class WizardOption:
11
11
  value: str
12
12
  label: str
13
13
  description: str = ""
14
+ recommended: bool = False
14
15
 
15
16
 
16
17
  @dataclass(frozen=True)