okstra 0.202.0 → 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 (258) 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/memory/memory.mjs +41 -8
  7. package/dist/commands/memory/memory.mjs.map +1 -1
  8. package/dist/lib/install-assets.mjs +3 -0
  9. package/dist/lib/install-assets.mjs.map +1 -1
  10. package/dist/lib/runtime-manifest.mjs +2 -1
  11. package/dist/lib/runtime-manifest.mjs.map +1 -1
  12. package/dist/lib/types.d.mts +2 -1
  13. package/docs/architecture/storage-model.md +14 -11
  14. package/docs/architecture.md +25 -19
  15. package/docs/cli.md +15 -12
  16. package/docs/contributor-change-matrix.md +3 -2
  17. package/docs/performance-improvement-plan-v2.md +2 -3
  18. package/docs/project-structure-overview.md +35 -9
  19. package/docs/task-process/README.md +1 -1
  20. package/docs/task-process/common-flow.md +1 -1
  21. package/docs/task-process/final-verification.md +3 -1
  22. package/docs/task-process/implementation.md +1 -1
  23. package/docs/task-process/release-handoff.md +36 -39
  24. package/package.json +1 -2
  25. package/runtime/BUILD.json +2 -2
  26. package/runtime/agents/common.json +28 -0
  27. package/runtime/agents/operations/code-review.json +6 -0
  28. package/runtime/agents/operations/report-translation.json +6 -0
  29. package/runtime/agents/operations/schedule-verification.json +6 -0
  30. package/runtime/agents/roles/analyser.json +18 -0
  31. package/runtime/agents/roles/critic.json +18 -0
  32. package/runtime/agents/roles/designer.json +18 -0
  33. package/runtime/agents/roles/implementer.json +20 -0
  34. package/runtime/agents/roles/leader.json +20 -0
  35. package/runtime/agents/roles/planner.json +18 -0
  36. package/runtime/agents/roles/report-writer.json +19 -0
  37. package/runtime/agents/roles/translator.json +19 -0
  38. package/runtime/agents/roles/verifier.json +18 -0
  39. package/runtime/bin/lib/okstra/usage.sh +5 -5
  40. package/runtime/prompts/duties/acceptance-critic.json +32 -0
  41. package/runtime/prompts/duties/acceptance-verifier.json +32 -0
  42. package/runtime/prompts/duties/analysis-worker.json +32 -0
  43. package/runtime/prompts/duties/code-reviewer.json +32 -0
  44. package/runtime/prompts/duties/diagnosis-worker.json +32 -0
  45. package/runtime/prompts/duties/direction-selection-worker.json +32 -0
  46. package/runtime/prompts/duties/discovery-worker.json +32 -0
  47. package/runtime/prompts/duties/implementation-executor.json +32 -0
  48. package/runtime/prompts/duties/implementation-verifier.json +32 -0
  49. package/runtime/prompts/duties/lead.json +32 -0
  50. package/runtime/prompts/duties/planning-worker.json +36 -0
  51. package/runtime/prompts/duties/report-writer.json +32 -0
  52. package/runtime/prompts/duties/reverification-worker.json +32 -0
  53. package/runtime/prompts/duties/schedule-verifier.json +32 -0
  54. package/runtime/prompts/duties/scope-critic.json +32 -0
  55. package/runtime/prompts/duties/technical-verification-worker.json +32 -0
  56. package/runtime/prompts/duties/translator.json +32 -0
  57. package/runtime/prompts/launch.template.md +2 -1
  58. package/runtime/prompts/lead/adapters/cmux.md +1 -1
  59. package/runtime/prompts/lead/convergence.md +4 -4
  60. package/runtime/prompts/lead/okstra-lead-contract.md +113 -4
  61. package/runtime/prompts/lead/plan-body-verification.md +6 -6
  62. package/runtime/prompts/lead/report-writer.md +3 -3
  63. package/runtime/prompts/profiles/_common-contract.md +2 -2
  64. package/runtime/prompts/profiles/_implementation-executor.md +4 -1
  65. package/runtime/prompts/profiles/_implementation-verifier.md +2 -2
  66. package/runtime/prompts/profiles/change-impact-analysis.json +31 -0
  67. package/runtime/prompts/profiles/change-impact-analysis.md +0 -20
  68. package/runtime/prompts/profiles/error-analysis.json +39 -0
  69. package/runtime/prompts/profiles/error-analysis.md +0 -25
  70. package/runtime/prompts/profiles/feature-analysis.json +31 -0
  71. package/runtime/prompts/profiles/feature-analysis.md +0 -20
  72. package/runtime/prompts/profiles/final-verification.json +30 -0
  73. package/runtime/prompts/profiles/final-verification.md +3 -22
  74. package/runtime/prompts/profiles/forbidden-actions.json +4 -3
  75. package/runtime/prompts/profiles/implementation-option-selection.json +31 -0
  76. package/runtime/prompts/profiles/implementation-option-selection.md +0 -20
  77. package/runtime/prompts/profiles/implementation-planning.json +40 -0
  78. package/runtime/prompts/profiles/implementation-planning.md +4 -29
  79. package/runtime/prompts/profiles/implementation.json +30 -0
  80. package/runtime/prompts/profiles/implementation.md +1 -20
  81. package/runtime/prompts/profiles/improvement-discovery.json +31 -0
  82. package/runtime/prompts/profiles/improvement-discovery.md +0 -20
  83. package/runtime/prompts/profiles/project-analysis.json +31 -0
  84. package/runtime/prompts/profiles/project-analysis.md +0 -20
  85. package/runtime/prompts/profiles/release-handoff.json +5 -0
  86. package/runtime/prompts/profiles/release-handoff.md +71 -73
  87. package/runtime/prompts/profiles/requirements-discovery.json +39 -0
  88. package/runtime/prompts/profiles/requirements-discovery.md +0 -25
  89. package/runtime/prompts/profiles/technical-verification.json +39 -0
  90. package/runtime/prompts/profiles/technical-verification.md +0 -25
  91. package/runtime/prompts/wizard/prompts.ko.json +12 -17
  92. package/runtime/python/okstra_ctl/adapters/hosts/antigravity/relay.md +1 -0
  93. package/runtime/python/okstra_ctl/adapters/hosts/claude-code/adapter.py +3 -0
  94. package/runtime/python/okstra_ctl/adapters/hosts/claude-code/manifest.json +1 -1
  95. package/runtime/python/okstra_ctl/adapters/hosts/claude-code/relay.md +4 -3
  96. package/runtime/python/okstra_ctl/adapters/hosts/claude-code/worker-session.md +108 -0
  97. package/runtime/python/okstra_ctl/adapters/hosts/codex/relay.md +1 -0
  98. package/runtime/python/okstra_ctl/adapters/hosts/grok/relay.md +2 -0
  99. package/runtime/python/okstra_ctl/adapters/hosts/kimi/relay.md +2 -0
  100. package/runtime/python/okstra_ctl/adapters/providers/antigravity/adapter.py +8 -1
  101. package/runtime/python/okstra_ctl/adapters/providers/claude/adapter.py +8 -0
  102. package/runtime/python/okstra_ctl/adapters/providers/codex/adapter.py +23 -6
  103. package/runtime/python/okstra_ctl/adapters/providers/grok/adapter.py +6 -2
  104. package/runtime/python/okstra_ctl/agent/invocation.py +168 -113
  105. package/runtime/python/okstra_ctl/agent/prompt_cli/cli.py +120 -0
  106. package/runtime/python/okstra_ctl/agent/prompt_cli/materialize.py +107 -2
  107. package/runtime/python/okstra_ctl/agent/prompt_cli/run_identity.py +0 -49
  108. package/runtime/python/okstra_ctl/analysis_packet.py +4 -1
  109. package/runtime/python/okstra_ctl/application/open_worker.py +6 -1
  110. package/runtime/python/okstra_ctl/assignment_resolver.py +16 -5
  111. package/runtime/python/okstra_ctl/cmux.py +69 -20
  112. package/runtime/python/okstra_ctl/code_review_target.py +16 -8
  113. package/runtime/python/okstra_ctl/conformance.py +43 -0
  114. package/runtime/python/okstra_ctl/consumers.py +6 -3
  115. package/runtime/python/okstra_ctl/container.py +31 -8
  116. package/runtime/python/okstra_ctl/context_cost.py +11 -15
  117. package/runtime/python/okstra_ctl/contract_refreeze.py +156 -0
  118. package/runtime/python/okstra_ctl/convergence_provenance.py +7 -1
  119. package/runtime/python/okstra_ctl/design_prep.py +34 -1
  120. package/runtime/python/okstra_ctl/dispatch_core.py +53 -27
  121. package/runtime/python/okstra_ctl/domain/host.py +5 -0
  122. package/runtime/python/okstra_ctl/domain/worker_runtime.py +10 -0
  123. package/runtime/python/okstra_ctl/error_report.py +4 -3
  124. package/runtime/python/okstra_ctl/execution_manifest.py +71 -18
  125. package/runtime/python/okstra_ctl/handoff.py +167 -277
  126. package/runtime/python/okstra_ctl/implementation_stage.py +9 -0
  127. package/runtime/python/okstra_ctl/initial_prompt_materialization.py +113 -0
  128. package/runtime/python/okstra_ctl/lead_progress.py +1 -1
  129. package/runtime/python/okstra_ctl/legacy_model_selection.py +2 -2
  130. package/runtime/python/okstra_ctl/manager_cli.py +92 -4
  131. package/runtime/python/okstra_ctl/manager_launch.py +1 -1
  132. package/runtime/python/okstra_ctl/manager_paths.py +14 -3
  133. package/runtime/python/okstra_ctl/manager_store.py +210 -3
  134. package/runtime/python/okstra_ctl/manager_sync.py +4 -1
  135. package/runtime/python/okstra_ctl/manager_view.py +2 -1
  136. package/runtime/python/okstra_ctl/model_discovery.py +30 -0
  137. package/runtime/python/okstra_ctl/model_io/lines.py +14 -1
  138. package/runtime/python/okstra_ctl/model_io/renderers.py +4 -3
  139. package/runtime/python/okstra_ctl/models.py +1 -1
  140. package/runtime/python/okstra_ctl/next_phase.py +16 -6
  141. package/runtime/python/okstra_ctl/operation_invocation.py +86 -0
  142. package/runtime/python/okstra_ctl/option_comparison.py +168 -0
  143. package/runtime/python/okstra_ctl/path_hints.py +9 -0
  144. package/runtime/python/okstra_ctl/paths.py +3 -0
  145. package/runtime/python/okstra_ctl/profile_show.py +42 -1
  146. package/runtime/python/okstra_ctl/registry/host_discovery.py +20 -12
  147. package/runtime/python/okstra_ctl/registry/host_registry.py +11 -0
  148. package/runtime/python/okstra_ctl/render.py +50 -0
  149. package/runtime/python/okstra_ctl/report_contract.py +1 -1
  150. package/runtime/python/okstra_ctl/report_html/view_models/release_handoff.py +21 -3
  151. package/runtime/python/okstra_ctl/report_synthesis_packet.py +177 -17
  152. package/runtime/python/okstra_ctl/report_translation.py +2 -1
  153. package/runtime/python/okstra_ctl/report_translation_dispatch.py +69 -9
  154. package/runtime/python/okstra_ctl/role_requirements.py +142 -129
  155. package/runtime/python/okstra_ctl/rollup.py +3 -1
  156. package/runtime/python/okstra_ctl/run.py +76 -29
  157. package/runtime/python/okstra_ctl/schedule_semantics.py +17 -6
  158. package/runtime/python/okstra_ctl/stage_fix_carry.py +23 -4
  159. package/runtime/python/okstra_ctl/stage_integrate.py +178 -18
  160. package/runtime/python/okstra_ctl/stage_map.py +16 -2
  161. package/runtime/python/okstra_ctl/stage_targets.py +209 -43
  162. package/runtime/python/okstra_ctl/team.py +22 -13
  163. package/runtime/python/okstra_ctl/time_report.py +2 -1
  164. package/runtime/python/okstra_ctl/usage_report.py +3 -1
  165. package/runtime/python/okstra_ctl/wizard/confirmation.py +3 -9
  166. package/runtime/python/okstra_ctl/wizard/ids.py +1 -1
  167. package/runtime/python/okstra_ctl/wizard/registry.py +1 -1
  168. package/runtime/python/okstra_ctl/wizard/state.py +3 -5
  169. package/runtime/python/okstra_ctl/wizard/steps_plan.py +3 -23
  170. package/runtime/python/okstra_ctl/worker_prompt_contract.py +5 -1
  171. package/runtime/python/okstra_ctl/worker_prompt_headers.py +35 -7
  172. package/runtime/python/okstra_ctl/worker_prompt_policy.py +66 -48
  173. package/runtime/python/okstra_ctl/workflow.py +1 -1
  174. package/runtime/python/okstra_ctl/worktree/__init__.py +3 -1
  175. package/runtime/python/okstra_ctl/worktree/naming.py +9 -0
  176. package/runtime/python/okstra_ctl/worktree_registry.py +38 -9
  177. package/runtime/python/okstra_token_usage/pricing.py +6 -4
  178. package/runtime/schemas/agent-common-v1.schema.json +34 -0
  179. package/runtime/schemas/agent-duty-v1.schema.json +38 -0
  180. package/runtime/schemas/agent-operation-v1.schema.json +11 -0
  181. package/runtime/schemas/agent-profile-v1.schema.json +46 -0
  182. package/runtime/schemas/agent-role-v1.schema.json +29 -0
  183. package/runtime/schemas/final-report-v2.0.schema.json +118 -97
  184. package/runtime/schemas/final-report-v3.0.schema.json +118 -97
  185. package/runtime/skills/okstra-brief-gen/SKILL.md +84 -4
  186. package/runtime/skills/okstra-chat/SKILL.md +2 -2
  187. package/runtime/skills/okstra-code-review/SKILL.md +23 -9
  188. package/runtime/skills/okstra-container-build/SKILL.md +10 -10
  189. package/runtime/skills/okstra-inspect/SKILL.md +1 -1
  190. package/runtime/skills/okstra-inspect/facets/cost.md +1 -1
  191. package/runtime/skills/okstra-inspect/facets/error-zip.md +9 -9
  192. package/runtime/skills/okstra-inspect/facets/errors.md +16 -16
  193. package/runtime/skills/okstra-inspect/facets/logs.md +7 -7
  194. package/runtime/skills/okstra-inspect/facets/recap.md +2 -2
  195. package/runtime/skills/okstra-inspect/facets/report.md +1 -1
  196. package/runtime/skills/okstra-inspect/facets/status.md +4 -3
  197. package/runtime/skills/okstra-inspect/facets/time.md +11 -10
  198. package/runtime/skills/okstra-manager/SKILL.md +18 -2
  199. package/runtime/skills/okstra-pr-gen/SKILL.md +6 -5
  200. package/runtime/skills/okstra-rollup/SKILL.md +5 -5
  201. package/runtime/skills/okstra-run/SKILL.md +31 -12
  202. package/runtime/skills/okstra-schedule-gen/SKILL.md +19 -14
  203. package/runtime/skills/okstra-setup/SKILL.md +12 -10
  204. package/runtime/skills/okstra-setup/references/project-config.md +7 -6
  205. package/runtime/skills/okstra-usage/SKILL.md +1 -1
  206. package/runtime/skills/okstra-user-response/SKILL.md +1 -1
  207. package/runtime/templates/manager/view.template.html +1 -0
  208. package/runtime/templates/report-writer-prompt-preamble.md +8 -0
  209. package/runtime/templates/reports/brief.template.md +14 -4
  210. package/runtime/templates/reports/html/i18n/en.json +5 -4
  211. package/runtime/templates/reports/html/i18n/ko.json +5 -4
  212. package/runtime/templates/reports/html/tasks/release-handoff.template.html +8 -5
  213. package/runtime/templates/reports/i18n/en.json +1 -1
  214. package/runtime/templates/reports/md/tasks/release-handoff.template.md +1 -1
  215. package/runtime/templates/reports/release-handoff-input.template.md +6 -4
  216. package/runtime/templates/translator-prompt-preamble.md +36 -0
  217. package/runtime/validators/checks/validate-assets-01.py +7 -8
  218. package/runtime/validators/validate-brief.py +70 -0
  219. package/runtime/validators/validate-implementation-plan-stages.py +2 -1
  220. package/runtime/validators/validate-run.py +59 -9
  221. package/runtime/validators/validate-schedule.py +9 -0
  222. package/docs/for-ai/README.md +0 -68
  223. package/docs/for-ai/skills/okstra-brief-gen.md +0 -262
  224. package/docs/for-ai/skills/okstra-chat.md +0 -34
  225. package/docs/for-ai/skills/okstra-code-review.md +0 -57
  226. package/docs/for-ai/skills/okstra-container-build.md +0 -129
  227. package/docs/for-ai/skills/okstra-inspect.md +0 -262
  228. package/docs/for-ai/skills/okstra-manager.md +0 -86
  229. package/docs/for-ai/skills/okstra-memory.md +0 -126
  230. package/docs/for-ai/skills/okstra-pr-gen.md +0 -49
  231. package/docs/for-ai/skills/okstra-rollup.md +0 -114
  232. package/docs/for-ai/skills/okstra-run.md +0 -250
  233. package/docs/for-ai/skills/okstra-schedule-gen.md +0 -240
  234. package/docs/for-ai/skills/okstra-setup.md +0 -167
  235. package/docs/for-ai/skills/okstra-usage.md +0 -29
  236. package/docs/for-ai/skills/okstra-user-response.md +0 -72
  237. package/runtime/agents/workers/claude-worker.md +0 -128
  238. package/runtime/agents/workers/report-writer-worker.md +0 -37
  239. package/runtime/agents/workers/translator-worker.md +0 -63
  240. package/runtime/prompts/duties/acceptance-critic.md +0 -44
  241. package/runtime/prompts/duties/acceptance-verifier.md +0 -44
  242. package/runtime/prompts/duties/analysis-worker.md +0 -44
  243. package/runtime/prompts/duties/code-reviewer.md +0 -44
  244. package/runtime/prompts/duties/common.md +0 -39
  245. package/runtime/prompts/duties/diagnosis-worker.md +0 -44
  246. package/runtime/prompts/duties/direction-selection-worker.md +0 -44
  247. package/runtime/prompts/duties/discovery-worker.md +0 -44
  248. package/runtime/prompts/duties/implementation-executor.md +0 -44
  249. package/runtime/prompts/duties/implementation-verifier.md +0 -44
  250. package/runtime/prompts/duties/lead.md +0 -44
  251. package/runtime/prompts/duties/planning-worker.md +0 -52
  252. package/runtime/prompts/duties/report-writer.md +0 -44
  253. package/runtime/prompts/duties/reverification-worker.md +0 -44
  254. package/runtime/prompts/duties/schedule-verifier.md +0 -44
  255. package/runtime/prompts/duties/scope-critic.md +0 -44
  256. package/runtime/prompts/duties/technical-verification-worker.md +0 -44
  257. package/runtime/prompts/duties/translator.md +0 -44
  258. package/runtime/python/okstra_ctl/pane_title.py +0 -154
@@ -12,7 +12,7 @@ from pathlib import Path
12
12
  from pathlib import PurePosixPath
13
13
  import re
14
14
  import tempfile
15
- from typing import Iterator, Literal, Mapping, get_args
15
+ from typing import Any, Iterator, Literal, Mapping, get_args
16
16
 
17
17
  from ..domain.role import RoleCatalogError, role_for_duty
18
18
  from ..json_boundary import (
@@ -44,34 +44,6 @@ AgentAudience = Literal[
44
44
  ]
45
45
 
46
46
  _SUPPORTED_AUDIENCES = frozenset(get_args(AgentAudience))
47
- # The section set IS the duty contract's shape: a duty author adding a role file
48
- # reads these names, and `_validate_duty_sections` refuses a file that misses one.
49
- # The check is structural — it proves every required section exists and carries
50
- # text, NOT that the text is a real contract. A one-line placeholder passes it;
51
- # what keeps a section substantive is review, and the rule that earns a section a
52
- # place here at all: a sentence that reads the same in another duty file belongs
53
- # in `common.md` or nowhere.
54
- COMMON_DUTY_SECTIONS = (
55
- "Assignment fidelity",
56
- "Required inputs",
57
- "Evidence first",
58
- "Authority and scope",
59
- "Collaboration and independence",
60
- "Instruction precedence",
61
- "Conflict handling",
62
- "Completion honesty",
63
- )
64
- ROLE_DUTY_SECTIONS = (
65
- "Responsibility",
66
- "Required conduct",
67
- "Decision principles",
68
- "Authority and boundaries",
69
- "Evidence standard",
70
- "Collaboration contract",
71
- "Completion criteria",
72
- "Forbidden conduct",
73
- "Blocked-state reporting",
74
- )
75
47
  _SLUG_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
76
48
  _DUTY_SECTION_RE = re.compile(r"(?m)^## ([^\n]+)\s*$")
77
49
  _TOP_LEVEL_KEYS = {
@@ -144,6 +116,8 @@ class DutyContract:
144
116
  applies_to: AgentAudience | None
145
117
  body: str
146
118
  source_path: Path
119
+ # 직무가 선언한 정본 역할. 공통 계약과 역할 계약 자신은 갖지 않는다.
120
+ role_id: str = ""
147
121
 
148
122
 
149
123
  @dataclass(frozen=True)
@@ -495,21 +469,102 @@ class _MaterializedInvocation:
495
469
  reservation: dict[str, object] | None
496
470
 
497
471
 
472
+
473
+ # 계약 JSON 을 프롬프트에 실을 Markdown 으로 바꾸는 고정 렌더러. 제목과 순서는
474
+ # 스키마 필드 순서에 고정되어 있고, 빈 배열도 섹션을 생략하지 않는다 — 생략하면
475
+ # 같은 계약이 호출마다 다른 다이제스트를 갖는다.
476
+ _COMMON_DUTY_SECTIONS_JSON = (
477
+ ("assignmentFidelity", "Assignment fidelity"),
478
+ ("requiredInputs", "Required inputs"),
479
+ ("evidenceFirst", "Evidence first"),
480
+ ("authorityAndScope", "Authority and scope"),
481
+ ("collaborationAndIndependence", "Collaboration and independence"),
482
+ ("instructionPrecedence", "Instruction precedence"),
483
+ ("conflictHandling", "Conflict handling"),
484
+ ("completionHonesty", "Completion honesty"),
485
+ )
486
+ _ROLE_CONTRACT_SECTIONS_JSON = (
487
+ ("identity", "Identity"),
488
+ ("responsibilities", "Responsibility"),
489
+ ("requiredCapabilities", "Required capabilities"),
490
+ ("prohibitions", "Prohibitions"),
491
+ )
492
+ _ROLE_DUTY_SECTIONS_JSON = (
493
+ ("responsibilities", "Responsibility"),
494
+ ("requiredConduct", "Required conduct"),
495
+ ("decisionPrinciples", "Decision principles"),
496
+ ("authorityAndBoundaries", "Authority and boundaries"),
497
+ ("evidenceStandards", "Evidence standard"),
498
+ ("collaborationContract", "Collaboration contract"),
499
+ ("completionCriteria", "Completion criteria"),
500
+ ("prohibitions", "Forbidden conduct"),
501
+ ("blockedStateReporting", "Blocked-state reporting"),
502
+ )
503
+
504
+
505
+ def _contract_title(contract_id: str) -> str:
506
+ return " ".join(part.capitalize() for part in contract_id.split("-"))
507
+
508
+
509
+ def _render_contract_body(
510
+ payload: Mapping[str, Any],
511
+ sections: tuple[tuple[str, str], ...],
512
+ heading: str,
513
+ path: Path,
514
+ ) -> str:
515
+ lines = [f"# {heading}", ""]
516
+ for key, title in sections:
517
+ values = payload.get(key)
518
+ if not isinstance(values, list) or not all(
519
+ isinstance(item, str) and item.strip() for item in values
520
+ ):
521
+ raise AgentInvocationError(f"invalid contract field {key!r}: {path}")
522
+ lines.append(f"## {title}")
523
+ lines.append("")
524
+ if values:
525
+ lines.extend(f"- {item}" for item in values)
526
+ else:
527
+ lines.append("- (none)")
528
+ lines.append("")
529
+ return "\n".join(lines).rstrip("\n") + "\n"
530
+
531
+
532
+ def _load_contract_payload(path: Path) -> Mapping[str, Any]:
533
+ if not path.is_file() and path.with_suffix(".md").is_file():
534
+ # 이 run 은 Markdown 직무를 동결했다(contractFormatVersion 1). 그 형식의
535
+ # 파서는 제거됐으므로 여기서 멈추고 원인을 이름한다 — 없는 파일 이름만
536
+ # 알리면 설치가 깨진 것처럼 읽힌다.
537
+ raise AgentInvocationError(
538
+ f"run froze markdown duty contracts and cannot be resumed: {path.parent}"
539
+ )
540
+ try:
541
+ payload = load_owned_object(path, artifact="agent contract")
542
+ except JsonBoundaryError as exc:
543
+ raise AgentInvocationError(f"invalid agent contract: {path}: {exc}") from exc
544
+ except OSError as exc:
545
+ raise AgentInvocationError(f"cannot read agent contract: {path}") from exc
546
+ if payload.get("schemaVersion") != "1.0":
547
+ raise AgentInvocationError(f"unsupported contract schemaVersion: {path}")
548
+ return payload
549
+
550
+
498
551
  def load_common_duty_contract(duty_root: Path) -> DutyContract:
499
552
  """Load the common fragment, which is deliberately outside the role catalog."""
500
- path = duty_root / "common.md"
501
- fields, body = _parse_duty_file(path)
502
- if set(fields) != {"id", "version", "kind"}:
503
- raise AgentInvocationError(f"invalid common duty frontmatter: {path}")
504
- if fields["id"] != "common" or fields["kind"] != "common":
505
- raise AgentInvocationError(f"invalid common duty frontmatter: {path}")
506
- _validate_duty_sections(body, COMMON_DUTY_SECTIONS, "common", path)
553
+ path = duty_root / "common.json"
554
+ payload = _load_contract_payload(path)
555
+ if payload.get("id") != "common":
556
+ raise AgentInvocationError(f"invalid common contract id: {path}")
507
557
  return DutyContract(
508
558
  id="common",
509
- version=_parse_version(fields["version"], path),
559
+ version=1,
510
560
  kind="common",
511
561
  applies_to=None,
512
- body=body,
562
+ body=_render_contract_body(
563
+ payload,
564
+ _COMMON_DUTY_SECTIONS_JSON,
565
+ "Common Agent Duty Contract",
566
+ path,
567
+ ),
513
568
  source_path=path,
514
569
  )
515
570
 
@@ -518,8 +573,8 @@ def load_duty_catalog(duty_root: Path) -> dict[AgentAudience, DutyContract]:
518
573
  """Load one role contract for every supported invocation audience."""
519
574
  catalog: dict[AgentAudience, DutyContract] = {}
520
575
  seen_ids: set[str] = set()
521
- for path in sorted(duty_root.glob("*.md")):
522
- if path.name == "common.md":
576
+ for path in sorted(duty_root.glob("*.json")):
577
+ if path.name == "common.json":
523
578
  continue
524
579
  duty = _load_role_duty(path)
525
580
  if duty.id in seen_ids:
@@ -538,18 +593,49 @@ def load_role_duty_contract(duty_root: Path, audience: AgentAudience) -> DutyCon
538
593
  """현재 호출의 지침만 읽어 이후 추가된 역할을 기존 실행에 요구하지 않는다."""
539
594
  if audience not in _SUPPORTED_AUDIENCES:
540
595
  raise AgentInvocationError(f"unknown duty audience: {audience}")
541
- duty = _load_role_duty(duty_root / f"{audience}.md")
596
+ duty = _load_role_duty(duty_root / f"{audience}.json")
542
597
  if duty.applies_to != audience:
543
598
  raise AgentInvocationError(f"duty audience does not match requested audience: {audience}")
544
599
  return duty
545
600
 
546
601
 
547
602
  def digest_duty_catalog(duty_root: Path) -> str:
548
- """Return the canonical digest of every duty file in a snapshot."""
549
- names = [path.relative_to(duty_root).as_posix() for path in duty_root.glob("*.md")]
603
+ """Return the canonical digest of every contract file in a snapshot.
604
+
605
+ 역할 계약은 하위 `roles/` 에 둔다 — `report-writer` 와 `translator` 는 직무
606
+ id 이면서 역할 id 라, 한 폴더에 두면 두 계약이 같은 파일 이름을 두고 겹친다.
607
+ """
608
+ names = [
609
+ path.relative_to(duty_root).as_posix()
610
+ for path in (*duty_root.glob("*.json"), *(duty_root / "roles").glob("*.json"))
611
+ ]
550
612
  return _digest_framed_files(duty_root, names)
551
613
 
552
614
 
615
+ def load_role_contract(duty_root: Path, role_id: str) -> DutyContract:
616
+ """이 호출의 역할 계약. 직무가 `roleId` 로 이름한 역할 하나만 읽는다."""
617
+ path = duty_root / "roles" / f"{role_id}.json"
618
+ payload = _load_contract_payload(path)
619
+ if payload.get("id") != role_id:
620
+ raise AgentInvocationError(f"role contract id does not match its file: {path}")
621
+ identity = payload.get("identity")
622
+ if not isinstance(identity, str) or not identity.strip():
623
+ raise AgentInvocationError(f"role contract has no identity: {path}")
624
+ return DutyContract(
625
+ id=role_id,
626
+ version=1,
627
+ kind="role",
628
+ applies_to=None,
629
+ body=_render_contract_body(
630
+ {**payload, "identity": [identity]},
631
+ _ROLE_CONTRACT_SECTIONS_JSON,
632
+ f"{_contract_title(role_id)} Role Contract",
633
+ path,
634
+ ),
635
+ source_path=path,
636
+ )
637
+
638
+
553
639
  def prepare_agent_invocation(
554
640
  request: AgentInvocationRequest,
555
641
  ) -> PreparedAgentInvocation:
@@ -1314,7 +1400,10 @@ def _render_prompt(
1314
1400
  if assignment.host_model_value is not None:
1315
1401
  header.append(f"**Host model value:** {assignment.host_model_value}")
1316
1402
  header.extend(delivery)
1317
- duty_body = f"{common.body.rstrip()}\n\n{duty.body.rstrip()}"
1403
+ role_contract = load_role_contract(request.duty_root, duty.role_id)
1404
+ duty_body = "\n\n".join(
1405
+ part.rstrip() for part in (common.body, role_contract.body, duty.body)
1406
+ )
1318
1407
  return (
1319
1408
  "\n".join(header)
1320
1409
  + "\n\n## Duty Contract\n\n"
@@ -1344,7 +1433,11 @@ def _invocation_digests(
1344
1433
  _source_payload(source) for source in request.instruction.source_paths
1345
1434
  ],
1346
1435
  }
1347
- selected_names = ["common.md", duty.source_path.name]
1436
+ selected_names = [
1437
+ "common.json",
1438
+ f"roles/{duty.role_id}.json",
1439
+ duty.source_path.name,
1440
+ ]
1348
1441
  return {
1349
1442
  "catalogDigest": digest_duty_catalog(request.duty_root),
1350
1443
  "assignmentDigest": _sha256(_canonical_json(assignment)),
@@ -2131,7 +2224,9 @@ def _verify_duty_snapshot(
2131
2224
  if actual_catalog != expected_catalog or digests["catalogDigest"] != actual_catalog:
2132
2225
  errors.append("catalog digest does not match run duty snapshot")
2133
2226
  return errors
2134
- actual_duty = _digest_framed_files(root, ["common.md", duty.source_path.name])
2227
+ actual_duty = _digest_framed_files(
2228
+ root, ["common.json", f"roles/{duty.role_id}.json", duty.source_path.name]
2229
+ )
2135
2230
  if digests["dutyDigest"] != actual_duty:
2136
2231
  errors.append("duty digest does not match run duty snapshot")
2137
2232
  return errors
@@ -2163,7 +2258,17 @@ def _verify_prompt_duty_body(
2163
2258
  rendered_duty, _task = remainder.split("\n\n## Task Instructions\n\n", 1)
2164
2259
  except (AgentInvocationError, OSError, UnicodeDecodeError, ValueError):
2165
2260
  return ["prompt duty contract does not match duty snapshot"]
2166
- expected = f"{common.body.rstrip()}\n\n{duty.body.rstrip()}"
2261
+ role_contract = load_role_contract(
2262
+ _project_path(
2263
+ project_root,
2264
+ str(metadata["contractSource"]["dutyRootPath"]),
2265
+ must_exist=True,
2266
+ ),
2267
+ duty.role_id,
2268
+ )
2269
+ expected = "\n\n".join(
2270
+ part.rstrip() for part in (common.body, role_contract.body, duty.body)
2271
+ )
2167
2272
  if rendered_duty != expected:
2168
2273
  return ["prompt duty contract does not match duty snapshot"]
2169
2274
  return []
@@ -2407,77 +2512,27 @@ def _deduplicate(errors: list[str]) -> list[str]:
2407
2512
 
2408
2513
 
2409
2514
  def _load_role_duty(path: Path) -> DutyContract:
2410
- fields, body = _parse_duty_file(path)
2411
- if set(fields) != {"id", "version", "kind", "appliesTo"}:
2412
- raise AgentInvocationError(f"invalid role duty frontmatter: {path}")
2413
- audience = fields["appliesTo"]
2414
- if audience not in _SUPPORTED_AUDIENCES:
2515
+ payload = _load_contract_payload(path)
2516
+ audience = payload.get("id")
2517
+ if not isinstance(audience, str) or audience not in _SUPPORTED_AUDIENCES:
2415
2518
  raise AgentInvocationError(f"unknown duty audience: {audience}")
2416
- if fields["kind"] != "role" or fields["id"] != audience:
2417
- raise AgentInvocationError(f"invalid role duty frontmatter: {path}")
2418
- _validate_duty_sections(body, ROLE_DUTY_SECTIONS, "role", path)
2519
+ if audience != path.stem:
2520
+ raise AgentInvocationError(f"duty id does not match its file name: {path}")
2521
+ if not isinstance(payload.get("roleId"), str) or not payload["roleId"]:
2522
+ raise AgentInvocationError(f"duty contract has no roleId: {path}")
2419
2523
  return DutyContract(
2420
- id=fields["id"],
2421
- version=_parse_version(fields["version"], path),
2524
+ role_id=payload["roleId"],
2525
+ id=audience,
2526
+ version=1,
2422
2527
  kind="role",
2423
2528
  applies_to=audience,
2424
- body=body,
2529
+ body=_render_contract_body(
2530
+ payload,
2531
+ _ROLE_DUTY_SECTIONS_JSON,
2532
+ f"{_contract_title(audience)} Duty Contract",
2533
+ path,
2534
+ ),
2425
2535
  source_path=path,
2426
2536
  )
2427
2537
 
2428
2538
 
2429
- def _parse_duty_file(path: Path) -> tuple[dict[str, str], str]:
2430
- try:
2431
- text = path.read_text(encoding="utf-8")
2432
- except OSError as exc:
2433
- raise AgentInvocationError(f"cannot read duty contract: {path}") from exc
2434
- lines = text.splitlines(keepends=True)
2435
- if not lines or lines[0].strip() != "---":
2436
- raise AgentInvocationError(f"missing duty frontmatter: {path}")
2437
- try:
2438
- end = next(index for index, line in enumerate(lines[1:], 1) if line.strip() == "---")
2439
- except StopIteration as exc:
2440
- raise AgentInvocationError(f"unterminated duty frontmatter: {path}") from exc
2441
- fields: dict[str, str] = {}
2442
- for line in lines[1:end]:
2443
- key, separator, value = line.partition(":")
2444
- if not separator or not key.strip() or key.strip() in fields:
2445
- raise AgentInvocationError(f"invalid duty frontmatter: {path}")
2446
- fields[key.strip()] = value.strip()
2447
- return fields, "".join(lines[end + 1 :]).lstrip("\n")
2448
-
2449
-
2450
- def _validate_duty_sections(
2451
- body: str,
2452
- required: tuple[str, ...],
2453
- kind: str,
2454
- path: Path,
2455
- ) -> None:
2456
- matches = list(_DUTY_SECTION_RE.finditer(body))
2457
- sections: dict[str, str] = {}
2458
- for index, match in enumerate(matches):
2459
- name = match.group(1).strip()
2460
- if name in sections:
2461
- raise AgentInvocationError(f"duplicate {kind} duty section {name}: {path}")
2462
- end = matches[index + 1].start() if index + 1 < len(matches) else len(body)
2463
- sections[name] = body[match.end() : end].strip()
2464
- missing = [name for name in required if name not in sections]
2465
- if missing:
2466
- raise AgentInvocationError(
2467
- f"missing {kind} duty sections: {', '.join(missing)}: {path}"
2468
- )
2469
- empty = [name for name in required if not sections[name]]
2470
- if empty:
2471
- raise AgentInvocationError(
2472
- f"empty {kind} duty sections: {', '.join(empty)}: {path}"
2473
- )
2474
-
2475
-
2476
- def _parse_version(value: str, path: Path) -> int:
2477
- try:
2478
- version = int(value)
2479
- except ValueError as exc:
2480
- raise AgentInvocationError(f"invalid duty version: {path}") from exc
2481
- if version < 1:
2482
- raise AgentInvocationError(f"invalid duty version: {path}")
2483
- return version
@@ -41,6 +41,7 @@ _CLI_EPILOG = r"""Usage:
41
41
  okstra agent-prompt verify-completion [options]
42
42
  okstra agent-prompt record-dispatch [options]
43
43
  okstra agent-prompt link-result [options]
44
+ okstra agent-prompt refreeze-contracts --project-root <dir> --run-manifest <path> [--json]
44
45
  """
45
46
 
46
47
 
@@ -57,6 +58,12 @@ def _parser() -> argparse.ArgumentParser:
57
58
  _add_materialize_parser(commands)
58
59
  _add_completion_parsers(commands)
59
60
  _add_dispatch_parsers(commands)
61
+ operation = commands.add_parser(
62
+ "resolve-operation",
63
+ help="resolve the role, worker count and models an operation requires",
64
+ )
65
+ operation.add_argument("--operation", required=True)
66
+ operation.add_argument("--json", action="store_true")
60
67
  jobs = commands.add_parser("jobs", help="generate a verified jobs file without dispatching")
61
68
  _common_paths(jobs)
62
69
  jobs.add_argument("--run-manifest", required=True)
@@ -210,6 +217,15 @@ def _add_dispatch_parsers(commands: argparse._SubParsersAction) -> None:
210
217
  abandon.add_argument("--reason", required=True)
211
218
  abandon.add_argument("--json", action="store_true")
212
219
 
220
+ refreeze = commands.add_parser(
221
+ "refreeze-contracts",
222
+ help="re-freeze this run's duty contracts in the installed format "
223
+ "and stamp the manifest digest",
224
+ )
225
+ _common_paths(refreeze)
226
+ refreeze.add_argument("--run-manifest", required=True)
227
+ refreeze.add_argument("--json", action="store_true")
228
+
213
229
  link_result = commands.add_parser("link-result")
214
230
  _common_paths(link_result)
215
231
  link_result.add_argument("--run-manifest", required=True)
@@ -229,9 +245,99 @@ def _standalone_completion_args(parser: argparse.ArgumentParser) -> None:
229
245
  parser.add_argument("--json", action="store_true")
230
246
 
231
247
 
248
+ def _refreeze_contracts(args: argparse.Namespace) -> dict[str, object]:
249
+ """이 run 의 계약을 설치본 형식으로 다시 동결한다. 사용자만 부른다."""
250
+ from ...contract_refreeze import (
251
+ ContractRefreezeError,
252
+ refreeze_run_contracts,
253
+ )
254
+ from ...paths import find_asset_root
255
+
256
+ workspace_root = find_asset_root(("prompts", "duties"), is_present=Path.is_dir)
257
+ if workspace_root is None:
258
+ raise AgentPromptCliError(
259
+ "agent duty catalog not found: no prompts/duties under OKSTRA_HOME "
260
+ "or this checkout"
261
+ )
262
+ try:
263
+ result = refreeze_run_contracts(
264
+ project_root=Path(args.project_root),
265
+ run_manifest_path=Path(args.run_manifest),
266
+ workspace_root=workspace_root,
267
+ )
268
+ except ContractRefreezeError as exc:
269
+ raise AgentPromptCliError(str(exc)) from exc
270
+ return {
271
+ "dutyRoot": result.duty_root,
272
+ "digest": result.digest,
273
+ "previousDigest": result.previous_digest,
274
+ "formatVersion": result.format_version,
275
+ "previousFormatVersion": result.previous_format_version,
276
+ "changed": result.changed,
277
+ }
278
+
279
+
280
+ def _resolve_operation(operation_id: str) -> dict[str, object]:
281
+ """이 작업이 열 칸과 각 칸의 모델. 기술은 작업 이름만 알면 된다."""
282
+ from ...model_pool import ModelPool
283
+ from ...operation_invocation import (
284
+ OperationPreparationError,
285
+ resolve_operation_slots,
286
+ )
287
+ from ...paths import find_asset_root
288
+ from ...registry.provider_registry import default_provider_registry
289
+
290
+ root = find_asset_root(("agents", "operations"), is_present=Path.is_dir)
291
+ if root is None:
292
+ raise AgentPromptCliError(
293
+ "operation contracts not found: no agents/operations under "
294
+ "OKSTRA_HOME or this checkout"
295
+ )
296
+ try:
297
+ slots = resolve_operation_slots(
298
+ operation_id,
299
+ contract_root=root,
300
+ pool=ModelPool.from_registry(default_provider_registry()),
301
+ )
302
+ except OperationPreparationError as exc:
303
+ raise AgentPromptCliError(str(exc)) from exc
304
+ return {
305
+ "operation": operation_id,
306
+ "dutyId": slots[0].duty_id,
307
+ "roleId": slots[0].role_id,
308
+ "count": len(slots),
309
+ "slots": [
310
+ {
311
+ "ordinal": slot.ordinal,
312
+ "provider": slot.provider_id,
313
+ "modelRef": slot.model_ref,
314
+ "model": slot.display_name,
315
+ }
316
+ for slot in slots
317
+ ],
318
+ }
319
+
320
+
232
321
  def main(argv: list[str] | None = None) -> int:
233
322
  try:
234
323
  args = _parser().parse_args(argv)
324
+ if args.command == "resolve-operation":
325
+ payload = _resolve_operation(args.operation)
326
+ if args.json:
327
+ _emit(payload, True)
328
+ else:
329
+ # 기본 출력은 고정 텍스트다. 기술 본문이 모델에게 okstra 소유
330
+ # JSON 을 읽으라고 지시하면 그 해석이 곧 두 번째 파서가 된다.
331
+ print(f"operation: {payload['operation']}")
332
+ print(f"duty: {payload['dutyId']}")
333
+ print(f"role: {payload['roleId']}")
334
+ print(f"workers: {payload['count']}")
335
+ for slot in payload["slots"]:
336
+ print(
337
+ f" slot {slot['ordinal']}: "
338
+ f"provider={slot['provider']} model={slot['modelRef']}"
339
+ )
340
+ return 0
235
341
  if args.command == "jobs":
236
342
  payload = generate_jobs(args)
237
343
  if args.json:
@@ -277,6 +383,20 @@ def main(argv: list[str] | None = None) -> int:
277
383
  if args.command == "verify-completion":
278
384
  _verify_completion(args)
279
385
  return 0
386
+ if args.command == "refreeze-contracts":
387
+ payload = _refreeze_contracts(args)
388
+ if args.json:
389
+ _emit(payload, True)
390
+ else:
391
+ print(f"duty root: {payload['dutyRoot']}")
392
+ print(f"catalog digest: {payload['digest']}")
393
+ print(
394
+ "contract format: "
395
+ f"{payload['previousFormatVersion']} -> {payload['formatVersion']}"
396
+ if payload["changed"]
397
+ else "contract format: already current"
398
+ )
399
+ return 0
280
400
  if args.command == "record-dispatch":
281
401
  _record_dispatch(args)
282
402
  return 0
@@ -41,6 +41,16 @@ from ...worker_prompt_policy import (
41
41
  is_verification_dispatch_kind,
42
42
  )
43
43
  from ...paths import okstra_home
44
+ from ...contract_refreeze import (
45
+ CONTRACT_FORMAT_VERSION,
46
+ frozen_format_version,
47
+ refreeze_message,
48
+ )
49
+ from ...initial_prompt_materialization import (
50
+ InitialPromptMaterializationError,
51
+ implementation_anchor_lines,
52
+ required_resource_block,
53
+ )
44
54
  from ...final_report_paths import final_report_data_path
45
55
  from ...final_report_schema import load_schema_version
46
56
  from ...report_inputs import report_narrative_path, uses_report_contract_v3
@@ -300,6 +310,12 @@ def _materialize_run(
300
310
  must_exist=True,
301
311
  )
302
312
  if contract.get("catalogDigest") != digest_duty_catalog(duty_root):
313
+ # 형식이 바뀐 릴리스를 설치하면 옛 run 의 다이제스트는 영영 맞지 않는다.
314
+ # "다이제스트 불일치" 만 말하면 읽는 쪽은 파일이 변조된 줄 알고 그 자리를
315
+ # 뒤진다 — 실측 2026-09-23: 한 세션이 dispatch-kind 별 분기를 찾다가
316
+ # 멈췄고, 원인은 그 사이에 일어난 설치였다.
317
+ if frozen_format_version(contract) != CONTRACT_FORMAT_VERSION:
318
+ raise AgentPromptCliError(refreeze_message(str(duty_root)))
303
319
  raise AgentPromptCliError("run duty snapshot catalog digest does not match")
304
320
  active_context_path = _project_manifest_path(
305
321
  project_root,
@@ -357,6 +373,13 @@ def _materialize_run(
357
373
  "--corrections requires report contract 3.0 (a Markdown narrative); "
358
374
  "this run uses an older contract"
359
375
  )
376
+ if args.audience in _RESOURCE_AUDIENCES and not is_reverify:
377
+ anchor_lines = anchor_lines + tuple(
378
+ _implementation_anchors(project_root, manifest_path, args.worker_id)
379
+ )
380
+ body = _with_required_resources(
381
+ project_root, manifest_path, args.worker_id, body
382
+ )
360
383
  request = AgentInvocationRequest(
361
384
  invocation_id=args.invocation_id,
362
385
  worker_id=args.worker_id if identity is None else None,
@@ -410,6 +433,62 @@ def _materialize_run(
410
433
  return prepare_agent_invocation(request)
411
434
 
412
435
 
436
+ # 디스패치 composer 가 필수 자원 블록을 싣는 audience. 이 명령으로 먼저 발행된
437
+ # 프롬프트는 디스패치가 그대로 쓰므로, 블록을 여기서 붙이지 않으면 워커에 닿지 않는다.
438
+ _RESOURCE_AUDIENCES = frozenset({"implementation-executor", "implementation-verifier"})
439
+
440
+
441
+ def _implementation_anchors(
442
+ project_root: Path, manifest_path: Path, worker_id: str,
443
+ ) -> list[str]:
444
+ """디스패치 composer 와 같은 구현 앵커 줄."""
445
+ try:
446
+ return implementation_anchor_lines(
447
+ project_root=project_root,
448
+ run_manifest_path=manifest_path,
449
+ runtime_root=_runtime_root(),
450
+ worker_id=worker_id,
451
+ )
452
+ except InitialPromptMaterializationError as exc:
453
+ raise AgentPromptCliError(f"implementation prompt anchors: {exc}") from exc
454
+
455
+
456
+ def _runtime_root() -> Path:
457
+ from ...paths import find_asset_root
458
+
459
+ root = find_asset_root(("prompts", "profiles"), is_present=Path.is_dir)
460
+ if root is None:
461
+ raise AgentPromptCliError(
462
+ "prompt profiles not found: no prompts/profiles under OKSTRA_HOME "
463
+ "or this checkout"
464
+ )
465
+ return root
466
+
467
+
468
+ def _with_required_resources(
469
+ project_root: Path, manifest_path: Path, worker_id: str, body: str,
470
+ ) -> str:
471
+ """리드의 지시문 뒤에 composer 와 같은 필수 자원 블록을 붙인다.
472
+
473
+ executor 의 BLOCKING 게이트 세 본문, verifier 의 `_implementation-verifier.md`
474
+ (self-mock 검출기 호출문 포함), clarification 권한 블록, fix-run carry 다.
475
+ 배송 방식(eager / lazy)은 run 매니페스트의 선언을 따른다.
476
+ """
477
+ runtime_root = _runtime_root()
478
+ try:
479
+ block = required_resource_block(
480
+ project_root=project_root,
481
+ run_manifest_path=manifest_path,
482
+ runtime_root=runtime_root,
483
+ worker_id=worker_id,
484
+ )
485
+ except InitialPromptMaterializationError as exc:
486
+ raise AgentPromptCliError(f"required prompt resources: {exc}") from exc
487
+ if not block:
488
+ return body
489
+ return body.rstrip() + "\n\n" + block + "\n"
490
+
491
+
413
492
  def _complete_run_reverify_body(
414
493
  args: argparse.Namespace, manifest: Mapping[str, Any],
415
494
  active_context: Mapping[str, Any], assignment: AgentModelAssignment,
@@ -956,17 +1035,43 @@ def _snapshot_standalone_duties(destination: Path) -> None:
956
1035
  "OKSTRA_HOME or this checkout"
957
1036
  )
958
1037
  source = root.joinpath(*duties_relative)
1038
+ # 공통 계약은 직무가 아니라 `agents/` 가 소유한다(ADR-0017). 동결본은 한
1039
+ # 뿌리여야 하므로 여기서 함께 싣는다.
1040
+ common_root = find_asset_root(("agents", "common.json"), is_present=Path.is_file)
1041
+ if common_root is None:
1042
+ raise AgentPromptCliError(
1043
+ "agent common contract not found: no agents/common.json under "
1044
+ "OKSTRA_HOME or this checkout"
1045
+ )
1046
+ common_source = common_root / "agents" / "common.json"
1047
+
1048
+ def _staged_digest() -> str:
1049
+ staged = Path(tempfile.mkdtemp(prefix=".okstra-standalone-duties."))
1050
+ try:
1051
+ shutil.copytree(source, staged, dirs_exist_ok=True)
1052
+ shutil.copyfile(common_source, staged / "common.json")
1053
+ shutil.copytree(
1054
+ common_source.parent / "roles", staged / "roles", dirs_exist_ok=True
1055
+ )
1056
+ return digest_duty_catalog(staged)
1057
+ finally:
1058
+ shutil.rmtree(staged, ignore_errors=True)
1059
+
959
1060
  if destination.exists():
960
- if not destination.is_dir() or digest_duty_catalog(destination) != digest_duty_catalog(source):
1061
+ if not destination.is_dir() or digest_duty_catalog(destination) != _staged_digest():
961
1062
  raise AgentPromptCliError("standalone duty snapshot conflicts with existing files")
962
1063
  return
963
1064
  temporary = Path(tempfile.mkdtemp(prefix=f".{destination.name}.", dir=destination.parent))
964
1065
  try:
965
1066
  shutil.copytree(source, temporary, dirs_exist_ok=True)
1067
+ shutil.copyfile(common_source, temporary / "common.json")
1068
+ shutil.copytree(
1069
+ common_source.parent / "roles", temporary / "roles", dirs_exist_ok=True
1070
+ )
966
1071
  try:
967
1072
  os.rename(temporary, destination)
968
1073
  except FileExistsError:
969
- if digest_duty_catalog(destination) != digest_duty_catalog(source):
1074
+ if digest_duty_catalog(destination) != _staged_digest():
970
1075
  raise AgentPromptCliError("standalone duty snapshot publication conflict")
971
1076
  finally:
972
1077
  if temporary.exists():