okstra 0.179.2 → 0.183.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 (258) hide show
  1. package/README.md +1 -1
  2. package/dist/cli-registry.mjs +30 -2
  3. package/dist/cli-registry.mjs.map +1 -1
  4. package/dist/commands/execute/incremental-carry.mjs +9 -8
  5. package/dist/commands/execute/incremental-carry.mjs.map +1 -1
  6. package/dist/commands/execute/plan-verify.mjs +3 -1
  7. package/dist/commands/execute/plan-verify.mjs.map +1 -1
  8. package/dist/commands/execute/render-bundle.d.mts +4 -2
  9. package/dist/commands/execute/render-bundle.mjs +46 -5
  10. package/dist/commands/execute/render-bundle.mjs.map +1 -1
  11. package/dist/commands/execute/run.mjs +11 -3
  12. package/dist/commands/execute/run.mjs.map +1 -1
  13. package/dist/commands/inspect/model-io.d.mts +1 -0
  14. package/dist/commands/inspect/model-io.mjs +25 -0
  15. package/dist/commands/inspect/model-io.mjs.map +1 -0
  16. package/dist/commands/inspect/stage-map.mjs +29 -8
  17. package/dist/commands/inspect/stage-map.mjs.map +1 -1
  18. package/dist/commands/inspect/task-list.mjs +52 -6
  19. package/dist/commands/inspect/task-list.mjs.map +1 -1
  20. package/dist/commands/inspect/user-response.mjs +14 -4
  21. package/dist/commands/inspect/user-response.mjs.map +1 -1
  22. package/dist/commands/lifecycle/check-project.d.mts +1 -0
  23. package/dist/commands/lifecycle/check-project.mjs +69 -50
  24. package/dist/commands/lifecycle/check-project.mjs.map +1 -1
  25. package/dist/commands/lifecycle/contract-check.d.mts +1 -0
  26. package/dist/commands/lifecycle/contract-check.mjs +18 -0
  27. package/dist/commands/lifecycle/contract-check.mjs.map +1 -0
  28. package/dist/commands/lifecycle/preflight.mjs +154 -51
  29. package/dist/commands/lifecycle/preflight.mjs.map +1 -1
  30. package/dist/commands/pr/pr.d.mts +1 -0
  31. package/dist/commands/pr/pr.mjs +19 -1
  32. package/dist/commands/pr/pr.mjs.map +1 -1
  33. package/dist/commands/report/agent-activity.mjs +2 -2
  34. package/dist/commands/report/approval-decision.d.mts +1 -0
  35. package/dist/commands/report/approval-decision.mjs +21 -0
  36. package/dist/commands/report/approval-decision.mjs.map +1 -0
  37. package/dist/commands/report/design-snapshot.d.mts +1 -0
  38. package/dist/commands/report/design-snapshot.mjs +19 -0
  39. package/dist/commands/report/design-snapshot.mjs.map +1 -0
  40. package/dist/commands/report/translate.mjs +3 -0
  41. package/dist/commands/report/translate.mjs.map +1 -1
  42. package/dist/lib/host-registry-client.mjs +13 -9
  43. package/dist/lib/host-registry-client.mjs.map +1 -1
  44. package/docs/architecture/storage-model.md +1 -1
  45. package/docs/architecture.md +21 -10
  46. package/docs/cli.md +36 -20
  47. package/docs/container.md +6 -4
  48. package/docs/contributor-change-matrix.md +1 -1
  49. package/docs/for-ai/README.md +2 -2
  50. package/docs/for-ai/skills/okstra-brief-gen.md +5 -3
  51. package/docs/for-ai/skills/okstra-code-review.md +4 -4
  52. package/docs/for-ai/skills/okstra-container-build.md +20 -17
  53. package/docs/for-ai/skills/okstra-inspect.md +20 -23
  54. package/docs/for-ai/skills/okstra-manager.md +19 -18
  55. package/docs/for-ai/skills/okstra-memory.md +2 -2
  56. package/docs/for-ai/skills/okstra-pr-gen.md +3 -3
  57. package/docs/for-ai/skills/okstra-rollup.md +14 -13
  58. package/docs/for-ai/skills/okstra-run.md +7 -3
  59. package/docs/for-ai/skills/okstra-schedule-gen.md +15 -18
  60. package/docs/for-ai/skills/okstra-setup.md +7 -7
  61. package/docs/for-ai/skills/okstra-usage.md +5 -4
  62. package/docs/for-ai/skills/okstra-user-response.md +50 -32
  63. package/docs/project-structure-overview.md +43 -31
  64. package/docs/task-process/implementation-planning.md +2 -2
  65. package/docs/task-process/implementation.md +7 -4
  66. package/package.json +1 -1
  67. package/runtime/BUILD.json +2 -2
  68. package/runtime/agents/workers/claude-worker.md +4 -4
  69. package/runtime/agents/workers/report-writer-worker.md +15 -164
  70. package/runtime/agents/workers/translator-worker.md +5 -13
  71. package/runtime/bin/okstra-error-log.py +51 -11
  72. package/runtime/bin/okstra-report-translate.py +210 -23
  73. package/runtime/prompts/host-orchestration/implementation.md +1 -1
  74. package/runtime/prompts/launch.template.md +9 -12
  75. package/runtime/prompts/lead/adapters/cmux.md +1 -1
  76. package/runtime/prompts/lead/context-loader.md +41 -141
  77. package/runtime/prompts/lead/convergence.md +10 -8
  78. package/runtime/prompts/lead/okstra-lead-contract.md +40 -49
  79. package/runtime/prompts/lead/plan-body-verification.md +217 -23
  80. package/runtime/prompts/lead/report-writer.md +81 -421
  81. package/runtime/prompts/lead/team-contract.md +9 -54
  82. package/runtime/prompts/profiles/_clarification-recommendation.md +5 -4
  83. package/runtime/prompts/profiles/_coding-conventions-preflight.md +3 -2
  84. package/runtime/prompts/profiles/_common-contract.md +4 -4
  85. package/runtime/prompts/profiles/_implementation-deliverable.md +1 -1
  86. package/runtime/prompts/profiles/_implementation-diff-review.md +1 -1
  87. package/runtime/prompts/profiles/_implementation-verifier.md +3 -3
  88. package/runtime/prompts/profiles/change-impact-analysis.md +1 -1
  89. package/runtime/prompts/profiles/error-analysis.md +1 -1
  90. package/runtime/prompts/profiles/feature-analysis.md +1 -1
  91. package/runtime/prompts/profiles/final-verification.md +1 -1
  92. package/runtime/prompts/profiles/implementation-planning.md +20 -12
  93. package/runtime/prompts/profiles/improvement-discovery.md +1 -1
  94. package/runtime/prompts/profiles/project-analysis.md +1 -1
  95. package/runtime/prompts/profiles/release-handoff.md +0 -1
  96. package/runtime/prompts/profiles/requirements-discovery.md +1 -1
  97. package/runtime/prompts/wizard/prompts.ko.json +0 -1
  98. package/runtime/python/okstra_ctl/adapters/hosts/capability_adapter.py +69 -17
  99. package/runtime/python/okstra_ctl/adapters/hosts/claude-code/adapter.py +13 -4
  100. package/runtime/python/okstra_ctl/adapters/hosts/claude-code/relay.md +8 -2
  101. package/runtime/python/okstra_ctl/adapters/hosts/codex/adapter.py +2 -2
  102. package/runtime/python/okstra_ctl/adapters/hosts/codex/relay.md +50 -5
  103. package/runtime/python/okstra_ctl/adapters/hosts/external/relay.md +1 -1
  104. package/runtime/python/okstra_ctl/adapters/hosts/grok/adapter.py +2 -2
  105. package/runtime/python/okstra_ctl/adapters/hosts/grok/relay.md +66 -5
  106. package/runtime/python/okstra_ctl/adapters/providers/grok/adapter.py +70 -2
  107. package/runtime/python/okstra_ctl/agent_activity.py +141 -38
  108. package/runtime/python/okstra_ctl/agent_invocation.py +19 -6
  109. package/runtime/python/okstra_ctl/agent_prompt_cli.py +70 -23
  110. package/runtime/python/okstra_ctl/analysis_inputs.py +5 -4
  111. package/runtime/python/okstra_ctl/analysis_packet.py +124 -3
  112. package/runtime/python/okstra_ctl/approval_decisions.py +328 -0
  113. package/runtime/python/okstra_ctl/attempt_evidence.py +2 -2
  114. package/runtime/python/okstra_ctl/backfill.py +13 -10
  115. package/runtime/python/okstra_ctl/batch.py +2 -4
  116. package/runtime/python/okstra_ctl/build_tools.py +6 -3
  117. package/runtime/python/okstra_ctl/claim_reproduction.py +101 -0
  118. package/runtime/python/okstra_ctl/clarification_items.py +27 -13
  119. package/runtime/python/okstra_ctl/cmux.py +130 -52
  120. package/runtime/python/okstra_ctl/code_review_target.py +34 -8
  121. package/runtime/python/okstra_ctl/consumers.py +5 -4
  122. package/runtime/python/okstra_ctl/container.py +103 -8
  123. package/runtime/python/okstra_ctl/context_cost.py +2 -1
  124. package/runtime/python/okstra_ctl/contract_graph.py +497 -0
  125. package/runtime/python/okstra_ctl/contract_graph_cli.py +62 -0
  126. package/runtime/python/okstra_ctl/convergence.py +338 -17
  127. package/runtime/python/okstra_ctl/convergence_engine.py +10 -18
  128. package/runtime/python/okstra_ctl/convergence_provenance.py +58 -8
  129. package/runtime/python/okstra_ctl/convergence_store.py +55 -34
  130. package/runtime/python/okstra_ctl/design_prep.py +7 -4
  131. package/runtime/python/okstra_ctl/design_snapshot.py +134 -0
  132. package/runtime/python/okstra_ctl/dispatch_core.py +97 -69
  133. package/runtime/python/okstra_ctl/dispatch_state.py +163 -63
  134. package/runtime/python/okstra_ctl/doctor.py +6 -3
  135. package/runtime/python/okstra_ctl/domain/worker_presentation.py +70 -9
  136. package/runtime/python/okstra_ctl/entrypoints/hosts.py +16 -30
  137. package/runtime/python/okstra_ctl/error_log_write.py +35 -30
  138. package/runtime/python/okstra_ctl/error_report.py +26 -1
  139. package/runtime/python/okstra_ctl/error_zip.py +27 -5
  140. package/runtime/python/okstra_ctl/execution_identity.py +3 -2
  141. package/runtime/python/okstra_ctl/execution_manifest.py +7 -4
  142. package/runtime/python/okstra_ctl/execution_mutation_audit.py +6 -2
  143. package/runtime/python/okstra_ctl/final_report_schema.py +26 -17
  144. package/runtime/python/okstra_ctl/fix_cycles.py +2 -2
  145. package/runtime/python/okstra_ctl/fixed_text.py +39 -0
  146. package/runtime/python/okstra_ctl/git_reconcile.py +41 -9
  147. package/runtime/python/okstra_ctl/handoff.py +5 -4
  148. package/runtime/python/okstra_ctl/i18n.py +4 -2
  149. package/runtime/python/okstra_ctl/implementation_direction.py +22 -14
  150. package/runtime/python/okstra_ctl/implementation_outcome.py +4 -7
  151. package/runtime/python/okstra_ctl/incremental_carry.py +130 -17
  152. package/runtime/python/okstra_ctl/incremental_scope.py +12 -4
  153. package/runtime/python/okstra_ctl/index.py +8 -11
  154. package/runtime/python/okstra_ctl/initial_prompt_materialization.py +79 -7
  155. package/runtime/python/okstra_ctl/invocation.py +3 -6
  156. package/runtime/python/okstra_ctl/json_boundary.py +366 -0
  157. package/runtime/python/okstra_ctl/json_registry.py +10 -12
  158. package/runtime/python/okstra_ctl/jsonl.py +19 -2
  159. package/runtime/python/okstra_ctl/lead_events.py +33 -1
  160. package/runtime/python/okstra_ctl/listing.py +3 -3
  161. package/runtime/python/okstra_ctl/log_report.py +24 -2
  162. package/runtime/python/okstra_ctl/manager_cli.py +92 -7
  163. package/runtime/python/okstra_ctl/manager_store.py +12 -10
  164. package/runtime/python/okstra_ctl/material.py +5 -1
  165. package/runtime/python/okstra_ctl/migrate.py +29 -25
  166. package/runtime/python/okstra_ctl/model_cli.py +3 -15
  167. package/runtime/python/okstra_ctl/model_io_cli.py +1051 -0
  168. package/runtime/python/okstra_ctl/mutation_probe.py +13 -4
  169. package/runtime/python/okstra_ctl/pane_reclaim.py +3 -2
  170. package/runtime/python/okstra_ctl/path_hints.py +12 -0
  171. package/runtime/python/okstra_ctl/paths.py +21 -0
  172. package/runtime/python/okstra_ctl/plan_items.py +115 -4
  173. package/runtime/python/okstra_ctl/plan_items_cli.py +626 -34
  174. package/runtime/python/okstra_ctl/ports/worker_dispatch.py +2 -1
  175. package/runtime/python/okstra_ctl/pr_template.py +3 -2
  176. package/runtime/python/okstra_ctl/project_meta.py +5 -7
  177. package/runtime/python/okstra_ctl/recap.py +5 -4
  178. package/runtime/python/okstra_ctl/reconcile.py +21 -27
  179. package/runtime/python/okstra_ctl/registry/host_discovery.py +3 -2
  180. package/runtime/python/okstra_ctl/registry/provider_registry.py +3 -2
  181. package/runtime/python/okstra_ctl/render.py +74 -16
  182. package/runtime/python/okstra_ctl/render_final_report.py +10 -8
  183. package/runtime/python/okstra_ctl/report_assembly.py +509 -0
  184. package/runtime/python/okstra_ctl/report_contract.py +2 -1
  185. package/runtime/python/okstra_ctl/report_finalize.py +67 -32
  186. package/runtime/python/okstra_ctl/report_html/render.py +3 -2
  187. package/runtime/python/okstra_ctl/report_inputs.py +72 -0
  188. package/runtime/python/okstra_ctl/report_language.py +3 -2
  189. package/runtime/python/okstra_ctl/report_markdown.py +81 -8
  190. package/runtime/python/okstra_ctl/report_narrative.py +351 -0
  191. package/runtime/python/okstra_ctl/report_projections.py +265 -0
  192. package/runtime/python/okstra_ctl/report_synthesis_packet.py +518 -0
  193. package/runtime/python/okstra_ctl/report_views.py +3 -2
  194. package/runtime/python/okstra_ctl/rollup.py +65 -4
  195. package/runtime/python/okstra_ctl/run.py +184 -65
  196. package/runtime/python/okstra_ctl/run_audit.py +3 -2
  197. package/runtime/python/okstra_ctl/run_context.py +6 -9
  198. package/runtime/python/okstra_ctl/run_index_row.py +2 -8
  199. package/runtime/python/okstra_ctl/schedule_semantics.py +5 -2
  200. package/runtime/python/okstra_ctl/schema_excerpt.py +15 -8
  201. package/runtime/python/okstra_ctl/session_transcript.py +27 -1
  202. package/runtime/python/okstra_ctl/set_work_status.py +64 -38
  203. package/runtime/python/okstra_ctl/stage_fix_carry.py +8 -6
  204. package/runtime/python/okstra_ctl/stage_ledger.py +132 -18
  205. package/runtime/python/okstra_ctl/stage_map.py +96 -28
  206. package/runtime/python/okstra_ctl/stage_targets.py +3 -4
  207. package/runtime/python/okstra_ctl/team.py +3 -2
  208. package/runtime/python/okstra_ctl/team_reconcile.py +11 -2
  209. package/runtime/python/okstra_ctl/time_report.py +51 -4
  210. package/runtime/python/okstra_ctl/usage_identity.py +2 -1
  211. package/runtime/python/okstra_ctl/usage_report.py +58 -4
  212. package/runtime/python/okstra_ctl/user_response.py +1431 -66
  213. package/runtime/python/okstra_ctl/wizard.py +11 -44
  214. package/runtime/python/okstra_ctl/work_categories.py +3 -2
  215. package/runtime/python/okstra_ctl/worker_dispatch.py +5 -2
  216. package/runtime/python/okstra_ctl/worker_prompt_body.py +49 -3
  217. package/runtime/python/okstra_ctl/worker_prompt_contract.py +3 -2
  218. package/runtime/python/okstra_ctl/worker_prompt_policy.py +31 -3
  219. package/runtime/python/okstra_ctl/worker_runner.py +14 -12
  220. package/runtime/python/okstra_ctl/workflow.py +2 -1
  221. package/runtime/python/okstra_ctl/worktree.py +3 -2
  222. package/runtime/python/okstra_ctl/wrapper_status.py +4 -2
  223. package/runtime/python/okstra_ctl/write_policy.py +4 -2
  224. package/runtime/python/okstra_token_usage/antigravity.py +39 -12
  225. package/runtime/python/okstra_token_usage/collect.py +90 -38
  226. package/runtime/python/okstra_token_usage/grok.py +127 -0
  227. package/runtime/schemas/final-report-v2.0.schema.json +9 -0
  228. package/runtime/schemas/final-report-v3.0.schema.json +10219 -0
  229. package/runtime/schemas/report-narrative-v3.0.schema.json +30 -0
  230. package/runtime/schemas/report-synthesis-packet-v1.0.schema.json +140 -0
  231. package/runtime/skills/okstra-brief-gen/SKILL.md +9 -7
  232. package/runtime/skills/okstra-code-review/SKILL.md +21 -11
  233. package/runtime/skills/okstra-container-build/SKILL.md +18 -18
  234. package/runtime/skills/okstra-inspect/SKILL.md +12 -11
  235. package/runtime/skills/okstra-inspect/facets/error-zip.md +8 -8
  236. package/runtime/skills/okstra-inspect/facets/errors.md +2 -2
  237. package/runtime/skills/okstra-inspect/facets/history.md +9 -14
  238. package/runtime/skills/okstra-inspect/facets/logs.md +2 -2
  239. package/runtime/skills/okstra-inspect/facets/recap.md +5 -5
  240. package/runtime/skills/okstra-inspect/facets/report.md +6 -10
  241. package/runtime/skills/okstra-inspect/facets/status.md +9 -8
  242. package/runtime/skills/okstra-inspect/facets/time.md +3 -3
  243. package/runtime/skills/okstra-manager/SKILL.md +16 -14
  244. package/runtime/skills/okstra-memory/SKILL.md +3 -3
  245. package/runtime/skills/okstra-pr-gen/SKILL.md +5 -4
  246. package/runtime/skills/okstra-rollup/SKILL.md +6 -16
  247. package/runtime/skills/okstra-run/SKILL.md +7 -7
  248. package/runtime/skills/okstra-schedule-gen/SKILL.md +21 -17
  249. package/runtime/skills/okstra-setup/SKILL.md +21 -13
  250. package/runtime/skills/okstra-setup/references/project-config.md +2 -2
  251. package/runtime/skills/okstra-usage/SKILL.md +10 -10
  252. package/runtime/skills/okstra-user-response/SKILL.md +78 -107
  253. package/runtime/templates/report-writer-prompt-preamble.md +29 -19
  254. package/runtime/templates/reports/html/macros/forms.html +6 -4
  255. package/runtime/templates/reports/schedule.template.md +4 -4
  256. package/runtime/templates/worker-error-contract.md +17 -29
  257. package/runtime/validators/validate-run.py +636 -72
  258. package/runtime/validators/validate_session_conformance.py +43 -7
@@ -78,6 +78,7 @@ from .execution_mutation_audit import (
78
78
  assert_compatible_batch,
79
79
  )
80
80
  from .convergence_store import write_json_atomic
81
+ from .json_boundary import JsonBoundaryError, external_worker_json_source, load_external_json
81
82
  from .assignment_environment import load_assignment_context
82
83
  from .model_pool import ModelPool
83
84
  from .error_log_write import append_observed
@@ -97,12 +98,6 @@ from .schema_excerpt import (
97
98
  excerpt_contract_skew,
98
99
  )
99
100
  from .seeding import installed_version
100
- from .report_finalize import (
101
- STEP_VALIDATE_RUN,
102
- FinalizeContext,
103
- FinalizeError,
104
- run_finalize,
105
- )
106
101
  from .worker_audit_ledger import (
107
102
  check_worker_results_audit,
108
103
  parse_worker_result_name,
@@ -310,7 +305,7 @@ class DispatchPlan:
310
305
  @dataclass(frozen=True)
311
306
  class _BuildOptions:
312
307
  okstra_bin: Path | None
313
- idle_timeout_seconds: int
308
+ idle_timeout_seconds: int | None
314
309
  default_backend: str
315
310
  supported_worker_wrappers: Mapping[str, str]
316
311
  unsupported_worker_label: str
@@ -335,7 +330,7 @@ def build_dispatch_plan(
335
330
  workspace_root: Path,
336
331
  okstra_bin: Path | None = None,
337
332
  requested_workers: Sequence[str] = (),
338
- idle_timeout_seconds: int = 600,
333
+ idle_timeout_seconds: int | None = None,
339
334
  required_lead_runtime: str | None = None,
340
335
  default_backend: str = BACKEND_CLI_WRAPPER,
341
336
  supported_worker_wrappers: Mapping[str, str],
@@ -432,6 +427,43 @@ def dispatch_plan(plan: DispatchPlan, *, wait: bool = True) -> int:
432
427
  return 0
433
428
 
434
429
 
430
+ def _orchestrator_artifact_roots(plan: DispatchPlan) -> tuple[Path, ...]:
431
+ """okstra 자신이 이 run 동안 쓰는 산출물 경로.
432
+
433
+ `_round_artifact_paths` 는 이번 배치의 job 만 알기 때문에 원리상 완결될 수
434
+ 없다. 감사 창에 들어오는 것은 형제 job 의 산출물만이 아니라 같은 run 의 다른
435
+ 라운드가 남기는 것들이다 — 재시도 프롬프트와 그 `.meta.json` / `.publish.lock`,
436
+ 이전 라운드 워커의 `.log` / `.status.json` / `.mutation-audit.json`, critic 의
437
+ 결과, design-prep 요청. 실측 한 건에서 그렇게 빠져나간 경로가 16개였고 그
438
+ 배치의 디스패치 5건이 전부 `readonly source changed` 로 떨어졌다.
439
+
440
+ 그래서 파일을 더 세는 대신 트리를 싣는다. 이 디렉터리 아래는 정의상 okstra 의
441
+ 산출물이고 프로젝트 소스가 아니다 — 소스가 바뀌었는지를 묻는 질문의 답이 될 수
442
+ 없다.
443
+ """
444
+ # 실행 매니페스트는 `<run_dir>/manifests/` 아래 놓인다.
445
+ roots = [plan.manifest_path.resolve().parents[1]]
446
+ # task 매니페스트와 discovery 카탈로그는 run 디렉터리 위에 있고, prep 과
447
+ # 완료 기록이 실행 중에 갱신한다. 경로는 매니페스트가 이미 들고 있으므로
448
+ # 디렉터리 깊이를 세지 않는다 — implementation 은 stage 층이 하나 더 있어
449
+ # 고정 인덱스로는 task root 에 닿지 못한다.
450
+ for key in ("taskManifestPath", "taskCatalogPath"):
451
+ value = plan.manifest.get(key)
452
+ if isinstance(value, str) and value:
453
+ candidate = Path(value)
454
+ roots.append(
455
+ candidate if candidate.is_absolute()
456
+ else (plan.project_root / candidate)
457
+ )
458
+ return tuple(roots)
459
+
460
+
461
+ # 이 모듈이 정산하는 모든 디스패치의 집행 방식. 상수인 이유는
462
+ # `dispatch_state._AGENT_ENFORCEMENT_MODES` 의 다른 값(`host-native-spec-link-gate`)
463
+ # 이 호스트 원시 호출 전용이고 이 경로로 오지 않기 때문이다.
464
+ CLI_DISPATCH_ENFORCEMENT_MODE = "core-pre-dispatch"
465
+
466
+
435
467
  def _round_artifact_paths(plan: DispatchPlan) -> tuple[Path, ...]:
436
468
  """Every artifact this round's workers are entitled to write.
437
469
 
@@ -1214,7 +1246,7 @@ def _invocation_job_fields(
1214
1246
  "instruction_digest": _require_string(digests, "instructionDigest"),
1215
1247
  "prompt_digest": _require_string(digests, "promptDigest"),
1216
1248
  "host_model_value": host_model_value,
1217
- "enforcement_mode": "core-pre-dispatch",
1249
+ "enforcement_mode": CLI_DISPATCH_ENFORCEMENT_MODE,
1218
1250
  }
1219
1251
  schema = metadata.get("schemaVersion")
1220
1252
  identity_version = metadata.get("executionIdentityVersion")
@@ -1488,6 +1520,7 @@ def _mutation_snapshot(
1488
1520
  orchestrator_paths=(
1489
1521
  *round_artifact_paths,
1490
1522
  *_run_errors_log_path(plan),
1523
+ *_orchestrator_artifact_roots(plan),
1491
1524
  plan.manifest_path,
1492
1525
  plan.team_state_path,
1493
1526
  Path(f"{plan.team_state_path}.lock"),
@@ -1840,6 +1873,31 @@ def _retry_from_record(
1840
1873
  _spawn_job(plan, _job_for_next_attempt(job), attempt + 1)
1841
1874
 
1842
1875
 
1876
+ # 사유 한 줄에 실을 경로 개수. 전부 실으면 team-state 행이 부풀고, 하나도 안
1877
+ # 실으면 읽는 쪽이 다른 파일을 뒤져야 한다.
1878
+ _MUTATION_REASON_PATH_LIMIT = 5
1879
+
1880
+
1881
+ def _mutation_failure_reason(mutation) -> str:
1882
+ """왜 실패했는지와 무엇이 바뀌었는지를 한 줄에 담는다.
1883
+
1884
+ 종전에는 디스패치 행에 `readonly source changed` 만 남고 바뀐 경로는
1885
+ lead-events 에만 있었다. 읽는 쪽이 그 파일의 존재를 알아야 원인에 닿을 수
1886
+ 있었고, 실제로 두 번의 진단이 같은 자리에서 빗나갔다 — 한 번은 감사를 손으로
1887
+ 재구현해서, 한 번은 사용자 편집을 의심해서. 감사는 답을 이미 계산해 두었으니
1888
+ 사유 옆에 놓는다.
1889
+ """
1890
+ reason = "; ".join(mutation.violations) or mutation.status
1891
+ paths = list(mutation.changed_paths)
1892
+ if not paths:
1893
+ return reason
1894
+ shown = ", ".join(paths[:_MUTATION_REASON_PATH_LIMIT])
1895
+ remaining = len(paths) - _MUTATION_REASON_PATH_LIMIT
1896
+ if remaining > 0:
1897
+ shown += f", +{remaining} more"
1898
+ return f"{reason} [changed: {shown}]"
1899
+
1900
+
1843
1901
  def _finish_attempt(
1844
1902
  plan: DispatchPlan,
1845
1903
  job: WorkerJob,
@@ -1854,7 +1912,7 @@ def _finish_attempt(
1854
1912
  "contract-failed-unattributed",
1855
1913
  "mutation-present-unresolved",
1856
1914
  }:
1857
- reason = "; ".join(mutation.violations) or mutation.status
1915
+ reason = _mutation_failure_reason(mutation)
1858
1916
  _transition_job_status(plan, job, "error", reason)
1859
1917
  _update_dispatch_status(
1860
1918
  plan.team_state_path, job, attempt, "error", reason
@@ -1867,28 +1925,6 @@ def _finish_attempt(
1867
1925
  settlement = _settle(plan, job, attempt, outcome)
1868
1926
  if settlement.completed:
1869
1927
  result_link = _link_result(plan, job, attempt)
1870
- post_process = _post_process_report_writer_result(plan, job)
1871
- if not post_process["ok"]:
1872
- reason = _require_string(post_process, "reason")
1873
- _transition_job_status(plan, job, "error", reason)
1874
- _update_dispatch_status(
1875
- plan.team_state_path,
1876
- job,
1877
- attempt,
1878
- "error",
1879
- reason,
1880
- )
1881
- details = _failure_details(
1882
- job,
1883
- attempt,
1884
- outcome,
1885
- reason,
1886
- exit_code=1,
1887
- )
1888
- details["postProcessing"] = post_process["steps"]
1889
- _append_event(plan, "worker-failed", details)
1890
- _finish_manifest_attempt(plan, job, attempt, mutation, None)
1891
- return False
1892
1928
  _transition_job_status(plan, job, "completed", "")
1893
1929
  _update_dispatch_status(
1894
1930
  plan.team_state_path,
@@ -1898,7 +1934,6 @@ def _finish_attempt(
1898
1934
  "; ".join(part for part in (settlement.note, result_link["reason"]) if part),
1899
1935
  )
1900
1936
  details = _result_details(job, attempt, outcome)
1901
- details["postProcessing"] = post_process["steps"]
1902
1937
  details["resultLink"] = result_link
1903
1938
  if settlement.error_log_append is not None:
1904
1939
  details["errorLogAppend"] = settlement.error_log_append
@@ -1950,7 +1985,9 @@ def _audit_attempt(
1950
1985
  return ExecutionMutationAudit().compare(
1951
1986
  snapshot,
1952
1987
  policies,
1953
- out_of_plan_edits=_out_of_plan_edit_paths(job.result_path),
1988
+ out_of_plan_edits=_out_of_plan_edit_paths(
1989
+ job.result_path, project_root=job.project_root
1990
+ ),
1954
1991
  attempt_succeeded=succeeded,
1955
1992
  result_present=result_present,
1956
1993
  )
@@ -1987,14 +2024,26 @@ def _declared_out_of_plan_paths(result_path: Path) -> tuple[str, ...]:
1987
2024
  return tuple(paths)
1988
2025
 
1989
2026
 
1990
- def _out_of_plan_edit_paths(result_path: Path) -> tuple[str, ...]:
2027
+ def _out_of_plan_edit_paths(
2028
+ result_path: Path, *, project_root: Path | None = None
2029
+ ) -> tuple[str, ...]:
1991
2030
  if result_path.suffix != ".json":
1992
2031
  return _declared_out_of_plan_paths(result_path)
1993
2032
  if not result_path.is_file():
1994
2033
  return ()
2034
+ if project_root is None:
2035
+ raise ValueError("JSON worker result requires project_root")
1995
2036
  try:
1996
- payload = json.loads(result_path.read_text(encoding="utf-8"))
1997
- except (OSError, UnicodeError, json.JSONDecodeError):
2037
+ # 외부 입력: 워커 모델이 게시한 결과 봉투를 디스패처가 수락 전에 판독한다.
2038
+ payload = load_external_json(
2039
+ external_worker_json_source(
2040
+ result_path,
2041
+ trusted_root=project_root,
2042
+ lane_root=result_path.parent,
2043
+ ),
2044
+ artifact="worker result",
2045
+ )
2046
+ except JsonBoundaryError:
1998
2047
  return ()
1999
2048
  implementation = payload.get("implementation") if isinstance(payload, Mapping) else None
2000
2049
  rows = implementation.get("outOfPlanEdits") if isinstance(implementation, Mapping) else None
@@ -2380,36 +2429,6 @@ def _wrapper_log_tail(job: WorkerJob) -> str | None:
2380
2429
  return tail.decode("utf-8", errors="replace").strip() or None
2381
2430
 
2382
2431
 
2383
- def _post_process_report_writer_result(
2384
- plan: DispatchPlan,
2385
- job: WorkerJob,
2386
- ) -> dict[str, Any]:
2387
- if job.worker_id != REPORT_WRITER_WORKER_ID:
2388
- return {"ok": True, "reason": "", "steps": []}
2389
- try:
2390
- context = FinalizeContext.from_manifest(
2391
- project_root=plan.project_root,
2392
- workspace_root=plan.workspace_root,
2393
- manifest_path=plan.manifest_path,
2394
- team_state_path=plan.team_state_path,
2395
- data_path=job.result_path,
2396
- manifest=plan.manifest,
2397
- )
2398
-
2399
- def settle_before(step_name: str) -> None:
2400
- if step_name == STEP_VALIDATE_RUN:
2401
- _transition_worker_status(
2402
- plan.team_state_path,
2403
- job.worker_id,
2404
- "completed",
2405
- "",
2406
- )
2407
-
2408
- return run_finalize(context, before_step=settle_before)
2409
- except FinalizeError as exc:
2410
- return {"ok": False, "reason": str(exc), "steps": []}
2411
-
2412
-
2413
2432
  def _projectionless_translator(plan: DispatchPlan, job: WorkerJob) -> bool:
2414
2433
  if not (
2415
2434
  job.has_execution_identity
@@ -2866,7 +2885,8 @@ def _job_from_record(project_root: Path, record: Mapping[str, Any]) -> WorkerJob
2866
2885
  completion_paths=tuple(_record_completion_paths(record)),
2867
2886
  worktree_path=_string_value(record.get("worktreePath")),
2868
2887
  role=_require_string(record, "role"),
2869
- idle_timeout_seconds=600,
2888
+ # 복원된 job 은 다시 실행되지 않으므로 예산을 정하지 않는다.
2889
+ idle_timeout_seconds=None,
2870
2890
  dispatch_kind=_require_string(record, "kind"),
2871
2891
  # Restored, never re-issued: both callers rebuild a job from its record
2872
2892
  # to settle a dispatch that already ran, and a fresh id would settle it
@@ -2888,7 +2908,15 @@ def _job_from_record(project_root: Path, record: Mapping[str, Any]) -> WorkerJob
2888
2908
  if isinstance(record.get("hostModelValue"), str)
2889
2909
  else None
2890
2910
  ),
2891
- enforcement_mode=_string_value(record.get("enforcementMode")),
2911
+ # jobs 파일은 `agent-prompt materialize` 가 쓴 `.meta.json` 을 옮겨 적는
2912
+ # 것이 정상 경로인데, materialize 는 이 값을 기록하지 않는다 — 기록할
2913
+ # 것이 없기 때문이다. worker-dispatch 를 타는 디스패치의 집행 방식은
2914
+ # 하나뿐이고, 다른 하나(`host-native-spec-link-gate`)는 리드가 호스트
2915
+ # 원시 호출을 직접 기록할 때만 쓴다. 그래서 손으로 채우게 하지 않는다.
2916
+ enforcement_mode=(
2917
+ _string_value(record.get("enforcementMode"))
2918
+ or CLI_DISPATCH_ENFORCEMENT_MODE
2919
+ ),
2892
2920
  **(identity or {}),
2893
2921
  )
2894
2922
 
@@ -47,6 +47,7 @@ from .execution_manifest import (
47
47
  )
48
48
  from .execution_mutation_audit import ExecutionMutationAudit, MutationSnapshot
49
49
  from .final_report_paths import final_report_data_path
50
+ from .report_inputs import report_narrative_path, uses_report_contract_v3
50
51
  from .worker_prompt_body import REPORT_WRITER_WORKER_ID
51
52
  from .worker_prompt_contract import (
52
53
  PromptRecord,
@@ -67,6 +68,13 @@ from .write_policy import (
67
68
  write_policy_from_payload,
68
69
  )
69
70
  from .convergence_store import write_json_atomic
71
+ from .json_boundary import (
72
+ JsonBoundaryError,
73
+ external_worker_json_source,
74
+ load_external_json,
75
+ load_owned_object,
76
+ write_owned_object_atomic,
77
+ )
70
78
 
71
79
  BACKEND_CLI_WRAPPER = "cli-wrapper"
72
80
  BACKEND_CMUX_PANE = "cmux-pane"
@@ -152,7 +160,11 @@ class WorkerJob:
152
160
  completion_paths: tuple[Path, ...]
153
161
  worktree_path: str
154
162
  role: str
155
- idle_timeout_seconds: int
163
+ # None 은 "이 디스패치가 예산을 정하지 않는다" 는 뜻이고, 그때 예산은
164
+ # 역할이 정한다(`domain/worker_role.role_spec`). 여기에 숫자 기본값을 두면
165
+ # 역할별 예산이 영영 도달 불가능해진다 — 엔트리포인트는 이 자리가 비었을
166
+ # 때만 역할을 보기 때문이다.
167
+ idle_timeout_seconds: int | None
156
168
  dispatch_kind: str
157
169
  invocation_id: str = ""
158
170
  audience: str = ""
@@ -188,7 +200,7 @@ class WorkerJob:
188
200
  str(self.prompt_path),
189
201
  self.worktree_path,
190
202
  self.wrapper_role,
191
- str(self.idle_timeout_seconds),
203
+ self._idle_timeout_argument,
192
204
  "--presentation",
193
205
  self._presentation(),
194
206
  ]
@@ -201,6 +213,18 @@ class WorkerJob:
201
213
  argv += ["--session-id", self.session_id]
202
214
  return argv
203
215
 
216
+ @property
217
+ def _idle_timeout_argument(self) -> str:
218
+ """엔트리포인트가 읽는 유휴 예산 자리.
219
+
220
+ 비워 두면 엔트리포인트가 역할의 예산을 쓴다. 이 자리를 항상 채우던
221
+ 동안에는 `worker_request.idle_timeout` 의 역할 분기가 한 번도 실행되지
222
+ 않았고, executor/verifier 의 1500s 도 함께 죽어 있었다.
223
+ """
224
+ if self.idle_timeout_seconds is None:
225
+ return ""
226
+ return str(self.idle_timeout_seconds)
227
+
204
228
  @property
205
229
  def wrapper_role(self) -> str:
206
230
  """The canonical role the entrypoint's role positional is read as.
@@ -352,8 +376,8 @@ def load_json_object(path: Path, label: str) -> dict[str, Any]:
352
376
  if not path.is_file():
353
377
  raise DispatchError(f"{label} not found: {path}")
354
378
  try:
355
- payload = json.loads(path.read_text(encoding="utf-8"))
356
- except json.JSONDecodeError as exc:
379
+ payload = load_owned_object(path, artifact=label)
380
+ except JsonBoundaryError as exc:
357
381
  raise DispatchError(f"{label} is invalid JSON: {path}: {exc}") from exc
358
382
  if not isinstance(payload, dict):
359
383
  raise DispatchError(f"{label} must be a JSON object: {path}")
@@ -364,26 +388,7 @@ def write_json(path: Path, payload: Mapping[str, Any]) -> None:
364
388
  # Write via temp file + os.replace so a crash mid-write cannot leave a
365
389
  # truncated team-state.json that every later load_json_object rejects;
366
390
  # os.replace is atomic on POSIX, matching run_context._atomic_write_json.
367
- path.parent.mkdir(parents=True, exist_ok=True)
368
- temp_path: Path | None = None
369
- try:
370
- with tempfile.NamedTemporaryFile(
371
- mode="w",
372
- encoding="utf-8",
373
- dir=path.parent,
374
- prefix=f".{path.name}.",
375
- suffix=".tmp",
376
- delete=False,
377
- ) as handle:
378
- temp_path = Path(handle.name)
379
- handle.write(json.dumps(payload, ensure_ascii=False, indent=2) + "\n")
380
- handle.flush()
381
- os.fsync(handle.fileno())
382
- os.replace(temp_path, path)
383
- temp_path = None
384
- finally:
385
- if temp_path is not None:
386
- temp_path.unlink(missing_ok=True)
391
+ write_owned_object_atomic(path, payload, artifact="team state")
387
392
 
388
393
 
389
394
  @contextlib.contextmanager
@@ -707,6 +712,10 @@ def record_verified_agent_dispatch(
707
712
  snapshot = ExecutionMutationAudit().snapshot(
708
713
  (write_policy,),
709
714
  orchestrator_paths=(
715
+ # run 산출물 트리 전체. 같은 run 의 다른 라운드가 감사 창
716
+ # 안에 남기는 프롬프트·로그·사이드카를 파일 단위로 미리 셀
717
+ # 수 없고, 그 아래는 정의상 소스가 아니다.
718
+ Path(run_manifest_path).resolve().parents[1],
710
719
  run_manifest_path,
711
720
  team_state_path,
712
721
  Path(f"{team_state_path}.lock"),
@@ -1086,6 +1095,33 @@ def _finish_native_agent_attempt(
1086
1095
  if dispatch.get("writeEnforcement") != invocation.write_enforcement:
1087
1096
  raise DispatchError("agent dispatch write enforcement changed")
1088
1097
  policy = write_policy_from_payload(invocation.write_policy)
1098
+ status, summary = _native_mutation_result(
1099
+ project_root=project_root,
1100
+ dispatch=dispatch,
1101
+ result_path=result_path,
1102
+ policy=policy,
1103
+ )
1104
+ _close_native_attempt(
1105
+ manifest_path,
1106
+ manifest,
1107
+ identity,
1108
+ status,
1109
+ result_relative,
1110
+ summary,
1111
+ )
1112
+ if status != "ok":
1113
+ violations = summary.get("violations")
1114
+ detail = "; ".join(violations) if isinstance(violations, list) else status
1115
+ raise DispatchError(f"agent mutation audit failed: {detail or status}")
1116
+
1117
+
1118
+ def _native_mutation_result(
1119
+ *,
1120
+ project_root: Path,
1121
+ dispatch: Mapping[str, Any],
1122
+ result_path: Path,
1123
+ policy: WritePolicy,
1124
+ ) -> tuple[str, Mapping[str, Any]]:
1089
1125
  snapshot_value = string_value(dispatch.get("mutationAuditSnapshotPath"))
1090
1126
  if snapshot_value:
1091
1127
  snapshot_payload = load_json_object(
@@ -1099,7 +1135,9 @@ def _finish_native_agent_attempt(
1099
1135
  result = ExecutionMutationAudit().compare(
1100
1136
  snapshot,
1101
1137
  (policy,),
1102
- out_of_plan_edits=_native_out_of_plan_paths(result_path),
1138
+ out_of_plan_edits=_native_out_of_plan_paths(
1139
+ result_path, project_root=project_root
1140
+ ),
1103
1141
  attempt_succeeded=True,
1104
1142
  result_present=True,
1105
1143
  )
@@ -1118,18 +1156,7 @@ def _finish_native_agent_attempt(
1118
1156
  "beforeDigest": "",
1119
1157
  "afterDigest": "",
1120
1158
  }
1121
- _close_native_attempt(
1122
- manifest_path,
1123
- manifest,
1124
- identity,
1125
- status,
1126
- result_relative,
1127
- summary,
1128
- )
1129
- if status != "ok":
1130
- violations = summary.get("violations")
1131
- detail = "; ".join(violations) if isinstance(violations, list) else status
1132
- raise DispatchError(f"agent mutation audit failed: {detail or status}")
1159
+ return status, summary
1133
1160
 
1134
1161
 
1135
1162
  def _close_native_attempt(
@@ -1170,12 +1197,22 @@ def _close_native_attempt(
1170
1197
  )
1171
1198
 
1172
1199
 
1173
- def _native_out_of_plan_paths(result_path: Path) -> tuple[str, ...]:
1200
+ def _native_out_of_plan_paths(
1201
+ result_path: Path, *, project_root: Path
1202
+ ) -> tuple[str, ...]:
1174
1203
  if result_path.suffix != ".json":
1175
1204
  return ()
1176
1205
  try:
1177
- payload = json.loads(result_path.read_text(encoding="utf-8"))
1178
- except (OSError, UnicodeError, json.JSONDecodeError):
1206
+ # 외부 입력: 네이티브 모델 결과에서 선택적 수정 경로만 투영한다.
1207
+ payload = load_external_json(
1208
+ external_worker_json_source(
1209
+ result_path,
1210
+ trusted_root=project_root,
1211
+ lane_root=result_path.parent,
1212
+ ),
1213
+ artifact="native worker result",
1214
+ )
1215
+ except JsonBoundaryError:
1179
1216
  return ()
1180
1217
  implementation = payload.get("implementation") if isinstance(payload, Mapping) else None
1181
1218
  rows = implementation.get("outOfPlanEdits") if isinstance(implementation, Mapping) else None
@@ -1468,6 +1505,8 @@ def dispatch_result_path(
1468
1505
  """
1469
1506
  if worker_id != REPORT_WRITER_WORKER_ID:
1470
1507
  return worker_result_path
1508
+ if uses_report_contract_v3(manifest):
1509
+ return report_narrative_path(project_root, manifest)
1471
1510
  return final_report_data_path(
1472
1511
  resolve_required_path(project_root, manifest, "expectedReportRecordPath")
1473
1512
  )
@@ -1487,6 +1526,8 @@ def dispatch_completion_paths(
1487
1526
  """
1488
1527
  if worker_id != REPORT_WRITER_WORKER_ID:
1489
1528
  return (worker_result_path,)
1529
+ if uses_report_contract_v3(manifest):
1530
+ return (report_narrative_path(project_root, manifest), worker_result_path)
1490
1531
  data_json = final_report_data_path(
1491
1532
  resolve_required_path(project_root, manifest, "expectedReportRecordPath")
1492
1533
  )
@@ -1643,6 +1684,26 @@ def _validate_agent_invocations(
1643
1684
  raise DispatchError("agent invocation contract: " + "; ".join(errors))
1644
1685
 
1645
1686
 
1687
+ def divergent_fields(
1688
+ pairs: Sequence[tuple[str, Any, Any]],
1689
+ *,
1690
+ left: str,
1691
+ right: str,
1692
+ ) -> list[str]:
1693
+ """어긋난 필드만 이름과 두 값으로 적는다.
1694
+
1695
+ 이 두 검사는 각각 일곱 개와 다섯 개의 조건을 `or` 로 묶어 한 문장으로
1696
+ 보고했다. 그래서 재시도 프롬프트의 `dispatchKind` 하나가 틀린 경우와
1697
+ provider 가 틀린 경우가 같은 문장으로 나왔고, 읽는 쪽이 어느 값을 봐야 할지
1698
+ 알 수 없었다. 판정에 쓴 값은 이미 손에 있으므로 감출 이유가 없다.
1699
+ """
1700
+ return [
1701
+ f"{name}: {left}={found!r} {right}={expected!r}"
1702
+ for name, found, expected in pairs
1703
+ if found != expected
1704
+ ]
1705
+
1706
+
1646
1707
  def _v2_job_authority_errors(
1647
1708
  job: WorkerJob,
1648
1709
  metadata: Mapping[str, Any],
@@ -1657,14 +1718,22 @@ def _v2_job_authority_errors(
1657
1718
  errors: list[str] = []
1658
1719
  if job.invocation_ref != identity.invocation_ref:
1659
1720
  errors.append("jobs file invocationRef does not match invocation metadata")
1660
- if (
1661
- job.participant_ref != identity.participant_ref
1662
- or job.role_execution_ref != identity.role_execution_ref
1663
- or job.execution_label != identity.execution_label
1664
- or job.duty_id != identity.duty_id
1665
- or job.attempt != identity.attempt
1666
- ):
1667
- errors.append("jobs file v2 identity does not match invocation metadata")
1721
+ divergent = divergent_fields(
1722
+ (
1723
+ ("participantRef", identity.participant_ref, job.participant_ref),
1724
+ ("roleExecutionRef", identity.role_execution_ref, job.role_execution_ref),
1725
+ ("executionLabel", identity.execution_label, job.execution_label),
1726
+ ("dutyId", identity.duty_id, job.duty_id),
1727
+ ("attempt", identity.attempt, job.attempt),
1728
+ ),
1729
+ left="metadata",
1730
+ right="job",
1731
+ )
1732
+ if divergent:
1733
+ errors.append(
1734
+ "jobs file v2 identity does not match invocation metadata — "
1735
+ + "; ".join(divergent)
1736
+ )
1668
1737
  prompt = metadata.get("prompt")
1669
1738
  metadata_prompt_value = (
1670
1739
  string_value(prompt.get("path")) if isinstance(prompt, Mapping) else ""
@@ -1709,19 +1778,50 @@ def _v2_job_authority_errors(
1709
1778
  assignment=parsed_assignment,
1710
1779
  )
1711
1780
  )
1712
- if (
1713
- not isinstance(execution, Mapping)
1714
- or not isinstance(participant, Mapping)
1715
- or not isinstance(assignment, Mapping)
1716
- or execution.get("participantRef") != job.participant_ref
1717
- or execution.get("executionLabel") != job.execution_label
1718
- or execution.get("role") != job.role
1719
- or execution.get("role") != duty_role
1720
- or execution.get("provider") != job.provider
1721
- or assignment.get("modelExecutionValue") != job.model_execution_value
1722
- or metadata.get("dispatchKind") != job.dispatch_kind
1723
- ):
1724
- errors.append("jobs file v2 identity does not match role execution authority")
1781
+ missing = [
1782
+ name for name, value in (
1783
+ ("roleExecution", execution),
1784
+ ("participant", participant),
1785
+ ("modelAssignment", assignment),
1786
+ )
1787
+ if not isinstance(value, Mapping)
1788
+ ]
1789
+ if missing:
1790
+ errors.append(
1791
+ "jobs file v2 identity does not match role execution authority: "
1792
+ f"the run manifest has no {', '.join(missing)} for this job"
1793
+ )
1794
+ return errors
1795
+ # 어긋난 필드를 이름으로 댄다. 일곱 조건을 한 문장으로 접던 동안, dispatchKind
1796
+ # 하나가 틀린 경우와 provider 가 틀린 경우가 같은 문장으로 보고됐다 —
1797
+ # 재시도 프롬프트를 만들다 `initial-retry` 로 적은 리드가 원인을 좁히지
1798
+ # 못했다. 판정에 쓴 값은 여기 다 있으므로 굳이 감출 이유가 없다.
1799
+ divergent = divergent_fields(
1800
+ (
1801
+ ("participantRef",
1802
+ execution.get("participantRef"), job.participant_ref),
1803
+ ("executionLabel",
1804
+ execution.get("executionLabel"), job.execution_label),
1805
+ ("role", execution.get("role"), job.role),
1806
+ ("modelExecutionValue",
1807
+ assignment.get("modelExecutionValue"), job.model_execution_value),
1808
+ ("dispatchKind",
1809
+ metadata.get("dispatchKind"), job.dispatch_kind),
1810
+ ("provider", execution.get("provider"), job.provider),
1811
+ ),
1812
+ left="manifest",
1813
+ right="job",
1814
+ )
1815
+ if execution.get("role") != duty_role:
1816
+ divergent.append(
1817
+ f"role: roleExecution={execution.get('role')!r} "
1818
+ f"duty {job.duty_id!r} requires {duty_role!r}"
1819
+ )
1820
+ if divergent:
1821
+ errors.append(
1822
+ "jobs file v2 identity does not match role execution authority — "
1823
+ + "; ".join(divergent)
1824
+ )
1725
1825
  return errors
1726
1826
 
1727
1827
 
@@ -1765,7 +1865,7 @@ def worker_jobs_from_file(
1765
1865
  manifest: Mapping[str, Any],
1766
1866
  active_context: Mapping[str, Any] | None = None,
1767
1867
  backend: str,
1768
- idle_timeout_seconds: int,
1868
+ idle_timeout_seconds: int | None,
1769
1869
  default_dispatch_kind: str,
1770
1870
  resolve_wrapper: Callable[[str], Path],
1771
1871
  default_provider: Callable[[str], str],
@@ -1804,7 +1904,7 @@ def _worker_job_from_file(
1804
1904
  manifest: Mapping[str, Any],
1805
1905
  active_context: Mapping[str, Any] | None = None,
1806
1906
  backend: str,
1807
- idle_timeout_seconds: int,
1907
+ idle_timeout_seconds: int | None,
1808
1908
  dispatch_kind: str,
1809
1909
  resolve_wrapper: Callable[[str], Path],
1810
1910
  default_provider: Callable[[str], str],
@@ -15,6 +15,7 @@ from . import improvement_lenses, worktree_registry
15
15
  from .models import provider_wrappers
16
16
  from .workers import resolve_profile_workers
17
17
  from .worktree import is_git_work_tree, main_worktree_path
18
+ from .json_boundary import JsonBoundaryError, load_owned_object
18
19
 
19
20
  SUPPORTED_PHASES: tuple[str, ...] = (
20
21
  "implementation",
@@ -156,7 +157,9 @@ def _architecture_declaration_check(project_root: Path) -> DoctorCheck | None:
156
157
  value is read here: `resolve_architecture()` maps absent and `none` alike.
157
158
  """
158
159
  try:
159
- payload = json.loads(project_json_path(project_root).read_text(encoding="utf-8"))
160
+ payload = load_owned_object(
161
+ project_json_path(project_root), artifact="project configuration"
162
+ )
160
163
  except (OSError, ValueError):
161
164
  return None
162
165
  architecture = payload.get("architecture") if isinstance(payload, dict) else None
@@ -383,8 +386,8 @@ def _worktree_registry_check() -> DoctorCheck:
383
386
  if not path.exists():
384
387
  return _ok("worktree registry", f"no registry yet ({path})")
385
388
  try:
386
- data = json.loads(path.read_text())
387
- except (OSError, json.JSONDecodeError) as exc:
389
+ data = load_owned_object(path, artifact="worktree registry")
390
+ except JsonBoundaryError as exc:
388
391
  return _fail("worktree registry", f"{path} is unreadable: {exc}")
389
392
  if not isinstance(data, dict) or not isinstance(data.get("tasks"), dict):
390
393
  return _fail("worktree registry", f"{path} is missing a valid 'tasks' table")