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.
- package/dist/cli-registry.mjs +16 -2
- package/dist/cli-registry.mjs.map +1 -1
- package/dist/commands/execute/render-bundle.d.mts +4 -2
- package/dist/commands/execute/render-bundle.mjs +46 -5
- package/dist/commands/execute/render-bundle.mjs.map +1 -1
- package/dist/commands/execute/run.mjs +11 -3
- package/dist/commands/execute/run.mjs.map +1 -1
- package/dist/commands/inspect/model-io.d.mts +1 -0
- package/dist/commands/inspect/model-io.mjs +25 -0
- package/dist/commands/inspect/model-io.mjs.map +1 -0
- package/dist/commands/inspect/stage-map.mjs +29 -8
- package/dist/commands/inspect/stage-map.mjs.map +1 -1
- package/dist/commands/inspect/task-list.mjs +52 -6
- package/dist/commands/inspect/task-list.mjs.map +1 -1
- package/dist/commands/inspect/user-response.mjs +14 -4
- package/dist/commands/inspect/user-response.mjs.map +1 -1
- package/dist/commands/lifecycle/check-project.d.mts +1 -0
- package/dist/commands/lifecycle/check-project.mjs +69 -50
- package/dist/commands/lifecycle/check-project.mjs.map +1 -1
- package/dist/commands/lifecycle/contract-check.d.mts +1 -0
- package/dist/commands/lifecycle/contract-check.mjs +18 -0
- package/dist/commands/lifecycle/contract-check.mjs.map +1 -0
- package/dist/commands/lifecycle/preflight.mjs +154 -51
- package/dist/commands/lifecycle/preflight.mjs.map +1 -1
- package/dist/commands/pr/pr.d.mts +1 -0
- package/dist/commands/pr/pr.mjs +19 -1
- package/dist/commands/pr/pr.mjs.map +1 -1
- package/dist/commands/report/agent-activity.mjs +2 -2
- package/dist/commands/report/translate.mjs +3 -0
- package/dist/commands/report/translate.mjs.map +1 -1
- package/dist/lib/host-registry-client.mjs +13 -9
- package/dist/lib/host-registry-client.mjs.map +1 -1
- package/docs/architecture.md +11 -0
- package/docs/cli.md +26 -13
- package/docs/container.md +6 -4
- package/docs/contributor-change-matrix.md +1 -1
- package/docs/for-ai/README.md +2 -2
- package/docs/for-ai/skills/okstra-brief-gen.md +5 -3
- package/docs/for-ai/skills/okstra-code-review.md +4 -4
- package/docs/for-ai/skills/okstra-container-build.md +20 -17
- package/docs/for-ai/skills/okstra-inspect.md +20 -23
- package/docs/for-ai/skills/okstra-manager.md +19 -18
- package/docs/for-ai/skills/okstra-memory.md +2 -2
- package/docs/for-ai/skills/okstra-pr-gen.md +3 -3
- package/docs/for-ai/skills/okstra-rollup.md +14 -13
- package/docs/for-ai/skills/okstra-run.md +7 -3
- package/docs/for-ai/skills/okstra-schedule-gen.md +15 -18
- package/docs/for-ai/skills/okstra-setup.md +7 -7
- package/docs/for-ai/skills/okstra-usage.md +5 -4
- package/docs/for-ai/skills/okstra-user-response.md +50 -32
- package/docs/project-structure-overview.md +29 -26
- package/docs/task-process/implementation.md +7 -4
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/agents/workers/claude-worker.md +4 -4
- package/runtime/agents/workers/report-writer-worker.md +3 -3
- package/runtime/agents/workers/translator-worker.md +5 -13
- package/runtime/bin/okstra-error-log.py +51 -11
- package/runtime/bin/okstra-report-translate.py +210 -23
- package/runtime/prompts/host-orchestration/implementation.md +1 -1
- package/runtime/prompts/launch.template.md +3 -7
- package/runtime/prompts/lead/context-loader.md +41 -141
- package/runtime/prompts/lead/convergence.md +8 -6
- package/runtime/prompts/lead/okstra-lead-contract.md +25 -35
- package/runtime/prompts/lead/plan-body-verification.md +187 -14
- package/runtime/prompts/lead/report-writer.md +20 -1
- package/runtime/prompts/lead/team-contract.md +8 -53
- package/runtime/prompts/profiles/_coding-conventions-preflight.md +3 -2
- package/runtime/prompts/profiles/_common-contract.md +1 -1
- package/runtime/prompts/profiles/_implementation-diff-review.md +1 -1
- package/runtime/prompts/profiles/_implementation-verifier.md +3 -3
- package/runtime/prompts/profiles/final-verification.md +1 -1
- package/runtime/prompts/profiles/implementation-planning.md +8 -2
- package/runtime/prompts/profiles/release-handoff.md +0 -1
- package/runtime/prompts/wizard/prompts.ko.json +0 -1
- package/runtime/python/okstra_ctl/adapters/hosts/capability_adapter.py +69 -17
- package/runtime/python/okstra_ctl/adapters/hosts/claude-code/adapter.py +13 -4
- package/runtime/python/okstra_ctl/adapters/hosts/claude-code/relay.md +6 -1
- package/runtime/python/okstra_ctl/adapters/hosts/codex/adapter.py +2 -2
- package/runtime/python/okstra_ctl/adapters/hosts/codex/relay.md +50 -5
- package/runtime/python/okstra_ctl/adapters/hosts/grok/adapter.py +2 -2
- package/runtime/python/okstra_ctl/adapters/hosts/grok/relay.md +66 -5
- package/runtime/python/okstra_ctl/adapters/providers/grok/adapter.py +70 -2
- package/runtime/python/okstra_ctl/agent_activity.py +118 -35
- package/runtime/python/okstra_ctl/agent_invocation.py +19 -6
- package/runtime/python/okstra_ctl/agent_prompt_cli.py +65 -18
- package/runtime/python/okstra_ctl/analysis_inputs.py +5 -4
- package/runtime/python/okstra_ctl/analysis_packet.py +81 -1
- package/runtime/python/okstra_ctl/approval_decisions.py +3 -2
- package/runtime/python/okstra_ctl/attempt_evidence.py +2 -2
- package/runtime/python/okstra_ctl/backfill.py +13 -10
- package/runtime/python/okstra_ctl/batch.py +2 -4
- package/runtime/python/okstra_ctl/build_tools.py +6 -3
- package/runtime/python/okstra_ctl/claim_reproduction.py +101 -0
- package/runtime/python/okstra_ctl/clarification_items.py +27 -13
- package/runtime/python/okstra_ctl/cmux.py +130 -52
- package/runtime/python/okstra_ctl/code_review_target.py +34 -8
- package/runtime/python/okstra_ctl/consumers.py +5 -4
- package/runtime/python/okstra_ctl/container.py +103 -8
- package/runtime/python/okstra_ctl/context_cost.py +2 -1
- package/runtime/python/okstra_ctl/contract_graph.py +497 -0
- package/runtime/python/okstra_ctl/contract_graph_cli.py +62 -0
- package/runtime/python/okstra_ctl/convergence.py +338 -17
- package/runtime/python/okstra_ctl/convergence_engine.py +10 -18
- package/runtime/python/okstra_ctl/convergence_provenance.py +58 -8
- package/runtime/python/okstra_ctl/convergence_store.py +55 -34
- package/runtime/python/okstra_ctl/design_prep.py +7 -4
- package/runtime/python/okstra_ctl/dispatch_core.py +35 -65
- package/runtime/python/okstra_ctl/dispatch_state.py +134 -59
- package/runtime/python/okstra_ctl/doctor.py +6 -3
- package/runtime/python/okstra_ctl/domain/worker_presentation.py +70 -9
- package/runtime/python/okstra_ctl/entrypoints/hosts.py +16 -30
- package/runtime/python/okstra_ctl/error_log_write.py +35 -30
- package/runtime/python/okstra_ctl/error_report.py +26 -1
- package/runtime/python/okstra_ctl/error_zip.py +27 -5
- package/runtime/python/okstra_ctl/execution_identity.py +3 -2
- package/runtime/python/okstra_ctl/execution_manifest.py +7 -4
- package/runtime/python/okstra_ctl/final_report_schema.py +2 -2
- package/runtime/python/okstra_ctl/fix_cycles.py +2 -2
- package/runtime/python/okstra_ctl/fixed_text.py +39 -0
- package/runtime/python/okstra_ctl/git_reconcile.py +41 -9
- package/runtime/python/okstra_ctl/handoff.py +5 -4
- package/runtime/python/okstra_ctl/i18n.py +4 -2
- package/runtime/python/okstra_ctl/implementation_direction.py +22 -14
- package/runtime/python/okstra_ctl/implementation_outcome.py +4 -7
- package/runtime/python/okstra_ctl/incremental_carry.py +2 -1
- package/runtime/python/okstra_ctl/incremental_scope.py +8 -3
- package/runtime/python/okstra_ctl/index.py +8 -11
- package/runtime/python/okstra_ctl/initial_prompt_materialization.py +79 -7
- package/runtime/python/okstra_ctl/invocation.py +3 -6
- package/runtime/python/okstra_ctl/json_boundary.py +366 -0
- package/runtime/python/okstra_ctl/json_registry.py +10 -12
- package/runtime/python/okstra_ctl/jsonl.py +19 -2
- package/runtime/python/okstra_ctl/lead_events.py +33 -1
- package/runtime/python/okstra_ctl/listing.py +3 -3
- package/runtime/python/okstra_ctl/log_report.py +24 -2
- package/runtime/python/okstra_ctl/manager_cli.py +92 -7
- package/runtime/python/okstra_ctl/manager_store.py +12 -10
- package/runtime/python/okstra_ctl/material.py +5 -1
- package/runtime/python/okstra_ctl/migrate.py +29 -25
- package/runtime/python/okstra_ctl/model_cli.py +3 -15
- package/runtime/python/okstra_ctl/model_io_cli.py +1051 -0
- package/runtime/python/okstra_ctl/mutation_probe.py +13 -4
- package/runtime/python/okstra_ctl/pane_reclaim.py +3 -2
- package/runtime/python/okstra_ctl/paths.py +9 -0
- package/runtime/python/okstra_ctl/plan_items.py +115 -4
- package/runtime/python/okstra_ctl/plan_items_cli.py +513 -18
- package/runtime/python/okstra_ctl/pr_template.py +3 -2
- package/runtime/python/okstra_ctl/project_meta.py +5 -7
- package/runtime/python/okstra_ctl/recap.py +5 -4
- package/runtime/python/okstra_ctl/reconcile.py +21 -27
- package/runtime/python/okstra_ctl/registry/host_discovery.py +3 -2
- package/runtime/python/okstra_ctl/registry/provider_registry.py +3 -2
- package/runtime/python/okstra_ctl/render.py +26 -15
- package/runtime/python/okstra_ctl/render_final_report.py +3 -2
- package/runtime/python/okstra_ctl/report_assembly.py +172 -17
- package/runtime/python/okstra_ctl/report_finalize.py +7 -10
- package/runtime/python/okstra_ctl/report_html/render.py +3 -2
- package/runtime/python/okstra_ctl/report_language.py +3 -2
- package/runtime/python/okstra_ctl/report_markdown.py +13 -1
- package/runtime/python/okstra_ctl/report_narrative.py +40 -8
- package/runtime/python/okstra_ctl/report_synthesis_packet.py +518 -0
- package/runtime/python/okstra_ctl/report_views.py +3 -2
- package/runtime/python/okstra_ctl/rollup.py +65 -4
- package/runtime/python/okstra_ctl/run.py +159 -56
- package/runtime/python/okstra_ctl/run_audit.py +3 -2
- package/runtime/python/okstra_ctl/run_context.py +6 -9
- package/runtime/python/okstra_ctl/run_index_row.py +2 -8
- package/runtime/python/okstra_ctl/schedule_semantics.py +5 -2
- package/runtime/python/okstra_ctl/schema_excerpt.py +4 -2
- package/runtime/python/okstra_ctl/session_transcript.py +27 -1
- package/runtime/python/okstra_ctl/set_work_status.py +64 -38
- package/runtime/python/okstra_ctl/stage_fix_carry.py +4 -2
- package/runtime/python/okstra_ctl/stage_map.py +26 -6
- package/runtime/python/okstra_ctl/stage_targets.py +3 -4
- package/runtime/python/okstra_ctl/team.py +2 -1
- package/runtime/python/okstra_ctl/team_reconcile.py +11 -2
- package/runtime/python/okstra_ctl/time_report.py +51 -4
- package/runtime/python/okstra_ctl/usage_identity.py +2 -1
- package/runtime/python/okstra_ctl/usage_report.py +58 -4
- package/runtime/python/okstra_ctl/user_response.py +1431 -66
- package/runtime/python/okstra_ctl/wizard.py +11 -44
- package/runtime/python/okstra_ctl/work_categories.py +3 -2
- package/runtime/python/okstra_ctl/worker_prompt_body.py +18 -7
- package/runtime/python/okstra_ctl/worker_prompt_contract.py +3 -2
- package/runtime/python/okstra_ctl/worker_runner.py +14 -12
- package/runtime/python/okstra_ctl/workflow.py +2 -1
- package/runtime/python/okstra_ctl/worktree.py +3 -2
- package/runtime/python/okstra_ctl/wrapper_status.py +4 -2
- package/runtime/python/okstra_ctl/write_policy.py +4 -2
- package/runtime/python/okstra_token_usage/antigravity.py +39 -12
- package/runtime/python/okstra_token_usage/collect.py +90 -38
- package/runtime/python/okstra_token_usage/grok.py +127 -0
- package/runtime/schemas/final-report-v2.0.schema.json +9 -0
- package/runtime/schemas/final-report-v3.0.schema.json +9 -0
- package/runtime/schemas/report-synthesis-packet-v1.0.schema.json +140 -0
- package/runtime/skills/okstra-brief-gen/SKILL.md +9 -7
- package/runtime/skills/okstra-code-review/SKILL.md +21 -11
- package/runtime/skills/okstra-container-build/SKILL.md +18 -18
- package/runtime/skills/okstra-inspect/SKILL.md +12 -11
- package/runtime/skills/okstra-inspect/facets/error-zip.md +8 -8
- package/runtime/skills/okstra-inspect/facets/errors.md +2 -2
- package/runtime/skills/okstra-inspect/facets/history.md +9 -14
- package/runtime/skills/okstra-inspect/facets/logs.md +2 -2
- package/runtime/skills/okstra-inspect/facets/recap.md +5 -5
- package/runtime/skills/okstra-inspect/facets/report.md +6 -10
- package/runtime/skills/okstra-inspect/facets/status.md +9 -8
- package/runtime/skills/okstra-inspect/facets/time.md +3 -3
- package/runtime/skills/okstra-manager/SKILL.md +16 -14
- package/runtime/skills/okstra-memory/SKILL.md +3 -3
- package/runtime/skills/okstra-pr-gen/SKILL.md +5 -4
- package/runtime/skills/okstra-rollup/SKILL.md +6 -16
- package/runtime/skills/okstra-run/SKILL.md +7 -7
- package/runtime/skills/okstra-schedule-gen/SKILL.md +21 -17
- package/runtime/skills/okstra-setup/SKILL.md +21 -13
- package/runtime/skills/okstra-setup/references/project-config.md +2 -2
- package/runtime/skills/okstra-usage/SKILL.md +10 -10
- package/runtime/skills/okstra-user-response/SKILL.md +78 -107
- package/runtime/templates/report-writer-prompt-preamble.md +17 -1
- package/runtime/templates/reports/schedule.template.md +4 -4
- package/runtime/templates/worker-error-contract.md +17 -29
- package/runtime/validators/validate-run.py +379 -63
- 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())
|