okstra 0.180.0 → 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 (223) hide show
  1. package/dist/cli-registry.mjs +16 -2
  2. package/dist/cli-registry.mjs.map +1 -1
  3. package/dist/commands/execute/render-bundle.d.mts +4 -2
  4. package/dist/commands/execute/render-bundle.mjs +46 -5
  5. package/dist/commands/execute/render-bundle.mjs.map +1 -1
  6. package/dist/commands/execute/run.mjs +11 -3
  7. package/dist/commands/execute/run.mjs.map +1 -1
  8. package/dist/commands/inspect/model-io.d.mts +1 -0
  9. package/dist/commands/inspect/model-io.mjs +25 -0
  10. package/dist/commands/inspect/model-io.mjs.map +1 -0
  11. package/dist/commands/inspect/stage-map.mjs +29 -8
  12. package/dist/commands/inspect/stage-map.mjs.map +1 -1
  13. package/dist/commands/inspect/task-list.mjs +52 -6
  14. package/dist/commands/inspect/task-list.mjs.map +1 -1
  15. package/dist/commands/inspect/user-response.mjs +14 -4
  16. package/dist/commands/inspect/user-response.mjs.map +1 -1
  17. package/dist/commands/lifecycle/check-project.d.mts +1 -0
  18. package/dist/commands/lifecycle/check-project.mjs +69 -50
  19. package/dist/commands/lifecycle/check-project.mjs.map +1 -1
  20. package/dist/commands/lifecycle/contract-check.d.mts +1 -0
  21. package/dist/commands/lifecycle/contract-check.mjs +18 -0
  22. package/dist/commands/lifecycle/contract-check.mjs.map +1 -0
  23. package/dist/commands/lifecycle/preflight.mjs +154 -51
  24. package/dist/commands/lifecycle/preflight.mjs.map +1 -1
  25. package/dist/commands/pr/pr.d.mts +1 -0
  26. package/dist/commands/pr/pr.mjs +19 -1
  27. package/dist/commands/pr/pr.mjs.map +1 -1
  28. package/dist/commands/report/agent-activity.mjs +2 -2
  29. package/dist/commands/report/translate.mjs +3 -0
  30. package/dist/commands/report/translate.mjs.map +1 -1
  31. package/dist/lib/host-registry-client.mjs +13 -9
  32. package/dist/lib/host-registry-client.mjs.map +1 -1
  33. package/docs/architecture.md +11 -0
  34. package/docs/cli.md +26 -13
  35. package/docs/container.md +6 -4
  36. package/docs/contributor-change-matrix.md +1 -1
  37. package/docs/for-ai/README.md +2 -2
  38. package/docs/for-ai/skills/okstra-brief-gen.md +5 -3
  39. package/docs/for-ai/skills/okstra-code-review.md +4 -4
  40. package/docs/for-ai/skills/okstra-container-build.md +20 -17
  41. package/docs/for-ai/skills/okstra-inspect.md +20 -23
  42. package/docs/for-ai/skills/okstra-manager.md +19 -18
  43. package/docs/for-ai/skills/okstra-memory.md +2 -2
  44. package/docs/for-ai/skills/okstra-pr-gen.md +3 -3
  45. package/docs/for-ai/skills/okstra-rollup.md +14 -13
  46. package/docs/for-ai/skills/okstra-run.md +7 -3
  47. package/docs/for-ai/skills/okstra-schedule-gen.md +15 -18
  48. package/docs/for-ai/skills/okstra-setup.md +7 -7
  49. package/docs/for-ai/skills/okstra-usage.md +5 -4
  50. package/docs/for-ai/skills/okstra-user-response.md +50 -32
  51. package/docs/project-structure-overview.md +29 -26
  52. package/docs/task-process/implementation.md +7 -4
  53. package/package.json +1 -1
  54. package/runtime/BUILD.json +2 -2
  55. package/runtime/agents/workers/claude-worker.md +4 -4
  56. package/runtime/agents/workers/report-writer-worker.md +3 -3
  57. package/runtime/agents/workers/translator-worker.md +5 -13
  58. package/runtime/bin/okstra-error-log.py +51 -11
  59. package/runtime/bin/okstra-report-translate.py +210 -23
  60. package/runtime/prompts/host-orchestration/implementation.md +1 -1
  61. package/runtime/prompts/launch.template.md +3 -7
  62. package/runtime/prompts/lead/context-loader.md +41 -141
  63. package/runtime/prompts/lead/convergence.md +8 -6
  64. package/runtime/prompts/lead/okstra-lead-contract.md +25 -35
  65. package/runtime/prompts/lead/plan-body-verification.md +187 -14
  66. package/runtime/prompts/lead/report-writer.md +20 -1
  67. package/runtime/prompts/lead/team-contract.md +8 -53
  68. package/runtime/prompts/profiles/_coding-conventions-preflight.md +3 -2
  69. package/runtime/prompts/profiles/_common-contract.md +1 -1
  70. package/runtime/prompts/profiles/_implementation-diff-review.md +1 -1
  71. package/runtime/prompts/profiles/_implementation-verifier.md +3 -3
  72. package/runtime/prompts/profiles/final-verification.md +1 -1
  73. package/runtime/prompts/profiles/implementation-planning.md +8 -2
  74. package/runtime/prompts/profiles/release-handoff.md +0 -1
  75. package/runtime/prompts/wizard/prompts.ko.json +0 -1
  76. package/runtime/python/okstra_ctl/adapters/hosts/capability_adapter.py +69 -17
  77. package/runtime/python/okstra_ctl/adapters/hosts/claude-code/adapter.py +13 -4
  78. package/runtime/python/okstra_ctl/adapters/hosts/claude-code/relay.md +6 -1
  79. package/runtime/python/okstra_ctl/adapters/hosts/codex/adapter.py +2 -2
  80. package/runtime/python/okstra_ctl/adapters/hosts/codex/relay.md +50 -5
  81. package/runtime/python/okstra_ctl/adapters/hosts/grok/adapter.py +2 -2
  82. package/runtime/python/okstra_ctl/adapters/hosts/grok/relay.md +66 -5
  83. package/runtime/python/okstra_ctl/adapters/providers/grok/adapter.py +70 -2
  84. package/runtime/python/okstra_ctl/agent_activity.py +118 -35
  85. package/runtime/python/okstra_ctl/agent_invocation.py +19 -6
  86. package/runtime/python/okstra_ctl/agent_prompt_cli.py +65 -18
  87. package/runtime/python/okstra_ctl/analysis_inputs.py +5 -4
  88. package/runtime/python/okstra_ctl/analysis_packet.py +81 -1
  89. package/runtime/python/okstra_ctl/approval_decisions.py +3 -2
  90. package/runtime/python/okstra_ctl/attempt_evidence.py +2 -2
  91. package/runtime/python/okstra_ctl/backfill.py +13 -10
  92. package/runtime/python/okstra_ctl/batch.py +2 -4
  93. package/runtime/python/okstra_ctl/build_tools.py +6 -3
  94. package/runtime/python/okstra_ctl/claim_reproduction.py +101 -0
  95. package/runtime/python/okstra_ctl/clarification_items.py +27 -13
  96. package/runtime/python/okstra_ctl/cmux.py +130 -52
  97. package/runtime/python/okstra_ctl/code_review_target.py +34 -8
  98. package/runtime/python/okstra_ctl/consumers.py +5 -4
  99. package/runtime/python/okstra_ctl/container.py +103 -8
  100. package/runtime/python/okstra_ctl/context_cost.py +2 -1
  101. package/runtime/python/okstra_ctl/contract_graph.py +497 -0
  102. package/runtime/python/okstra_ctl/contract_graph_cli.py +62 -0
  103. package/runtime/python/okstra_ctl/convergence.py +338 -17
  104. package/runtime/python/okstra_ctl/convergence_engine.py +10 -18
  105. package/runtime/python/okstra_ctl/convergence_provenance.py +58 -8
  106. package/runtime/python/okstra_ctl/convergence_store.py +55 -34
  107. package/runtime/python/okstra_ctl/design_prep.py +7 -4
  108. package/runtime/python/okstra_ctl/dispatch_core.py +35 -65
  109. package/runtime/python/okstra_ctl/dispatch_state.py +134 -59
  110. package/runtime/python/okstra_ctl/doctor.py +6 -3
  111. package/runtime/python/okstra_ctl/domain/worker_presentation.py +70 -9
  112. package/runtime/python/okstra_ctl/entrypoints/hosts.py +16 -30
  113. package/runtime/python/okstra_ctl/error_log_write.py +35 -30
  114. package/runtime/python/okstra_ctl/error_report.py +26 -1
  115. package/runtime/python/okstra_ctl/error_zip.py +27 -5
  116. package/runtime/python/okstra_ctl/execution_identity.py +3 -2
  117. package/runtime/python/okstra_ctl/execution_manifest.py +7 -4
  118. package/runtime/python/okstra_ctl/final_report_schema.py +2 -2
  119. package/runtime/python/okstra_ctl/fix_cycles.py +2 -2
  120. package/runtime/python/okstra_ctl/fixed_text.py +39 -0
  121. package/runtime/python/okstra_ctl/git_reconcile.py +41 -9
  122. package/runtime/python/okstra_ctl/handoff.py +5 -4
  123. package/runtime/python/okstra_ctl/i18n.py +4 -2
  124. package/runtime/python/okstra_ctl/implementation_direction.py +22 -14
  125. package/runtime/python/okstra_ctl/implementation_outcome.py +4 -7
  126. package/runtime/python/okstra_ctl/incremental_carry.py +2 -1
  127. package/runtime/python/okstra_ctl/incremental_scope.py +8 -3
  128. package/runtime/python/okstra_ctl/index.py +8 -11
  129. package/runtime/python/okstra_ctl/initial_prompt_materialization.py +79 -7
  130. package/runtime/python/okstra_ctl/invocation.py +3 -6
  131. package/runtime/python/okstra_ctl/json_boundary.py +366 -0
  132. package/runtime/python/okstra_ctl/json_registry.py +10 -12
  133. package/runtime/python/okstra_ctl/jsonl.py +19 -2
  134. package/runtime/python/okstra_ctl/lead_events.py +33 -1
  135. package/runtime/python/okstra_ctl/listing.py +3 -3
  136. package/runtime/python/okstra_ctl/log_report.py +24 -2
  137. package/runtime/python/okstra_ctl/manager_cli.py +92 -7
  138. package/runtime/python/okstra_ctl/manager_store.py +12 -10
  139. package/runtime/python/okstra_ctl/material.py +5 -1
  140. package/runtime/python/okstra_ctl/migrate.py +29 -25
  141. package/runtime/python/okstra_ctl/model_cli.py +3 -15
  142. package/runtime/python/okstra_ctl/model_io_cli.py +1051 -0
  143. package/runtime/python/okstra_ctl/mutation_probe.py +13 -4
  144. package/runtime/python/okstra_ctl/pane_reclaim.py +3 -2
  145. package/runtime/python/okstra_ctl/paths.py +9 -0
  146. package/runtime/python/okstra_ctl/plan_items.py +115 -4
  147. package/runtime/python/okstra_ctl/plan_items_cli.py +513 -18
  148. package/runtime/python/okstra_ctl/pr_template.py +3 -2
  149. package/runtime/python/okstra_ctl/project_meta.py +5 -7
  150. package/runtime/python/okstra_ctl/recap.py +5 -4
  151. package/runtime/python/okstra_ctl/reconcile.py +21 -27
  152. package/runtime/python/okstra_ctl/registry/host_discovery.py +3 -2
  153. package/runtime/python/okstra_ctl/registry/provider_registry.py +3 -2
  154. package/runtime/python/okstra_ctl/render.py +26 -15
  155. package/runtime/python/okstra_ctl/render_final_report.py +3 -2
  156. package/runtime/python/okstra_ctl/report_assembly.py +172 -17
  157. package/runtime/python/okstra_ctl/report_finalize.py +7 -10
  158. package/runtime/python/okstra_ctl/report_html/render.py +3 -2
  159. package/runtime/python/okstra_ctl/report_language.py +3 -2
  160. package/runtime/python/okstra_ctl/report_markdown.py +13 -1
  161. package/runtime/python/okstra_ctl/report_narrative.py +40 -8
  162. package/runtime/python/okstra_ctl/report_synthesis_packet.py +518 -0
  163. package/runtime/python/okstra_ctl/report_views.py +3 -2
  164. package/runtime/python/okstra_ctl/rollup.py +65 -4
  165. package/runtime/python/okstra_ctl/run.py +159 -56
  166. package/runtime/python/okstra_ctl/run_audit.py +3 -2
  167. package/runtime/python/okstra_ctl/run_context.py +6 -9
  168. package/runtime/python/okstra_ctl/run_index_row.py +2 -8
  169. package/runtime/python/okstra_ctl/schedule_semantics.py +5 -2
  170. package/runtime/python/okstra_ctl/schema_excerpt.py +4 -2
  171. package/runtime/python/okstra_ctl/session_transcript.py +27 -1
  172. package/runtime/python/okstra_ctl/set_work_status.py +64 -38
  173. package/runtime/python/okstra_ctl/stage_fix_carry.py +4 -2
  174. package/runtime/python/okstra_ctl/stage_map.py +26 -6
  175. package/runtime/python/okstra_ctl/stage_targets.py +3 -4
  176. package/runtime/python/okstra_ctl/team.py +2 -1
  177. package/runtime/python/okstra_ctl/team_reconcile.py +11 -2
  178. package/runtime/python/okstra_ctl/time_report.py +51 -4
  179. package/runtime/python/okstra_ctl/usage_identity.py +2 -1
  180. package/runtime/python/okstra_ctl/usage_report.py +58 -4
  181. package/runtime/python/okstra_ctl/user_response.py +1431 -66
  182. package/runtime/python/okstra_ctl/wizard.py +11 -44
  183. package/runtime/python/okstra_ctl/work_categories.py +3 -2
  184. package/runtime/python/okstra_ctl/worker_prompt_body.py +18 -7
  185. package/runtime/python/okstra_ctl/worker_prompt_contract.py +3 -2
  186. package/runtime/python/okstra_ctl/worker_runner.py +14 -12
  187. package/runtime/python/okstra_ctl/workflow.py +2 -1
  188. package/runtime/python/okstra_ctl/worktree.py +3 -2
  189. package/runtime/python/okstra_ctl/wrapper_status.py +4 -2
  190. package/runtime/python/okstra_ctl/write_policy.py +4 -2
  191. package/runtime/python/okstra_token_usage/antigravity.py +39 -12
  192. package/runtime/python/okstra_token_usage/collect.py +90 -38
  193. package/runtime/python/okstra_token_usage/grok.py +127 -0
  194. package/runtime/schemas/final-report-v2.0.schema.json +9 -0
  195. package/runtime/schemas/final-report-v3.0.schema.json +9 -0
  196. package/runtime/schemas/report-synthesis-packet-v1.0.schema.json +140 -0
  197. package/runtime/skills/okstra-brief-gen/SKILL.md +9 -7
  198. package/runtime/skills/okstra-code-review/SKILL.md +21 -11
  199. package/runtime/skills/okstra-container-build/SKILL.md +18 -18
  200. package/runtime/skills/okstra-inspect/SKILL.md +12 -11
  201. package/runtime/skills/okstra-inspect/facets/error-zip.md +8 -8
  202. package/runtime/skills/okstra-inspect/facets/errors.md +2 -2
  203. package/runtime/skills/okstra-inspect/facets/history.md +9 -14
  204. package/runtime/skills/okstra-inspect/facets/logs.md +2 -2
  205. package/runtime/skills/okstra-inspect/facets/recap.md +5 -5
  206. package/runtime/skills/okstra-inspect/facets/report.md +6 -10
  207. package/runtime/skills/okstra-inspect/facets/status.md +9 -8
  208. package/runtime/skills/okstra-inspect/facets/time.md +3 -3
  209. package/runtime/skills/okstra-manager/SKILL.md +16 -14
  210. package/runtime/skills/okstra-memory/SKILL.md +3 -3
  211. package/runtime/skills/okstra-pr-gen/SKILL.md +5 -4
  212. package/runtime/skills/okstra-rollup/SKILL.md +6 -16
  213. package/runtime/skills/okstra-run/SKILL.md +7 -7
  214. package/runtime/skills/okstra-schedule-gen/SKILL.md +21 -17
  215. package/runtime/skills/okstra-setup/SKILL.md +21 -13
  216. package/runtime/skills/okstra-setup/references/project-config.md +2 -2
  217. package/runtime/skills/okstra-usage/SKILL.md +10 -10
  218. package/runtime/skills/okstra-user-response/SKILL.md +78 -107
  219. package/runtime/templates/report-writer-prompt-preamble.md +17 -1
  220. package/runtime/templates/reports/schedule.template.md +4 -4
  221. package/runtime/templates/worker-error-contract.md +17 -29
  222. package/runtime/validators/validate-run.py +379 -63
  223. package/runtime/validators/validate_session_conformance.py +43 -7
@@ -0,0 +1,497 @@
1
+ """런타임 계약 적재, 교차 참조 검증, 의존성 폐쇄 계산."""
2
+ from __future__ import annotations
3
+
4
+ from collections import Counter
5
+ from collections.abc import Mapping, Sequence
6
+ from dataclasses import dataclass
7
+ import hashlib
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from .final_report_schema import validate as validate_schema
12
+ from .json_boundary import JsonBoundaryError, load_owned_object
13
+
14
+
15
+ _SCHEMA_FILES = {
16
+ "common": "agent-common-v1.schema.json",
17
+ "role": "agent-role-v1.schema.json",
18
+ "duty": "agent-duty-v1.schema.json",
19
+ "profile": "agent-profile-v1.schema.json",
20
+ "operation": "agent-operation-v1.schema.json",
21
+ }
22
+ _KNOWN_ROLE_CAPABILITIES = frozenset(
23
+ {
24
+ "extended-artifact-authoring",
25
+ "lead-session",
26
+ "project-mutation",
27
+ "source-readonly",
28
+ "worker-artifact-io",
29
+ }
30
+ )
31
+
32
+
33
+ class ContractGraphError(ValueError):
34
+ """한 검증 단계에서 유효하지 않은 런타임 계약을 발견했다."""
35
+
36
+ def __init__(self, errors: Sequence[str]) -> None:
37
+ self.errors = tuple(errors)
38
+ super().__init__("\n".join(self.errors))
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class ContractGraphRoot:
43
+ common: Path
44
+ roles: Path
45
+ duties: Path
46
+ profiles: Path
47
+ operations: Path
48
+ schemas: Path
49
+
50
+ @classmethod
51
+ def from_base(cls, base: Path) -> ContractGraphRoot:
52
+ return cls(
53
+ common=base / "agents" / "common.json",
54
+ roles=base / "agents" / "roles",
55
+ duties=base / "prompts" / "duties",
56
+ profiles=base / "prompts" / "profiles",
57
+ operations=base / "agents" / "operations",
58
+ schemas=base / "schemas",
59
+ )
60
+
61
+
62
+ @dataclass(frozen=True)
63
+ class ContractSelection:
64
+ profile_id: str | None = None
65
+ operation_id: str | None = None
66
+
67
+
68
+ @dataclass(frozen=True)
69
+ class ContractFile:
70
+ relative_path: str
71
+ schema_version: str
72
+ sha256: str
73
+ payload: Mapping[str, Any]
74
+
75
+ @property
76
+ def id(self) -> str:
77
+ return str(self.payload["id"])
78
+
79
+ @property
80
+ def role_id(self) -> str:
81
+ return str(self.payload["roleId"])
82
+
83
+
84
+ @dataclass(frozen=True)
85
+ class _LoadedContracts:
86
+ common: ContractFile
87
+ roles: tuple[ContractFile, ...]
88
+ duties: tuple[ContractFile, ...]
89
+ profiles: tuple[ContractFile, ...]
90
+ operations: tuple[ContractFile, ...]
91
+ profile_markdown: Mapping[str, ContractFile]
92
+
93
+
94
+ @dataclass(frozen=True)
95
+ class ContractGraph:
96
+ common: ContractFile
97
+ roles: Mapping[str, ContractFile]
98
+ duties: Mapping[str, ContractFile]
99
+ profiles: Mapping[str, ContractFile]
100
+ operations: Mapping[str, ContractFile]
101
+ profile_markdown: Mapping[str, ContractFile]
102
+
103
+ @property
104
+ def files(self) -> tuple[ContractFile, ...]:
105
+ rows = (
106
+ self.common,
107
+ *self.roles.values(),
108
+ *self.duties.values(),
109
+ *self.profiles.values(),
110
+ *self.profile_markdown.values(),
111
+ *self.operations.values(),
112
+ )
113
+ return tuple(sorted(rows, key=lambda row: row.relative_path))
114
+
115
+ def dependencies_for_profile(self, profile_id: str) -> Sequence[ContractFile]:
116
+ profile = self._find(self.profiles, profile_id, "profile")
117
+ dependencies = [self.common, profile]
118
+ markdown = self.profile_markdown.get(profile_id)
119
+ if markdown is not None:
120
+ dependencies.append(markdown)
121
+ for requirement in profile.payload["roles"]:
122
+ dependencies.append(self.roles[str(requirement["roleId"])])
123
+ dependencies.append(self.duties[str(requirement["dutyId"])])
124
+ for source_role_id in requirement.get("sourceRoleIds", []):
125
+ dependencies.append(self.roles[str(source_role_id)])
126
+ return _unique_sorted(dependencies)
127
+
128
+ def dependencies_for_operation(self, operation_id: str) -> Sequence[ContractFile]:
129
+ operation = self._find(self.operations, operation_id, "operation")
130
+ duty = self.duties[str(operation.payload["dutyId"])]
131
+ return _unique_sorted([self.common, operation, duty, self.roles[duty.role_id]])
132
+
133
+ @staticmethod
134
+ def _find(
135
+ contracts: Mapping[str, ContractFile], contract_id: str, kind: str
136
+ ) -> ContractFile:
137
+ try:
138
+ return contracts[contract_id]
139
+ except KeyError as exc:
140
+ raise ContractGraphError(
141
+ [f"<selection>: {kind}Id: unknown {kind} id {contract_id!r}"]
142
+ ) from exc
143
+
144
+
145
+ class ContractGraphValidator:
146
+ def validate(
147
+ self,
148
+ root: ContractGraphRoot,
149
+ *,
150
+ selected: ContractSelection | None = None,
151
+ model_refs: Mapping[str, Sequence[str]] | None = None,
152
+ ) -> ContractGraph:
153
+ loaded = _load_contracts(root)
154
+ schemas = _load_schemas(root, loaded)
155
+ _validate_documents(loaded, schemas)
156
+ _validate_identifiers(loaded)
157
+ graph = _build_graph(loaded)
158
+ _validate_duty_roles(graph)
159
+ _validate_references(graph)
160
+ _validate_dynamic_sources(graph)
161
+ _validate_role_capabilities(graph)
162
+ _validate_counts(graph, selected, model_refs)
163
+ _validate_model_diversity(graph, model_refs)
164
+ _validate_selection(graph, selected)
165
+ return graph
166
+
167
+
168
+ def _load_schemas(
169
+ root: ContractGraphRoot, loaded: _LoadedContracts
170
+ ) -> dict[str, Mapping[str, Any]]:
171
+ schemas: dict[str, Mapping[str, Any]] = {}
172
+ errors: list[str] = []
173
+ for kind in _required_schema_kinds(loaded):
174
+ filename = _SCHEMA_FILES[kind]
175
+ path = root.schemas / filename
176
+ try:
177
+ schemas[kind] = load_owned_object(path, artifact=f"schemas/{filename}")
178
+ except JsonBoundaryError as exc:
179
+ errors.append(f"schemas/{filename}: <root>: {exc.reason}")
180
+ _fail(errors)
181
+ return schemas
182
+
183
+
184
+ def _required_schema_kinds(loaded: _LoadedContracts) -> tuple[str, ...]:
185
+ kinds = ["common"]
186
+ for kind, rows in (
187
+ ("role", loaded.roles),
188
+ ("duty", loaded.duties),
189
+ ("profile", loaded.profiles),
190
+ ("operation", loaded.operations),
191
+ ):
192
+ if rows:
193
+ kinds.append(kind)
194
+ return tuple(kinds)
195
+
196
+
197
+ def _load_contracts(root: ContractGraphRoot) -> _LoadedContracts:
198
+ try:
199
+ common = _load_json_contract(root.common, "agents/common.json")
200
+ roles = _load_directory(root.roles, "agents/roles")
201
+ duties = _load_directory(root.duties, "prompts/duties")
202
+ profiles = _load_directory(
203
+ root.profiles,
204
+ "prompts/profiles",
205
+ excluded_names=frozenset({"forbidden-actions.json"}),
206
+ )
207
+ operations = _load_directory(root.operations, "agents/operations")
208
+ except JsonBoundaryError as exc:
209
+ relative = str(exc.artifact)
210
+ raise ContractGraphError([f"{relative}: <root>: {exc.reason}"]) from exc
211
+ markdown = _load_profile_markdown(root.profiles, profiles)
212
+ return _LoadedContracts(common, roles, duties, profiles, operations, markdown)
213
+
214
+
215
+ def _load_directory(
216
+ directory: Path,
217
+ prefix: str,
218
+ *,
219
+ excluded_names: frozenset[str] = frozenset(),
220
+ ) -> tuple[ContractFile, ...]:
221
+ if not directory.is_dir():
222
+ return ()
223
+ return tuple(
224
+ _load_json_contract(path, f"{prefix}/{path.name}")
225
+ for path in sorted(directory.glob("*.json"))
226
+ if path.name not in excluded_names
227
+ )
228
+
229
+
230
+ def _load_json_contract(path: Path, relative_path: str) -> ContractFile:
231
+ payload = load_owned_object(path, artifact=relative_path)
232
+ version = payload.get("schemaVersion")
233
+ schema_version = version if isinstance(version, str) else "<invalid>"
234
+ return ContractFile(relative_path, schema_version, _digest(path), payload)
235
+
236
+
237
+ def _load_profile_markdown(
238
+ directory: Path, profiles: Sequence[ContractFile]
239
+ ) -> dict[str, ContractFile]:
240
+ rows: dict[str, ContractFile] = {}
241
+ for profile in profiles:
242
+ profile_id = Path(profile.relative_path).stem
243
+ path = directory / f"{profile_id}.md"
244
+ if not path.is_file():
245
+ continue
246
+ relative_path = f"prompts/profiles/{path.name}"
247
+ rows[profile_id] = ContractFile(
248
+ relative_path,
249
+ profile.schema_version,
250
+ _digest(path),
251
+ {"text": path.read_text(encoding="utf-8")},
252
+ )
253
+ return rows
254
+
255
+
256
+ def _validate_documents(
257
+ loaded: _LoadedContracts, schemas: Mapping[str, Mapping[str, Any]]
258
+ ) -> None:
259
+ groups = (
260
+ ("common", (loaded.common,)),
261
+ ("role", loaded.roles),
262
+ ("duty", loaded.duties),
263
+ ("profile", loaded.profiles),
264
+ ("operation", loaded.operations),
265
+ )
266
+ errors = [
267
+ f"{row.relative_path}: {error}"
268
+ for kind, rows in groups
269
+ for row in rows
270
+ for error in validate_schema(dict(row.payload), dict(schemas[kind]))
271
+ ]
272
+ _fail(errors)
273
+
274
+
275
+ def _validate_identifiers(loaded: _LoadedContracts) -> None:
276
+ groups = (
277
+ ("roles", loaded.roles),
278
+ ("duties", loaded.duties),
279
+ ("profiles", loaded.profiles),
280
+ ("operations", loaded.operations),
281
+ )
282
+ errors: list[str] = []
283
+ for namespace, rows in groups:
284
+ counts = Counter(row.id for row in rows)
285
+ for row in rows:
286
+ filename_id = Path(row.relative_path).stem
287
+ if counts[row.id] > 1:
288
+ errors.append(
289
+ f"{row.relative_path}: id: duplicate id {row.id!r} in {namespace}"
290
+ )
291
+ if row.id != filename_id:
292
+ errors.append(
293
+ f"{row.relative_path}: id: id {row.id!r} does not match "
294
+ f"filename {filename_id!r}"
295
+ )
296
+ _fail(errors)
297
+
298
+
299
+ def _build_graph(loaded: _LoadedContracts) -> ContractGraph:
300
+ return ContractGraph(
301
+ common=loaded.common,
302
+ roles={row.id: row for row in loaded.roles},
303
+ duties={row.id: row for row in loaded.duties},
304
+ profiles={row.id: row for row in loaded.profiles},
305
+ operations={row.id: row for row in loaded.operations},
306
+ profile_markdown=loaded.profile_markdown,
307
+ )
308
+
309
+
310
+ def _validate_duty_roles(graph: ContractGraph) -> None:
311
+ errors = [
312
+ f"{duty.relative_path}: roleId: unknown roleId {duty.role_id!r}"
313
+ for duty in graph.duties.values()
314
+ if duty.role_id not in graph.roles
315
+ ]
316
+ _fail(errors)
317
+
318
+
319
+ def _validate_references(graph: ContractGraph) -> None:
320
+ errors: list[str] = []
321
+ for profile in graph.profiles.values():
322
+ for index, requirement in enumerate(profile.payload["roles"]):
323
+ duty_id = str(requirement["dutyId"])
324
+ role_id = str(requirement["roleId"])
325
+ field = f"roles[{index}]"
326
+ duty = graph.duties.get(duty_id)
327
+ if duty is None:
328
+ errors.append(
329
+ f"{profile.relative_path}: {field}.dutyId: "
330
+ f"unknown dutyId {duty_id!r}"
331
+ )
332
+ if role_id not in graph.roles:
333
+ errors.append(
334
+ f"{profile.relative_path}: {field}.roleId: "
335
+ f"unknown roleId {role_id!r}"
336
+ )
337
+ if duty is not None and duty.role_id != role_id:
338
+ errors.append(
339
+ f"{profile.relative_path}: {field}.roleId: "
340
+ f"roleId does not match duty {duty_id!r}"
341
+ )
342
+ errors.extend(_operation_reference_errors(graph))
343
+ errors.extend(
344
+ f"prompts/profiles/{profile_id}.md: <root>: missing profile instruction"
345
+ for profile_id in graph.profiles
346
+ if profile_id not in graph.profile_markdown
347
+ )
348
+ _fail(errors)
349
+
350
+
351
+ def _operation_reference_errors(graph: ContractGraph) -> list[str]:
352
+ return [
353
+ f"{operation.relative_path}: dutyId: unknown dutyId {duty_id!r}"
354
+ for operation in graph.operations.values()
355
+ if (duty_id := str(operation.payload["dutyId"])) not in graph.duties
356
+ ]
357
+
358
+
359
+ def _validate_dynamic_sources(graph: ContractGraph) -> None:
360
+ errors: list[str] = []
361
+ for profile in graph.profiles.values():
362
+ static_roles = {
363
+ str(row["roleId"])
364
+ for row in profile.payload["roles"]
365
+ if row["mode"] == "static"
366
+ }
367
+ for index, requirement in enumerate(profile.payload["roles"]):
368
+ if requirement["mode"] != "dynamic":
369
+ continue
370
+ unknown = sorted(set(requirement["sourceRoleIds"]) - static_roles)
371
+ if unknown:
372
+ errors.append(
373
+ f"{profile.relative_path}: roles[{index}].sourceRoleIds: "
374
+ f"unknown sourceRoleIds {unknown!r}"
375
+ )
376
+ _fail(errors)
377
+
378
+
379
+ def _validate_role_capabilities(graph: ContractGraph) -> None:
380
+ errors: list[str] = []
381
+ for role in graph.roles.values():
382
+ capabilities = set(role.payload["requiredCapabilities"])
383
+ unknown = sorted(capabilities - _KNOWN_ROLE_CAPABILITIES)
384
+ if unknown:
385
+ errors.append(
386
+ f"{role.relative_path}: requiredCapabilities: "
387
+ f"unknown requiredCapabilities {unknown!r}"
388
+ )
389
+ _fail(errors)
390
+
391
+
392
+ def _validate_counts(
393
+ graph: ContractGraph,
394
+ selected: ContractSelection | None,
395
+ model_refs: Mapping[str, Sequence[str]] | None,
396
+ ) -> None:
397
+ errors: list[str] = []
398
+ for profile in graph.profiles.values():
399
+ for index, requirement in enumerate(profile.payload["roles"]):
400
+ if requirement["mode"] != "static":
401
+ continue
402
+ counts = (
403
+ requirement["min"],
404
+ requirement["recommended"],
405
+ requirement["max"],
406
+ )
407
+ if counts != tuple(sorted(counts)):
408
+ errors.append(
409
+ f"{profile.relative_path}: roles[{index}]: "
410
+ "expected min <= recommended <= max"
411
+ )
412
+ if selected is not None and model_refs is not None:
413
+ errors.extend(_selected_count_errors(graph, selected, model_refs))
414
+ _fail(errors)
415
+
416
+
417
+ def _selected_count_errors(
418
+ graph: ContractGraph,
419
+ selected: ContractSelection,
420
+ model_refs: Mapping[str, Sequence[str]],
421
+ ) -> list[str]:
422
+ if selected.profile_id is not None and selected.profile_id in graph.profiles:
423
+ profile = graph.profiles[selected.profile_id]
424
+ return _profile_model_count_errors(profile, model_refs)
425
+ if selected.operation_id is not None and selected.operation_id in graph.operations:
426
+ operation = graph.operations[selected.operation_id]
427
+ duty = graph.duties.get(str(operation.payload["dutyId"]))
428
+ if duty is None:
429
+ return []
430
+ actual = len(model_refs.get(duty.role_id, ()))
431
+ expected = operation.payload["count"]
432
+ if actual != expected:
433
+ return [
434
+ f"{operation.relative_path}: count: assigned model count {actual} "
435
+ f"does not equal {expected}"
436
+ ]
437
+ return []
438
+
439
+
440
+ def _profile_model_count_errors(
441
+ profile: ContractFile, model_refs: Mapping[str, Sequence[str]]
442
+ ) -> list[str]:
443
+ errors: list[str] = []
444
+ for index, requirement in enumerate(profile.payload["roles"]):
445
+ if requirement["mode"] != "static":
446
+ continue
447
+ actual = len(model_refs.get(str(requirement["roleId"]), ()))
448
+ minimum = requirement["min"]
449
+ maximum = requirement["max"]
450
+ if not minimum <= actual <= maximum:
451
+ errors.append(
452
+ f"{profile.relative_path}: roles[{index}]: assigned model count "
453
+ f"{actual} outside [{minimum}, {maximum}]"
454
+ )
455
+ return errors
456
+
457
+
458
+ def _validate_model_diversity(
459
+ graph: ContractGraph, model_refs: Mapping[str, Sequence[str]] | None
460
+ ) -> None:
461
+ if model_refs is None:
462
+ return
463
+ errors = [
464
+ f"agents/roles/{role_id}.json: modelRefs: expected unique model refs"
465
+ for role_id, refs in model_refs.items()
466
+ if role_id in graph.roles and len(refs) != len(set(refs))
467
+ ]
468
+ _fail(errors)
469
+
470
+
471
+ def _validate_selection(
472
+ graph: ContractGraph, selected: ContractSelection | None
473
+ ) -> None:
474
+ if selected is None:
475
+ return
476
+ if (selected.profile_id is None) == (selected.operation_id is None):
477
+ raise ContractGraphError(
478
+ ["<selection>: target: select exactly one profile or operation"]
479
+ )
480
+ if selected.profile_id is not None:
481
+ graph._find(graph.profiles, selected.profile_id, "profile")
482
+ if selected.operation_id is not None:
483
+ graph._find(graph.operations, selected.operation_id, "operation")
484
+
485
+
486
+ def _unique_sorted(rows: Sequence[ContractFile]) -> tuple[ContractFile, ...]:
487
+ by_path = {row.relative_path: row for row in rows}
488
+ return tuple(by_path[path] for path in sorted(by_path))
489
+
490
+
491
+ def _digest(path: Path) -> str:
492
+ return "sha256:" + hashlib.sha256(path.read_bytes()).hexdigest()
493
+
494
+
495
+ def _fail(errors: Sequence[str]) -> None:
496
+ if errors:
497
+ raise ContractGraphError(sorted(errors))
@@ -0,0 +1,62 @@
1
+ """런타임 계약 그래프 검증 명령 진입점."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import json
6
+ from pathlib import Path
7
+ import sys
8
+
9
+ from .contract_graph import (
10
+ ContractFile,
11
+ ContractGraphError,
12
+ ContractGraphRoot,
13
+ ContractGraphValidator,
14
+ ContractSelection,
15
+ )
16
+
17
+
18
+ def _parser() -> argparse.ArgumentParser:
19
+ parser = argparse.ArgumentParser(
20
+ prog="okstra contract-check",
21
+ description="Validate runtime contract documents and dependencies.",
22
+ )
23
+ parser.add_argument("--root", required=True, type=Path)
24
+ selection = parser.add_mutually_exclusive_group()
25
+ selection.add_argument("--profile")
26
+ selection.add_argument("--operation")
27
+ return parser
28
+
29
+
30
+ def _record(row: ContractFile) -> dict[str, str]:
31
+ return {
32
+ "path": row.relative_path,
33
+ "schemaVersion": row.schema_version,
34
+ "sha256": row.sha256,
35
+ }
36
+
37
+
38
+ def main(argv: list[str] | None = None) -> int:
39
+ args = _parser().parse_args(argv)
40
+ selected = None
41
+ if args.profile is not None or args.operation is not None:
42
+ selected = ContractSelection(args.profile, args.operation)
43
+ try:
44
+ graph = ContractGraphValidator().validate(
45
+ ContractGraphRoot.from_base(args.root), selected=selected
46
+ )
47
+ if args.profile is not None:
48
+ rows = graph.dependencies_for_profile(args.profile)
49
+ elif args.operation is not None:
50
+ rows = graph.dependencies_for_operation(args.operation)
51
+ else:
52
+ rows = graph.files
53
+ except ContractGraphError as exc:
54
+ for error in exc.errors:
55
+ print(error, file=sys.stderr)
56
+ return 1
57
+ print(json.dumps([_record(row) for row in rows], indent=2))
58
+ return 0
59
+
60
+
61
+ if __name__ == "__main__":
62
+ raise SystemExit(main())