okstra 0.201.3 → 0.204.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 (273) hide show
  1. package/README.md +3 -3
  2. package/dist/cli-registry.mjs +7 -7
  3. package/dist/cli-registry.mjs.map +1 -1
  4. package/dist/commands/lifecycle/install.mjs +50 -124
  5. package/dist/commands/lifecycle/install.mjs.map +1 -1
  6. package/dist/commands/lifecycle/setup.mjs +15 -0
  7. package/dist/commands/lifecycle/setup.mjs.map +1 -1
  8. package/dist/commands/memory/memory.mjs +41 -8
  9. package/dist/commands/memory/memory.mjs.map +1 -1
  10. package/dist/lib/citation-guidance.d.mts +21 -0
  11. package/dist/lib/citation-guidance.mjs +79 -0
  12. package/dist/lib/citation-guidance.mjs.map +1 -0
  13. package/dist/lib/install-assets.mjs +3 -0
  14. package/dist/lib/install-assets.mjs.map +1 -1
  15. package/dist/lib/runtime-manifest.mjs +2 -1
  16. package/dist/lib/runtime-manifest.mjs.map +1 -1
  17. package/dist/lib/types.d.mts +2 -1
  18. package/docs/architecture/storage-model.md +17 -10
  19. package/docs/architecture.md +26 -20
  20. package/docs/cli.md +16 -13
  21. package/docs/contributor-change-matrix.md +3 -2
  22. package/docs/performance-improvement-plan-v2.md +2 -3
  23. package/docs/project-structure-overview.md +38 -9
  24. package/docs/task-process/README.md +1 -1
  25. package/docs/task-process/common-flow.md +1 -1
  26. package/docs/task-process/final-verification.md +3 -1
  27. package/docs/task-process/implementation.md +1 -1
  28. package/docs/task-process/release-handoff.md +36 -39
  29. package/package.json +1 -2
  30. package/runtime/BUILD.json +2 -2
  31. package/runtime/agents/common.json +28 -0
  32. package/runtime/agents/operations/code-review.json +6 -0
  33. package/runtime/agents/operations/report-translation.json +6 -0
  34. package/runtime/agents/operations/schedule-verification.json +6 -0
  35. package/runtime/agents/roles/analyser.json +18 -0
  36. package/runtime/agents/roles/critic.json +18 -0
  37. package/runtime/agents/roles/designer.json +18 -0
  38. package/runtime/agents/roles/implementer.json +20 -0
  39. package/runtime/agents/roles/leader.json +20 -0
  40. package/runtime/agents/roles/planner.json +18 -0
  41. package/runtime/agents/roles/report-writer.json +19 -0
  42. package/runtime/agents/roles/translator.json +19 -0
  43. package/runtime/agents/roles/verifier.json +18 -0
  44. package/runtime/bin/lib/okstra/usage.sh +5 -5
  45. package/runtime/prompts/duties/acceptance-critic.json +32 -0
  46. package/runtime/prompts/duties/acceptance-verifier.json +32 -0
  47. package/runtime/prompts/duties/analysis-worker.json +32 -0
  48. package/runtime/prompts/duties/code-reviewer.json +32 -0
  49. package/runtime/prompts/duties/diagnosis-worker.json +32 -0
  50. package/runtime/prompts/duties/direction-selection-worker.json +32 -0
  51. package/runtime/prompts/duties/discovery-worker.json +32 -0
  52. package/runtime/prompts/duties/implementation-executor.json +32 -0
  53. package/runtime/prompts/duties/implementation-verifier.json +32 -0
  54. package/runtime/prompts/duties/lead.json +32 -0
  55. package/runtime/prompts/duties/planning-worker.json +36 -0
  56. package/runtime/prompts/duties/report-writer.json +32 -0
  57. package/runtime/prompts/duties/reverification-worker.json +32 -0
  58. package/runtime/prompts/duties/schedule-verifier.json +32 -0
  59. package/runtime/prompts/duties/scope-critic.json +32 -0
  60. package/runtime/prompts/duties/technical-verification-worker.json +32 -0
  61. package/runtime/prompts/duties/translator.json +32 -0
  62. package/runtime/prompts/launch.template.md +3 -2
  63. package/runtime/prompts/lead/adapters/cmux.md +1 -1
  64. package/runtime/prompts/lead/convergence.md +4 -4
  65. package/runtime/prompts/lead/okstra-lead-contract.md +115 -6
  66. package/runtime/prompts/lead/plan-body-verification.md +6 -6
  67. package/runtime/prompts/lead/report-writer.md +3 -3
  68. package/runtime/prompts/profiles/_common-contract.md +2 -2
  69. package/runtime/prompts/profiles/_implementation-executor.md +4 -1
  70. package/runtime/prompts/profiles/_implementation-verifier.md +3 -3
  71. package/runtime/prompts/profiles/change-impact-analysis.json +31 -0
  72. package/runtime/prompts/profiles/change-impact-analysis.md +0 -20
  73. package/runtime/prompts/profiles/error-analysis.json +39 -0
  74. package/runtime/prompts/profiles/error-analysis.md +0 -25
  75. package/runtime/prompts/profiles/feature-analysis.json +31 -0
  76. package/runtime/prompts/profiles/feature-analysis.md +0 -20
  77. package/runtime/prompts/profiles/final-verification.json +30 -0
  78. package/runtime/prompts/profiles/final-verification.md +3 -22
  79. package/runtime/prompts/profiles/forbidden-actions.json +4 -3
  80. package/runtime/prompts/profiles/implementation-option-selection.json +31 -0
  81. package/runtime/prompts/profiles/implementation-option-selection.md +0 -20
  82. package/runtime/prompts/profiles/implementation-planning.json +40 -0
  83. package/runtime/prompts/profiles/implementation-planning.md +6 -29
  84. package/runtime/prompts/profiles/implementation.json +30 -0
  85. package/runtime/prompts/profiles/implementation.md +1 -20
  86. package/runtime/prompts/profiles/improvement-discovery.json +31 -0
  87. package/runtime/prompts/profiles/improvement-discovery.md +0 -20
  88. package/runtime/prompts/profiles/project-analysis.json +31 -0
  89. package/runtime/prompts/profiles/project-analysis.md +0 -20
  90. package/runtime/prompts/profiles/release-handoff.json +5 -0
  91. package/runtime/prompts/profiles/release-handoff.md +71 -73
  92. package/runtime/prompts/profiles/requirements-discovery.json +39 -0
  93. package/runtime/prompts/profiles/requirements-discovery.md +0 -25
  94. package/runtime/prompts/profiles/technical-verification.json +39 -0
  95. package/runtime/prompts/profiles/technical-verification.md +0 -25
  96. package/runtime/prompts/wizard/prompts.ko.json +12 -17
  97. package/runtime/python/okstra_ctl/adapters/hosts/antigravity/relay.md +1 -0
  98. package/runtime/python/okstra_ctl/adapters/hosts/claude-code/adapter.py +3 -0
  99. package/runtime/python/okstra_ctl/adapters/hosts/claude-code/manifest.json +1 -1
  100. package/runtime/python/okstra_ctl/adapters/hosts/claude-code/relay.md +4 -3
  101. package/runtime/python/okstra_ctl/adapters/hosts/claude-code/worker-session.md +108 -0
  102. package/runtime/python/okstra_ctl/adapters/hosts/codex/relay.md +1 -0
  103. package/runtime/python/okstra_ctl/adapters/hosts/grok/relay.md +2 -0
  104. package/runtime/python/okstra_ctl/adapters/hosts/kimi/relay.md +2 -0
  105. package/runtime/python/okstra_ctl/adapters/providers/antigravity/adapter.py +8 -1
  106. package/runtime/python/okstra_ctl/adapters/providers/claude/adapter.py +8 -0
  107. package/runtime/python/okstra_ctl/adapters/providers/codex/adapter.py +23 -6
  108. package/runtime/python/okstra_ctl/adapters/providers/grok/adapter.py +6 -2
  109. package/runtime/python/okstra_ctl/agent/invocation.py +168 -113
  110. package/runtime/python/okstra_ctl/agent/prompt_cli/cli.py +120 -0
  111. package/runtime/python/okstra_ctl/agent/prompt_cli/materialize.py +107 -2
  112. package/runtime/python/okstra_ctl/agent/prompt_cli/run_identity.py +0 -49
  113. package/runtime/python/okstra_ctl/analysis_packet.py +4 -1
  114. package/runtime/python/okstra_ctl/application/open_worker.py +6 -1
  115. package/runtime/python/okstra_ctl/assignment_resolver.py +16 -5
  116. package/runtime/python/okstra_ctl/cmux.py +69 -20
  117. package/runtime/python/okstra_ctl/code_review_target.py +16 -8
  118. package/runtime/python/okstra_ctl/conformance.py +43 -0
  119. package/runtime/python/okstra_ctl/consumers.py +6 -3
  120. package/runtime/python/okstra_ctl/container.py +31 -8
  121. package/runtime/python/okstra_ctl/context_cost.py +11 -15
  122. package/runtime/python/okstra_ctl/contract_refreeze.py +156 -0
  123. package/runtime/python/okstra_ctl/convergence_critic_prompt.py +4 -6
  124. package/runtime/python/okstra_ctl/convergence_provenance.py +81 -18
  125. package/runtime/python/okstra_ctl/design_prep.py +34 -1
  126. package/runtime/python/okstra_ctl/dispatch_core.py +53 -27
  127. package/runtime/python/okstra_ctl/domain/host.py +5 -0
  128. package/runtime/python/okstra_ctl/domain/worker_runtime.py +10 -0
  129. package/runtime/python/okstra_ctl/error_report.py +4 -3
  130. package/runtime/python/okstra_ctl/execution_manifest.py +71 -18
  131. package/runtime/python/okstra_ctl/execution_mutation_audit.py +21 -21
  132. package/runtime/python/okstra_ctl/handoff.py +167 -277
  133. package/runtime/python/okstra_ctl/implementation_stage.py +9 -0
  134. package/runtime/python/okstra_ctl/initial_prompt_materialization.py +113 -0
  135. package/runtime/python/okstra_ctl/lead_progress.py +1 -1
  136. package/runtime/python/okstra_ctl/legacy_model_selection.py +2 -2
  137. package/runtime/python/okstra_ctl/manager_cli.py +175 -14
  138. package/runtime/python/okstra_ctl/manager_launch.py +41 -19
  139. package/runtime/python/okstra_ctl/manager_paths.py +22 -3
  140. package/runtime/python/okstra_ctl/manager_split.py +474 -0
  141. package/runtime/python/okstra_ctl/manager_store.py +331 -21
  142. package/runtime/python/okstra_ctl/manager_sync.py +37 -16
  143. package/runtime/python/okstra_ctl/manager_view.py +217 -0
  144. package/runtime/python/okstra_ctl/model_discovery.py +30 -0
  145. package/runtime/python/okstra_ctl/model_io/lines.py +14 -1
  146. package/runtime/python/okstra_ctl/model_io/renderers.py +4 -3
  147. package/runtime/python/okstra_ctl/models.py +1 -1
  148. package/runtime/python/okstra_ctl/next_phase.py +16 -6
  149. package/runtime/python/okstra_ctl/operation_invocation.py +86 -0
  150. package/runtime/python/okstra_ctl/option_comparison.py +168 -0
  151. package/runtime/python/okstra_ctl/path_hints.py +9 -0
  152. package/runtime/python/okstra_ctl/paths.py +3 -0
  153. package/runtime/python/okstra_ctl/plan_items_cli.py +6 -1
  154. package/runtime/python/okstra_ctl/profile_show.py +42 -1
  155. package/runtime/python/okstra_ctl/qa_commands.py +15 -0
  156. package/runtime/python/okstra_ctl/registry/host_discovery.py +20 -12
  157. package/runtime/python/okstra_ctl/registry/host_registry.py +11 -0
  158. package/runtime/python/okstra_ctl/render.py +50 -0
  159. package/runtime/python/okstra_ctl/report_contract.py +1 -1
  160. package/runtime/python/okstra_ctl/report_finalize.py +13 -6
  161. package/runtime/python/okstra_ctl/report_html/view_models/final_verification.py +2 -21
  162. package/runtime/python/okstra_ctl/report_html/view_models/release_handoff.py +21 -3
  163. package/runtime/python/okstra_ctl/report_html/visualizations.py +0 -5
  164. package/runtime/python/okstra_ctl/report_synthesis_packet.py +177 -17
  165. package/runtime/python/okstra_ctl/report_translation.py +2 -1
  166. package/runtime/python/okstra_ctl/report_translation_dispatch.py +69 -9
  167. package/runtime/python/okstra_ctl/role_requirements.py +142 -129
  168. package/runtime/python/okstra_ctl/rollup.py +3 -1
  169. package/runtime/python/okstra_ctl/run.py +76 -29
  170. package/runtime/python/okstra_ctl/schedule_semantics.py +17 -6
  171. package/runtime/python/okstra_ctl/stage_fix_carry.py +23 -4
  172. package/runtime/python/okstra_ctl/stage_integrate.py +178 -18
  173. package/runtime/python/okstra_ctl/stage_map.py +16 -2
  174. package/runtime/python/okstra_ctl/stage_targets.py +209 -43
  175. package/runtime/python/okstra_ctl/team.py +22 -13
  176. package/runtime/python/okstra_ctl/time_report.py +2 -1
  177. package/runtime/python/okstra_ctl/usage_report.py +3 -1
  178. package/runtime/python/okstra_ctl/verification_target.py +13 -2
  179. package/runtime/python/okstra_ctl/wizard/confirmation.py +3 -9
  180. package/runtime/python/okstra_ctl/wizard/ids.py +1 -1
  181. package/runtime/python/okstra_ctl/wizard/registry.py +1 -1
  182. package/runtime/python/okstra_ctl/wizard/state.py +3 -5
  183. package/runtime/python/okstra_ctl/wizard/steps_plan.py +3 -23
  184. package/runtime/python/okstra_ctl/worker_prompt_contract.py +5 -1
  185. package/runtime/python/okstra_ctl/worker_prompt_headers.py +35 -7
  186. package/runtime/python/okstra_ctl/worker_prompt_policy.py +66 -48
  187. package/runtime/python/okstra_ctl/workflow.py +1 -1
  188. package/runtime/python/okstra_ctl/worktree/__init__.py +3 -1
  189. package/runtime/python/okstra_ctl/worktree/naming.py +9 -0
  190. package/runtime/python/okstra_ctl/worktree_registry.py +38 -9
  191. package/runtime/python/okstra_token_usage/pricing.py +6 -4
  192. package/runtime/schemas/agent-common-v1.schema.json +34 -0
  193. package/runtime/schemas/agent-duty-v1.schema.json +38 -0
  194. package/runtime/schemas/agent-operation-v1.schema.json +11 -0
  195. package/runtime/schemas/agent-profile-v1.schema.json +46 -0
  196. package/runtime/schemas/agent-role-v1.schema.json +29 -0
  197. package/runtime/schemas/final-report-v2.0.schema.json +118 -97
  198. package/runtime/schemas/final-report-v3.0.schema.json +118 -97
  199. package/runtime/skills/okstra-brief-gen/SKILL.md +84 -4
  200. package/runtime/skills/okstra-chat/SKILL.md +2 -2
  201. package/runtime/skills/okstra-code-review/SKILL.md +23 -9
  202. package/runtime/skills/okstra-container-build/SKILL.md +10 -10
  203. package/runtime/skills/okstra-inspect/SKILL.md +1 -1
  204. package/runtime/skills/okstra-inspect/facets/cost.md +1 -1
  205. package/runtime/skills/okstra-inspect/facets/error-zip.md +9 -9
  206. package/runtime/skills/okstra-inspect/facets/errors.md +16 -16
  207. package/runtime/skills/okstra-inspect/facets/logs.md +7 -7
  208. package/runtime/skills/okstra-inspect/facets/recap.md +2 -2
  209. package/runtime/skills/okstra-inspect/facets/report.md +1 -1
  210. package/runtime/skills/okstra-inspect/facets/status.md +4 -3
  211. package/runtime/skills/okstra-inspect/facets/time.md +11 -10
  212. package/runtime/skills/okstra-manager/SKILL.md +70 -5
  213. package/runtime/skills/okstra-pr-gen/SKILL.md +6 -5
  214. package/runtime/skills/okstra-rollup/SKILL.md +5 -5
  215. package/runtime/skills/okstra-run/SKILL.md +32 -13
  216. package/runtime/skills/okstra-schedule-gen/SKILL.md +19 -14
  217. package/runtime/skills/okstra-setup/SKILL.md +21 -10
  218. package/runtime/skills/okstra-setup/references/project-config.md +7 -6
  219. package/runtime/skills/okstra-usage/SKILL.md +1 -1
  220. package/runtime/skills/okstra-user-response/SKILL.md +1 -1
  221. package/runtime/templates/manager/view.template.html +109 -0
  222. package/runtime/templates/report-writer-prompt-preamble.md +8 -0
  223. package/runtime/templates/reports/brief.template.md +14 -4
  224. package/runtime/templates/reports/html/i18n/en.json +7 -4
  225. package/runtime/templates/reports/html/i18n/ko.json +7 -4
  226. package/runtime/templates/reports/html/tasks/final-verification.template.html +2 -2
  227. package/runtime/templates/reports/html/tasks/release-handoff.template.html +8 -5
  228. package/runtime/templates/reports/i18n/en.json +1 -1
  229. package/runtime/templates/reports/md/tasks/release-handoff.template.md +1 -1
  230. package/runtime/templates/reports/release-handoff-input.template.md +6 -4
  231. package/runtime/templates/translator-prompt-preamble.md +36 -0
  232. package/runtime/validators/checks/validate-assets-01.py +7 -8
  233. package/runtime/validators/validate-brief.py +77 -2
  234. package/runtime/validators/validate-implementation-plan-stages.py +2 -1
  235. package/runtime/validators/validate-run.py +59 -9
  236. package/runtime/validators/validate-schedule.py +9 -0
  237. package/docs/for-ai/README.md +0 -68
  238. package/docs/for-ai/skills/okstra-brief-gen.md +0 -262
  239. package/docs/for-ai/skills/okstra-chat.md +0 -34
  240. package/docs/for-ai/skills/okstra-code-review.md +0 -57
  241. package/docs/for-ai/skills/okstra-container-build.md +0 -129
  242. package/docs/for-ai/skills/okstra-inspect.md +0 -262
  243. package/docs/for-ai/skills/okstra-manager.md +0 -69
  244. package/docs/for-ai/skills/okstra-memory.md +0 -126
  245. package/docs/for-ai/skills/okstra-pr-gen.md +0 -49
  246. package/docs/for-ai/skills/okstra-rollup.md +0 -114
  247. package/docs/for-ai/skills/okstra-run.md +0 -250
  248. package/docs/for-ai/skills/okstra-schedule-gen.md +0 -240
  249. package/docs/for-ai/skills/okstra-setup.md +0 -158
  250. package/docs/for-ai/skills/okstra-usage.md +0 -29
  251. package/docs/for-ai/skills/okstra-user-response.md +0 -72
  252. package/runtime/agents/workers/claude-worker.md +0 -128
  253. package/runtime/agents/workers/report-writer-worker.md +0 -37
  254. package/runtime/agents/workers/translator-worker.md +0 -63
  255. package/runtime/prompts/duties/acceptance-critic.md +0 -44
  256. package/runtime/prompts/duties/acceptance-verifier.md +0 -44
  257. package/runtime/prompts/duties/analysis-worker.md +0 -44
  258. package/runtime/prompts/duties/code-reviewer.md +0 -44
  259. package/runtime/prompts/duties/common.md +0 -39
  260. package/runtime/prompts/duties/diagnosis-worker.md +0 -44
  261. package/runtime/prompts/duties/direction-selection-worker.md +0 -44
  262. package/runtime/prompts/duties/discovery-worker.md +0 -44
  263. package/runtime/prompts/duties/implementation-executor.md +0 -44
  264. package/runtime/prompts/duties/implementation-verifier.md +0 -44
  265. package/runtime/prompts/duties/lead.md +0 -44
  266. package/runtime/prompts/duties/planning-worker.md +0 -52
  267. package/runtime/prompts/duties/report-writer.md +0 -44
  268. package/runtime/prompts/duties/reverification-worker.md +0 -44
  269. package/runtime/prompts/duties/schedule-verifier.md +0 -44
  270. package/runtime/prompts/duties/scope-critic.md +0 -44
  271. package/runtime/prompts/duties/technical-verification-worker.md +0 -44
  272. package/runtime/prompts/duties/translator.md +0 -44
  273. package/runtime/python/okstra_ctl/pane_title.py +0 -154
@@ -219,6 +219,49 @@ def exempt_stage_surface_conflicts(
219
219
  return conflicts
220
220
 
221
221
 
222
+ def declared_stage_surface_gaps(
223
+ data: object, patterns: object = None,
224
+ ) -> list[dict[str, object]]:
225
+ """`Conformance tests:` 의 `requires` 가 계획된 경로의 표면을 다 덮지 못한 stage.
226
+
227
+ 구현 게이트는 두 가지를 동시에 요구한다 — diff 가 건드린 표면을 `requires`
228
+ 가 덮을 것(`_validate_conformance_surfaces`), 그리고 선언된 stage 의
229
+ `requires` 가 계획 선언과 같을 것(`_declared_conformance_errors`). 계획이
230
+ `requires` 를 좁게 적었는데 그 stage 가 계획한 경로가 다른 표면을 건드리면
231
+ 구현은 둘 중 하나에 반드시 걸린다. 실측(2026-09-22, fontradar-v2-api dev-10860
232
+ Stage 1~3): `requires=[io]` 로 선언하고 `catalog.controller.ts`(http)를
233
+ 계획해, 구현 run 이 "undeclared surface" 와 "requires mismatch" 를 번갈아 냈다.
234
+ 면제 stage 와 같은 판별을 선언 stage 에도 승인 전에 한다.
235
+
236
+ 반환 행: `{"stage": <int>, "surfaces": [빠진 표면..], "paths": [..],
237
+ "requires": [선언된 것..]}`.
238
+ """
239
+ planning = data.get("implementationPlanning") if isinstance(data, dict) else None
240
+ stages = planning.get("stages") if isinstance(planning, dict) else None
241
+ gaps: list[dict[str, object]] = []
242
+ for stage in stages if isinstance(stages, list) else []:
243
+ if not isinstance(stage, dict):
244
+ continue
245
+ parsed = parse_conformance_tests(stage.get("conformanceTests"))
246
+ number = stage.get("stage")
247
+ if parsed is None or not isinstance(number, int) or isinstance(number, bool):
248
+ continue
249
+ _script, requires = parsed
250
+ touching = sorted(
251
+ path for path in _stage_planned_paths(stage)
252
+ if detect_surfaces([path], patterns) - requires
253
+ )
254
+ missing = detect_surfaces(touching, patterns) - requires
255
+ if missing:
256
+ gaps.append({
257
+ "stage": number,
258
+ "surfaces": sorted(missing),
259
+ "paths": touching,
260
+ "requires": sorted(requires),
261
+ })
262
+ return gaps
263
+
264
+
222
265
  def _stage_planned_paths(stage: dict) -> list[str]:
223
266
  """stage 의 step 들이 계획한 경로 — 경로 모양인 문자열만.
224
267
 
@@ -244,15 +244,18 @@ def append_verified(plan_run_root: Path, *, impl_task_key: str, stage: int,
244
244
 
245
245
 
246
246
  def append_pr(plan_run_root: Path, *, impl_task_key: str, stages: List[int],
247
- branch: str, url: str) -> None:
248
- """handoff 의 PR 생성/재사용 기록. 같은 branch 의 pr 행이 이미 있으면 멱등."""
247
+ branch: str, url: str, base: str = "") -> None:
248
+ """handoff 의 PR 생성/재사용 기록. 같은 branch 의 pr 행이 이미 있으면 멱등.
249
+
250
+ `base` 는 그 PR 이 올라앉은 브랜치다. stage PR 은 스택이라 base 가 릴리스
251
+ 브랜치가 아닐 수 있고, 어느 순서로 머지해야 하는지는 이 값에만 남는다."""
249
252
  with consumers_mutex(plan_run_root):
250
253
  for row in read_consumers(plan_run_root):
251
254
  if row.get("status") == "pr" and row.get("branch") == branch:
252
255
  return
253
256
  _append_row(plan_run_root, {
254
257
  "impl_task_key": impl_task_key, "stages": sorted(stages),
255
- "status": "pr", "branch": branch, "url": url,
258
+ "status": "pr", "branch": branch, "base": base, "url": url,
256
259
  })
257
260
 
258
261
 
@@ -213,8 +213,18 @@ def _compose_ps(project_name: str, cwd: str, include_stopped: bool = False) -> l
213
213
  return rows
214
214
 
215
215
 
216
- def _docker_run(argv: list[str], cwd: str | None = None) -> str:
216
+ def _docker_run(argv: list[str], cwd: str | None = None, *, check: bool = False) -> str:
217
+ """docker 명령을 돌려 stdout 을 돌려준다.
218
+
219
+ `check=True` 면 0 이 아닌 종료를 PrepareError 로 올린다. status 가 빈 목록을,
220
+ down 이 성공을 보고하는데 실제로는 docker 가 실패한 경우를 막는다. config 계열
221
+ 조회는 실패해도 진행하도록 설계돼 있어 기본값은 그대로 둔다."""
217
222
  result = subprocess.run(argv, capture_output=True, text=True, check=False, cwd=cwd)
223
+ if check and result.returncode != 0:
224
+ detail = (result.stderr or result.stdout or "").strip().splitlines()
225
+ raise PrepareError(
226
+ f"`{' '.join(argv[:4])}` 가 exit {result.returncode} 로 실패했습니다: "
227
+ f"{detail[-1] if detail else '출력 없음'}")
218
228
  return result.stdout or ""
219
229
 
220
230
 
@@ -222,7 +232,7 @@ def query_containers_by_label(project_name: str) -> list[dict]:
222
232
  """라벨 SSOT(spec §11): `okstra.project-name` 으로 컨테이너를 질의한다.
223
233
 
224
234
  registry 가 아니라 docker 라벨이 컨테이너 존재의 기준값이다."""
225
- out = _docker_run([
235
+ out = _docker_run(check=True, argv=[
226
236
  "docker", "ps", "-a", "--filter",
227
237
  f"label=okstra.project-name={project_name}",
228
238
  "--format", (
@@ -431,7 +441,7 @@ def provision_container_group(
431
441
  raise PrepareError(
432
442
  f"container up: `up -d` 후 기동된 컨테이너가 없습니다(`docker compose -p "
433
443
  f"{project_name} ps -a` 로 확인). compose 정의·활성 profile 을 점검한 뒤 "
434
- f"`okstra container down --task-key {task_key}` 로 정리하고 다시 시도하세요.")
444
+ f"`{_down_hint(project_root, task_key)}` 로 정리하고 다시 시도하세요.")
435
445
  failed = poll_healthcheck(
436
446
  project_name=project_name, cwd=str(worktree), services=started,
437
447
  healthcheck_timeout_seconds=healthcheck_timeout_seconds,
@@ -445,8 +455,8 @@ def provision_container_group(
445
455
  raise PrepareError(
446
456
  f"container up: 다음 서비스가 시간 내 기동하지 못했습니다: {', '.join(failed)} "
447
457
  f"(timeout={healthcheck_timeout_seconds}s). `docker compose -p "
448
- f"{project_name} logs` 로 원인을 확인한 뒤 `okstra container down "
449
- f"--task-key {task_key}` 로 정리하세요.")
458
+ f"{project_name} logs` 로 원인을 확인한 뒤 "
459
+ f"`{_down_hint(project_root, task_key)}` 로 정리하세요.")
450
460
  # deploy-state 는 실제 기동된 started 만 기록한다 — full services 를 쓰면
451
461
  # profiles 로 안 뜬 서비스가 `down --all` 스윕의 대상으로 남는다.
452
462
  _write_deploy_state(cp, project_name=project_name, task_key=task_key,
@@ -528,12 +538,18 @@ def status_container_group(*, project_root, task_key: str) -> dict:
528
538
  "containers": query_containers_by_label(project_name)}
529
539
 
530
540
 
541
+ def _down_hint(project_root, task_key: str) -> str:
542
+ """실패 메시지가 안내하는 정리 명령. 파서가 요구하는 인자를 모두 싣는다."""
543
+ return f"okstra container down --project-root {project_root} --task-key {task_key}"
544
+
545
+
531
546
  def down_container_group(*, project_root, task_key: str = "", all_groups: bool = False) -> dict:
532
- """라벨 쿼리로 컨테이너를 down 한다."""
547
+ """compose 프로젝트 이름으로 task 의 컨테이너 그룹을 down 한다."""
533
548
  from .ids import compose_project_name
534
549
 
535
550
  project_root = Path(project_root)
536
551
  results = []
552
+ failures: list[str] = []
537
553
  for project_id, group, task_id in _down_targets(project_root, task_key, all_groups):
538
554
  project_name = compose_project_name(project_id, group, task_id)
539
555
  # `docker rm -f` 는 컨테이너만 지우고 compose 가 만든 네트워크를 누수시킨다.
@@ -541,9 +557,16 @@ def down_container_group(*, project_root, task_key: str = "", all_groups: bool =
541
557
  # 단위 컨테이너+네트워크를 회수한다. `-v` 는 의도적으로 빼서 compose 에
542
558
  # 선언된 named volume(예: DB 데이터)을 보존한다 — down 한 번에 사용자
543
559
  # 영속 데이터를 파괴하지 않는다.
544
- _docker_run(["docker", "compose", "-p", project_name, "down",
545
- "--remove-orphans"])
560
+ try:
561
+ _docker_run(["docker", "compose", "-p", project_name, "down",
562
+ "--remove-orphans"], check=True)
563
+ except PrepareError as exc:
564
+ failures.append(f"{project_name}: {exc}")
565
+ continue
546
566
  results.append({"projectName": project_name})
567
+ if failures:
568
+ # 나머지 대상은 끝까지 내리고, 실패한 것만 모아 알린다.
569
+ raise PrepareError("container down 실패 — " + "; ".join(failures))
547
570
  return {"downed": results}
548
571
 
549
572
 
@@ -2,7 +2,8 @@
2
2
 
3
3
  The estimator answers a narrow operational question: how much file/context
4
4
  surface does the current task bundle ask the lead, analysis workers, and
5
- report-writer to absorb? It does not mutate task artifacts.
5
+ report-writer to absorb? It writes no report or run artifact; resolving the task
6
+ may record a finished implementation outcome (see `find_task_root`).
6
7
  """
7
8
  from __future__ import annotations
8
9
 
@@ -38,11 +39,9 @@ HOT_PATH_LEAD_RESOURCES = (
38
39
  "convergence",
39
40
  "report-writer",
40
41
  )
41
- WORKER_AGENT_FILES = (
42
- "claude-worker.md",
43
- "report-writer-worker.md",
44
- "translator-worker.md",
45
- )
42
+ # 모든 디스패치 프롬프트 앞머리에 실리는 계약. 종전에는 `~/.claude/agents` 의
43
+ # 에이전트 정의를 셌는데, 그 정의는 더 이상 설치되지 않는다(ADR-0017).
44
+ HOT_PATH_AGENT_CONTRACTS = ("agents/common.json",)
46
45
  TIMESTAMPED_ARTIFACT_RE = re.compile(r"\d{4}-\d{2}-\d{2}[_T]\d{2}-\d{2}-\d{2}")
47
46
 
48
47
 
@@ -136,7 +135,7 @@ def _duty_contract_files(
136
135
  if not isinstance(duty_root_value, str) or not duty_root_value:
137
136
  return []
138
137
  duty_root = project_root / duty_root_value
139
- files = [duty_root / "common.md", duty_root / f"{audience}.md"]
138
+ files = [duty_root / "common.json", duty_root / f"{audience}.json"]
140
139
  return [path for path in files if path.is_file()]
141
140
 
142
141
 
@@ -239,19 +238,15 @@ def _skill_assets_metric() -> dict:
239
238
  instruction footprint and are the prompt-diet ranking input."""
240
239
  entries = []
241
240
  okstra_home = _okstra_asset_home()
242
- claude_home = Path.home() / ".claude"
243
241
  for name in HOT_PATH_LEAD_RESOURCES:
244
242
  path = _installed_or_dev(
245
243
  okstra_home / "prompts" / "lead" / f"{name}.md",
246
244
  f"prompts/lead/{name}.md",
247
245
  )
248
246
  entries.append((f"resource:{name}", path))
249
- for fname in WORKER_AGENT_FILES:
250
- path = _installed_or_dev(
251
- claude_home / "agents" / fname,
252
- f"runtime/agents/workers/{fname}",
253
- )
254
- entries.append((f"agent:{fname}", path))
247
+ for relative in HOT_PATH_AGENT_CONTRACTS:
248
+ path = _installed_or_dev(okstra_home / relative, relative)
249
+ entries.append((f"contract:{relative}", path))
255
250
 
256
251
  files = []
257
252
  for label, path in entries:
@@ -512,7 +507,8 @@ _CLI_EPILOG = r"""Usage:
512
507
 
513
508
  Output: JSON with task file counts, current-run counts, lead Phase 1 read
514
509
  surface, analysis-worker initial read surface, and report-writer synthesis
515
- surface. This is read-only; it never mutates task artifacts.
510
+ surface. Resolving a task may record a finished implementation outcome in its task
511
+ manifest, the task catalog, and consumers.jsonl; nothing else is written.
516
512
  """
517
513
 
518
514
 
@@ -0,0 +1,156 @@
1
+ """진행 중인 run 의 동결 계약을 현재 형식으로 다시 동결한다.
2
+
3
+ run 은 시작할 때 직무·역할·공통 계약을 자기 폴더로 동결하고, 그 다이제스트를
4
+ 매니페스트에 적는다. 계약 형식이 바뀐 릴리스를 설치하면 그 run 의 다이제스트는
5
+ 새 규칙으로 계산한 값과 영영 다르고, 프롬프트를 하나도 더 만들 수 없다 — 실측
6
+ 2026-09-23(fontradar-v2-api dev-10860 implementation-planning-002): 같은 run 에서
7
+ 8회 성공한 뒤 설치가 한 번 일어나자 그다음 materialize 가
8
+ `run duty snapshot catalog digest does not match` 로 멈췄다.
9
+
10
+ 이 모듈은 그 run 을 현재 형식으로 다시 동결하고 매니페스트의 다이제스트와 형식
11
+ 표식을 갱신한다. 계약 본문은 런타임이 소유하므로 새로 쓰는 것이 원래 값이고,
12
+ run 의 증거(프롬프트·결과·원장)는 건드리지 않는다. 사용자가 명시적으로 부를 때만
13
+ 도는 복구 경로다.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import os
18
+ import shutil
19
+ import tempfile
20
+ from dataclasses import dataclass
21
+ from pathlib import Path
22
+ from typing import Any, Mapping
23
+
24
+ from .agent.invocation import digest_duty_catalog
25
+ from .json_boundary import (
26
+ JsonBoundaryError,
27
+ load_owned_object,
28
+ write_owned_object_atomic,
29
+ )
30
+ from .run import (
31
+ CONTRACT_FORMAT_VERSION,
32
+ PrepareError,
33
+ _common_contract_source,
34
+ _duty_catalog_source,
35
+ )
36
+
37
+
38
+ class ContractRefreezeError(RuntimeError):
39
+ """다시 동결할 수 없는 run. 메시지가 그 자리를 이름한다."""
40
+
41
+
42
+ @dataclass(frozen=True)
43
+ class RefreezeResult:
44
+ duty_root: str
45
+ previous_digest: str
46
+ digest: str
47
+ previous_format_version: int
48
+ format_version: int
49
+ changed: bool
50
+
51
+
52
+ def frozen_format_version(contract: Mapping[str, Any]) -> int:
53
+ """이 run 이 동결한 계약 형식. 표식이 없는 run 은 1(Markdown 직무)이다."""
54
+ value = contract.get("contractFormatVersion")
55
+ return value if isinstance(value, int) and not isinstance(value, bool) else 1
56
+
57
+
58
+ def refreeze_message(duty_root: str) -> str:
59
+ """형식이 어긋난 run 이 받아야 할 문장 — 원인과 다음 명령."""
60
+ return (
61
+ "run froze an older duty contract format and this install cannot read "
62
+ f"it: {duty_root}. Re-freeze this run's contracts with "
63
+ "`okstra agent-prompt refreeze-contracts --project-root <dir> "
64
+ "--run-manifest <path>`, then repeat the command that failed."
65
+ )
66
+
67
+
68
+ def refreeze_run_contracts(
69
+ *, project_root: Path, run_manifest_path: Path, workspace_root: Path,
70
+ ) -> RefreezeResult:
71
+ """이 run 의 계약 폴더를 현재 런타임 카탈로그로 갈아 끼운다."""
72
+ root = project_root.resolve()
73
+ manifest_path = _resolve(root, run_manifest_path)
74
+ manifest = _load(manifest_path, "run manifest")
75
+ contract = manifest.get("agentContract")
76
+ if not isinstance(contract, dict):
77
+ raise ContractRefreezeError(f"run manifest has no agentContract: {manifest_path}")
78
+ duty_root_rel = str(contract.get("dutyRootPath") or "")
79
+ if not duty_root_rel:
80
+ raise ContractRefreezeError(f"agentContract has no dutyRootPath: {manifest_path}")
81
+ duty_root = _resolve(root, Path(duty_root_rel))
82
+ previous_digest = str(contract.get("catalogDigest") or "")
83
+ previous_format = frozen_format_version(contract)
84
+ try:
85
+ source = _duty_catalog_source(workspace_root)
86
+ common_source = _common_contract_source(workspace_root)
87
+ except PrepareError as exc:
88
+ raise ContractRefreezeError(str(exc)) from exc
89
+ digest = _replace_snapshot(duty_root, source, common_source)
90
+ changed = (
91
+ digest != previous_digest or previous_format != CONTRACT_FORMAT_VERSION
92
+ )
93
+ if changed:
94
+ contract["catalogDigest"] = digest
95
+ contract["contractFormatVersion"] = CONTRACT_FORMAT_VERSION
96
+ _write_manifest(manifest_path, manifest)
97
+ return RefreezeResult(
98
+ duty_root=str(duty_root),
99
+ previous_digest=previous_digest,
100
+ digest=digest,
101
+ previous_format_version=previous_format,
102
+ format_version=CONTRACT_FORMAT_VERSION,
103
+ changed=changed,
104
+ )
105
+
106
+
107
+ def _replace_snapshot(duty_root: Path, source: Path, common_source: Path) -> str:
108
+ """계약 폴더를 통째로 새로 쓴다. 옛 형식 파일은 남기지 않는다.
109
+
110
+ 옛 파일을 남기면 두 형식이 한 폴더에 섞이고, 다이제스트가 세는 대상과 로더가
111
+ 읽는 대상이 다시 갈린다 — 이 결함이 처음 생긴 자리가 그 갈림이다.
112
+ """
113
+ duty_root.parent.mkdir(parents=True, exist_ok=True)
114
+ staged = Path(tempfile.mkdtemp(
115
+ dir=duty_root.parent, prefix=f".{duty_root.name}.", suffix=".refreeze",
116
+ ))
117
+ retired = duty_root.with_name(f".{duty_root.name}.retired")
118
+ try:
119
+ shutil.copytree(source, staged, dirs_exist_ok=True)
120
+ shutil.copyfile(common_source, staged / "common.json")
121
+ shutil.copytree(
122
+ common_source.parent / "roles", staged / "roles", dirs_exist_ok=True
123
+ )
124
+ digest = digest_duty_catalog(staged)
125
+ if duty_root.exists():
126
+ shutil.rmtree(retired, ignore_errors=True)
127
+ os.rename(duty_root, retired)
128
+ os.rename(staged, duty_root)
129
+ except OSError as exc:
130
+ raise ContractRefreezeError(f"cannot re-freeze duty contracts: {exc}") from exc
131
+ finally:
132
+ shutil.rmtree(staged, ignore_errors=True)
133
+ shutil.rmtree(retired, ignore_errors=True)
134
+ return digest
135
+
136
+
137
+ def _resolve(project_root: Path, value: Path) -> Path:
138
+ path = Path(value).expanduser()
139
+ return path if path.is_absolute() else project_root / path
140
+
141
+
142
+ def _load(path: Path, label: str) -> dict[str, Any]:
143
+ try:
144
+ payload = load_owned_object(path, artifact=label)
145
+ except (OSError, JsonBoundaryError) as exc:
146
+ raise ContractRefreezeError(f"cannot read {label} {path}: {exc}") from exc
147
+ if not isinstance(payload, dict):
148
+ raise ContractRefreezeError(f"{label} is not an object: {path}")
149
+ return payload
150
+
151
+
152
+ def _write_manifest(path: Path, manifest: Mapping[str, Any]) -> None:
153
+ try:
154
+ write_owned_object_atomic(path, manifest, artifact="run manifest")
155
+ except JsonBoundaryError as exc:
156
+ raise ContractRefreezeError(f"cannot write run manifest {path}: {exc}") from exc
@@ -28,7 +28,7 @@ from dataclasses import dataclass
28
28
  from pathlib import Path
29
29
  from typing import Any, Mapping, Sequence
30
30
 
31
- from .convergence_provenance import worker_result_suffix
31
+ from .convergence_provenance import worker_result_paths, worker_result_suffix
32
32
  from .fixed_text import line
33
33
  from .json_boundary import JsonBoundaryError, load_owned_object
34
34
  from .paths import RunRef
@@ -132,9 +132,7 @@ def analyser_results(
132
132
  ) -> list[AnalyserResult]:
133
133
  """분석 audience 워커마다 결과 파일 경로와 finding id 를 짝지어 돌려준다.
134
134
 
135
- 결과 파일명은 `convergence_provenance` 가 이미 소유한 규칙을 그대로 쓴다 —
136
- `<worker>-worker-<task-type>-<workerResults seq>.md`. state 시퀀스와 다른
137
- 카운터라 그룹 파일명에서 접미사를 빌려 오면 빗나간다.
135
+ 출처 검사와 같은 실행 원장을 사용해야 교정 전 결과를 재검증하지 않는다.
138
136
  """
139
137
  suffix = worker_result_suffix(Path(run_dir), groups)
140
138
  if suffix is None:
@@ -142,13 +140,13 @@ def analyser_results(
142
140
  "cannot resolve the worker-result suffix from the grouping's "
143
141
  "runManifestPath"
144
142
  )
145
- results_dir = Path(run_dir) / "worker-results"
143
+ paths = worker_result_paths(Path(run_dir), groups, suffix)
146
144
  by_worker = _findings_by_worker(groups)
147
145
  return [
148
146
  AnalyserResult(
149
147
  worker_id=worker,
150
148
  result_path=_project_relative(
151
- project_root, results_dir / f"{worker}-worker-{suffix}.md"
149
+ project_root, paths[worker]
152
150
  ),
153
151
  finding_ids=tuple(by_worker.get(worker, ())),
154
152
  )
@@ -21,6 +21,8 @@ from pathlib import Path
21
21
  from typing import Mapping
22
22
 
23
23
  from okstra_ctl.convergence_engine import grouped_input_digest
24
+ from okstra_ctl.execution_identity import Attempt, ExecutionManifest
25
+ from okstra_ctl.execution_manifest import read_execution_manifest_view
24
26
  from okstra_ctl.json_boundary import (
25
27
  load_owned_object,
26
28
  )
@@ -97,6 +99,72 @@ def groups_digest_ok(state_dir: Path, suffix: str, document: dict) -> bool:
97
99
  return grouped_input_digest(document) == recorded
98
100
 
99
101
 
102
+ def discovery_attempts(manifest: ExecutionManifest) -> dict[str, list[Attempt]]:
103
+ """교정 실행은 발견 결과를 대체하지만 재검증 투표는 발견 출처가 아니다."""
104
+ latest: dict[str, Attempt] = {}
105
+ for attempt in manifest.attempts:
106
+ current = latest.get(attempt.invocation_ref)
107
+ if current is None or attempt.attempt > current.attempt:
108
+ latest[attempt.invocation_ref] = attempt
109
+ by_role: dict[str, list[Attempt]] = {}
110
+ for invocation in manifest.invocations:
111
+ if invocation.dispatch_kind not in {"initial", "correction"}:
112
+ continue
113
+ attempt = latest.get(invocation.invocation_ref)
114
+ if attempt is not None:
115
+ by_role.setdefault(invocation.role_execution_ref, []).append(attempt)
116
+ return by_role
117
+
118
+
119
+ def worker_result_paths(
120
+ run_dir: Path, document: Mapping, suffix: str
121
+ ) -> dict[str, Path]:
122
+ """실행 원장의 채택된 발견 결과를 고른다. 구형 원장은 파일명 규칙을 쓴다."""
123
+ results_dir = run_dir / "worker-results"
124
+ workers = document.get("workers") or []
125
+ paths = {
126
+ row["workerId"]: results_dir / f"{row['workerId']}-worker-{suffix}.md"
127
+ for row in workers if isinstance(row, Mapping) and row.get("workerId")
128
+ }
129
+ if document.get("schemaVersion") != "2.0":
130
+ return paths
131
+ recorded = document.get("runManifestPath")
132
+ if not _nonempty_string(recorded):
133
+ return paths
134
+ manifest_path = run_dir / "manifests" / Path(recorded).name
135
+ manifest, authority = read_execution_manifest_view(manifest_path)
136
+ if manifest.legacy:
137
+ return paths
138
+ attempts = discovery_attempts(manifest)
139
+ for row in workers:
140
+ if not isinstance(row, Mapping) or not row.get("workerId"):
141
+ continue
142
+ accepted = [
143
+ attempt for attempt in attempts.get(row.get("sourceRoleExecutionRef"), [])
144
+ if attempt.status == "ok" and attempt.result_path
145
+ ]
146
+ if not accepted:
147
+ continue
148
+ candidate = Path(accepted[-1].result_path)
149
+ if not candidate.is_absolute():
150
+ project_root = authority.get("projectRoot")
151
+ if not _nonempty_string(project_root):
152
+ raise ValueError("relative accepted resultPath requires projectRoot")
153
+ candidate = Path(project_root) / candidate
154
+ if candidate.resolve().parent != results_dir.resolve():
155
+ raise ValueError(f"accepted resultPath is outside worker-results: {candidate}")
156
+ if not candidate.is_file():
157
+ # 이 모듈의 계약은 "없는 워커 결과는 조용히 건너뛴다" 이고, 그 판정은
158
+ # 아래 `read_canonical_worker_result` 가 None 을 돌려주는 것으로
159
+ # 이루어진다. 여기서 던지면 판정이 아니라 run 이 죽는다 — 실측
160
+ # 2026-09-23(jobs dev-10860 stage 1): 옛 run 의 원장이 ok 로 적은
161
+ # 결과 파일이 디스크에 없어 `report-finalize --only validate-run` 이
162
+ # 예외로 멈췄다. 파일명 규칙 경로를 그대로 두고 넘어간다.
163
+ continue
164
+ paths[row["workerId"]] = candidate
165
+ return paths
166
+
167
+
100
168
  def read_canonical_worker_result(
101
169
  worker_results_dir: Path, worker: str, suffix: str
102
170
  ) -> str | None:
@@ -140,20 +208,25 @@ def provenance_errors(
140
208
  claims = group_claims(document)
141
209
  if claims is None:
142
210
  return []
211
+ paths = worker_result_paths(worker_results_dir.parent, document, suffix)
143
212
  contents: dict[str, str | None] = {}
144
213
  errors: list[str] = []
145
214
  for finding_id, worker, item_id in claims:
146
215
  if worker not in contents:
147
- contents[worker] = read_canonical_worker_result(
148
- worker_results_dir, worker, suffix
149
- )
216
+ candidate = paths.get(worker)
217
+ if candidate is not None and candidate.name != f"{worker}-worker-{suffix}.md":
218
+ contents[worker] = candidate.read_text(encoding="utf-8")
219
+ else:
220
+ contents[worker] = read_canonical_worker_result(
221
+ worker_results_dir, worker, suffix
222
+ )
150
223
  text = contents[worker]
151
224
  if text is None or id_occurs_wordbounded(text, item_id):
152
225
  continue
153
226
  errors.append(
154
227
  f"convergence groups `{groups_label}` group {finding_id} "
155
228
  f"cites source item `{worker}:{item_id}`, but that ID does not "
156
- f"occur in the worker's result file `{worker}-worker-{suffix}.md` — a "
229
+ f"occur in the worker's result file `{paths.get(worker, Path(f'{worker}-worker-{suffix}.md')).name}` — a "
157
230
  "grouping may not invent a provenance link to an item the "
158
231
  "worker never reported."
159
232
  )
@@ -243,10 +316,10 @@ DISCARDED_ATTEMPT_STATUSES = frozenset(
243
316
 
244
317
 
245
318
  def discarded_workers(document: object, execution_manifest: object) -> dict[str, str]:
246
- """``{workerId: "<invocationRef>#<attempt> <status>"}`` — 초기 attempt 가 전부
319
+ """``{workerId: "<invocationRef>#<attempt> <status>"}`` — 발견 attempt 가 전부
247
320
  폐기돼 채택할 결과가 없는 분석 워커.
248
321
 
249
- 한 워커의 초기 invocation 이 둘 이상이면(교정 디스패치는 새 invocation 이다)
322
+ 한 워커의 발견 invocation 이 둘 이상이면(교정 디스패치는 새 invocation 이다)
250
323
  그중 하나라도 마지막 attempt 가 ``ok`` 면 채택 가능한 결과가 있는 것이므로
251
324
  폐기로 보지 않는다. legacy 매니페스트나 v1 roster 는 판정하지 않는다.
252
325
  """
@@ -254,17 +327,7 @@ def discarded_workers(document: object, execution_manifest: object) -> dict[str,
254
327
  return {}
255
328
  if execution_manifest is None or getattr(execution_manifest, "legacy", False):
256
329
  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
330
+ attempts = discovery_attempts(execution_manifest)
268
331
  result: dict[str, str] = {}
269
332
  for row in document["workers"]:
270
333
  if not isinstance(row, dict):
@@ -273,7 +336,7 @@ def discarded_workers(document: object, execution_manifest: object) -> dict[str,
273
336
  role_ref = row.get("sourceRoleExecutionRef")
274
337
  if not _nonempty_string(worker) or not _nonempty_string(role_ref):
275
338
  continue
276
- rows = [latest[ref] for ref in sorted(initial_refs.get(role_ref, ())) if ref in latest]
339
+ rows = attempts.get(role_ref, [])
277
340
  if not rows or any(item.status == "ok" for item in rows):
278
341
  continue
279
342
  discarded = [item for item in rows if item.status in DISCARDED_ATTEMPT_STATUSES]
@@ -20,6 +20,7 @@ from fnmatch import fnmatchcase
20
20
  from pathlib import Path
21
21
  from typing import Any, Iterator
22
22
 
23
+ from .consumers import read_stage_consumer_state
23
24
  from .final_report_schema import load_schema, validate as validate_schema
24
25
  from .json_boundary import JsonBoundaryError, load_owned_object
25
26
  from okstra_project import ResolverError, resolve_project_root
@@ -466,6 +467,7 @@ def materialize_design_prep_requests(plan_data_path: Path) -> list[Path]:
466
467
  # 충돌은 한 항목씩 알리지 않는다. 요청 12개가 모두 옛 리포트의 파일과 부딪히면
467
468
  # 첫 건에서 멈추는 구현은 사용자에게 12회 왕복을 시키고, 그때마다 이미 쓴
468
469
  # 앞쪽 파일이 남아 상태가 절반만 진행된다. 전부 렌더해 전부 검사한 뒤 쓴다.
470
+ done = _done_stages(data_path)
469
471
  rendered = [
470
472
  _render_request(
471
473
  data_path=data_path,
@@ -474,7 +476,7 @@ def materialize_design_prep_requests(plan_data_path: Path) -> list[Path]:
474
476
  item=item,
475
477
  )
476
478
  for item in items
477
- if item.get("status") in _REQUEST_STATUSES
479
+ if item.get("status") in _REQUEST_STATUSES and not _is_implemented(item, done)
478
480
  ]
479
481
  conflicts = [
480
482
  reason
@@ -494,6 +496,37 @@ def materialize_design_prep_requests(plan_data_path: Path) -> list[Path]:
494
496
  return written
495
497
 
496
498
 
499
+ def _done_stages(data_path: Path) -> frozenset[int]:
500
+ """이 태스크에서 이미 구현이 끝난 stage.
501
+
502
+ 원장을 못 읽으면 빈 집합이다 — 그때의 동작은 이 판정이 없던 때와 같고,
503
+ "모르니까 건너뛴다" 는 반대 방향의 실패보다 안전하다.
504
+ """
505
+ try:
506
+ state = read_stage_consumer_state(data_path.parent.parent)
507
+ except (OSError, UnicodeError, ValueError):
508
+ return frozenset()
509
+ return frozenset(state.done_stages)
510
+
511
+
512
+ def _is_implemented(item: Mapping[str, Any], done: frozenset[int]) -> bool:
513
+ """이 항목이 가리키는 stage 가 전부 구현 완료인가.
514
+
515
+ 구현이 끝난 stage 의 계획은 역사이지 계획이 아니다. 그런데 탐지기는 매
516
+ 계획 run 마다 이월된 stage 본문을 다시 훑고, 그 사이 선택 방향의
517
+ `fileStructure` 가 바뀌면 같은 PREP 항목이 다른 증거로 렌더된다. 요청
518
+ 파일은 태스크 단위 이름이라 불변 파일과 충돌하고, report-finalize 는
519
+ 빠져나갈 길 없이 멈춘다(2026-09-24, dev-10860: 9건). 이미 지어진 것에
520
+ 설계 준비를 다시 요구하는 것이므로, 답도 쓸모가 없다.
521
+ """
522
+ stages = [
523
+ stage
524
+ for stage in item.get("stageRefs") or ()
525
+ if isinstance(stage, int) and not isinstance(stage, bool)
526
+ ]
527
+ return bool(stages) and all(stage in done for stage in stages)
528
+
529
+
497
530
  def load_design_prep_items(report_path: Path) -> tuple[dict[str, Any], ...]:
498
531
  """Return validated snapshot items without exposing mutable report data."""
499
532
  data_path = _canonical_data_path(report_path)