okstra 0.179.2 → 0.183.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/cli-registry.mjs +30 -2
- package/dist/cli-registry.mjs.map +1 -1
- package/dist/commands/execute/incremental-carry.mjs +9 -8
- package/dist/commands/execute/incremental-carry.mjs.map +1 -1
- package/dist/commands/execute/plan-verify.mjs +3 -1
- package/dist/commands/execute/plan-verify.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/approval-decision.d.mts +1 -0
- package/dist/commands/report/approval-decision.mjs +21 -0
- package/dist/commands/report/approval-decision.mjs.map +1 -0
- package/dist/commands/report/design-snapshot.d.mts +1 -0
- package/dist/commands/report/design-snapshot.mjs +19 -0
- package/dist/commands/report/design-snapshot.mjs.map +1 -0
- 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/storage-model.md +1 -1
- package/docs/architecture.md +21 -10
- package/docs/cli.md +36 -20
- 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 +43 -31
- package/docs/task-process/implementation-planning.md +2 -2
- 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 +15 -164
- 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 +9 -12
- package/runtime/prompts/lead/adapters/cmux.md +1 -1
- package/runtime/prompts/lead/context-loader.md +41 -141
- package/runtime/prompts/lead/convergence.md +10 -8
- package/runtime/prompts/lead/okstra-lead-contract.md +40 -49
- package/runtime/prompts/lead/plan-body-verification.md +217 -23
- package/runtime/prompts/lead/report-writer.md +81 -421
- package/runtime/prompts/lead/team-contract.md +9 -54
- package/runtime/prompts/profiles/_clarification-recommendation.md +5 -4
- package/runtime/prompts/profiles/_coding-conventions-preflight.md +3 -2
- package/runtime/prompts/profiles/_common-contract.md +4 -4
- package/runtime/prompts/profiles/_implementation-deliverable.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/change-impact-analysis.md +1 -1
- package/runtime/prompts/profiles/error-analysis.md +1 -1
- package/runtime/prompts/profiles/feature-analysis.md +1 -1
- package/runtime/prompts/profiles/final-verification.md +1 -1
- package/runtime/prompts/profiles/implementation-planning.md +20 -12
- package/runtime/prompts/profiles/improvement-discovery.md +1 -1
- package/runtime/prompts/profiles/project-analysis.md +1 -1
- package/runtime/prompts/profiles/release-handoff.md +0 -1
- package/runtime/prompts/profiles/requirements-discovery.md +1 -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 +8 -2
- 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/external/relay.md +1 -1
- 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 +141 -38
- package/runtime/python/okstra_ctl/agent_invocation.py +19 -6
- package/runtime/python/okstra_ctl/agent_prompt_cli.py +70 -23
- package/runtime/python/okstra_ctl/analysis_inputs.py +5 -4
- package/runtime/python/okstra_ctl/analysis_packet.py +124 -3
- package/runtime/python/okstra_ctl/approval_decisions.py +328 -0
- 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/design_snapshot.py +134 -0
- package/runtime/python/okstra_ctl/dispatch_core.py +97 -69
- package/runtime/python/okstra_ctl/dispatch_state.py +163 -63
- 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/execution_mutation_audit.py +6 -2
- package/runtime/python/okstra_ctl/final_report_schema.py +26 -17
- 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 +130 -17
- package/runtime/python/okstra_ctl/incremental_scope.py +12 -4
- 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/path_hints.py +12 -0
- package/runtime/python/okstra_ctl/paths.py +21 -0
- package/runtime/python/okstra_ctl/plan_items.py +115 -4
- package/runtime/python/okstra_ctl/plan_items_cli.py +626 -34
- package/runtime/python/okstra_ctl/ports/worker_dispatch.py +2 -1
- 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 +74 -16
- package/runtime/python/okstra_ctl/render_final_report.py +10 -8
- package/runtime/python/okstra_ctl/report_assembly.py +509 -0
- package/runtime/python/okstra_ctl/report_contract.py +2 -1
- package/runtime/python/okstra_ctl/report_finalize.py +67 -32
- package/runtime/python/okstra_ctl/report_html/render.py +3 -2
- package/runtime/python/okstra_ctl/report_inputs.py +72 -0
- package/runtime/python/okstra_ctl/report_language.py +3 -2
- package/runtime/python/okstra_ctl/report_markdown.py +81 -8
- package/runtime/python/okstra_ctl/report_narrative.py +351 -0
- package/runtime/python/okstra_ctl/report_projections.py +265 -0
- 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 +184 -65
- 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 +15 -8
- 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 +8 -6
- package/runtime/python/okstra_ctl/stage_ledger.py +132 -18
- package/runtime/python/okstra_ctl/stage_map.py +96 -28
- package/runtime/python/okstra_ctl/stage_targets.py +3 -4
- package/runtime/python/okstra_ctl/team.py +3 -2
- 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_dispatch.py +5 -2
- package/runtime/python/okstra_ctl/worker_prompt_body.py +49 -3
- package/runtime/python/okstra_ctl/worker_prompt_contract.py +3 -2
- package/runtime/python/okstra_ctl/worker_prompt_policy.py +31 -3
- 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 +10219 -0
- package/runtime/schemas/report-narrative-v3.0.schema.json +30 -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 +29 -19
- package/runtime/templates/reports/html/macros/forms.html +6 -4
- package/runtime/templates/reports/schedule.template.md +4 -4
- package/runtime/templates/worker-error-contract.md +17 -29
- package/runtime/validators/validate-run.py +636 -72
- package/runtime/validators/validate_session_conformance.py +43 -7
|
@@ -28,6 +28,8 @@ from typing import Callable, Iterable
|
|
|
28
28
|
|
|
29
29
|
from .stage_targets import PrepareError # 단일 PrepareError 타입 재노출(run.py 와 공유)
|
|
30
30
|
from .stage_map import StageMapError, parse_stage_map_file, stage_map_records
|
|
31
|
+
from .json_boundary import JsonBoundaryError, load_owned_object, write_owned_object_atomic
|
|
32
|
+
from .fixed_text import line
|
|
31
33
|
|
|
32
34
|
OKSTRA_LABEL_KEYS = ("okstra.task-key", "okstra.project-name", "okstra.run-trace")
|
|
33
35
|
|
|
@@ -426,13 +428,19 @@ def query_containers_by_label(project_name: str) -> list[dict]:
|
|
|
426
428
|
out = _docker_run([
|
|
427
429
|
"docker", "ps", "-a", "--filter",
|
|
428
430
|
f"label=okstra.project-name={project_name}",
|
|
429
|
-
"--format",
|
|
431
|
+
"--format", (
|
|
432
|
+
"{{.ID}}\t{{.Names}}\t{{.State}}\t"
|
|
433
|
+
'{{.Label "com.docker.compose.service"}}\t{{.Ports}}'
|
|
434
|
+
),
|
|
430
435
|
])
|
|
431
436
|
rows: list[dict] = []
|
|
432
437
|
for line in out.splitlines():
|
|
433
438
|
parts = line.split("\t")
|
|
434
|
-
if len(parts) >=
|
|
435
|
-
rows.append({
|
|
439
|
+
if len(parts) >= 5:
|
|
440
|
+
rows.append({
|
|
441
|
+
"id": parts[0], "name": parts[1], "state": parts[2],
|
|
442
|
+
"service": parts[3], "ports": parts[4],
|
|
443
|
+
})
|
|
436
444
|
return rows
|
|
437
445
|
|
|
438
446
|
|
|
@@ -774,8 +782,7 @@ def _spawn_service_panes(
|
|
|
774
782
|
|
|
775
783
|
def _write_deploy_state(cp: dict, **state) -> None:
|
|
776
784
|
cp["container_dir"].mkdir(parents=True, exist_ok=True)
|
|
777
|
-
cp["deploy_state"]
|
|
778
|
-
json.dumps(state, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
785
|
+
write_owned_object_atomic(cp["deploy_state"], state, artifact="container deploy state")
|
|
779
786
|
|
|
780
787
|
|
|
781
788
|
# --------------------------------------------------------------------------- #
|
|
@@ -879,8 +886,10 @@ def _read_project_id(project_json: Path) -> str:
|
|
|
879
886
|
if not project_json.is_file():
|
|
880
887
|
return ""
|
|
881
888
|
try:
|
|
882
|
-
return
|
|
883
|
-
|
|
889
|
+
return load_owned_object(project_json, artifact="project configuration").get(
|
|
890
|
+
"projectId", ""
|
|
891
|
+
)
|
|
892
|
+
except JsonBoundaryError:
|
|
884
893
|
return ""
|
|
885
894
|
|
|
886
895
|
|
|
@@ -928,6 +937,7 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
928
937
|
sp = sub.add_parser(name)
|
|
929
938
|
sp.add_argument("--project-root", required=True)
|
|
930
939
|
sp.add_argument("--task-key", default="")
|
|
940
|
+
sp.add_argument("--text", action="store_true")
|
|
931
941
|
if name == "down":
|
|
932
942
|
sp.add_argument("--all", action="store_true")
|
|
933
943
|
if name == "logs":
|
|
@@ -944,10 +954,95 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
944
954
|
findings_path=args.findings, scan_interval_seconds=args.scan_interval)
|
|
945
955
|
return 0
|
|
946
956
|
result = _dispatch(args)
|
|
947
|
-
print(
|
|
957
|
+
print(
|
|
958
|
+
render_container_text(args.command, result)
|
|
959
|
+
if args.text
|
|
960
|
+
else json.dumps(result, ensure_ascii=False, indent=2)
|
|
961
|
+
)
|
|
948
962
|
return 0
|
|
949
963
|
|
|
950
964
|
|
|
965
|
+
def render_container_text(command: str, payload: dict) -> str:
|
|
966
|
+
"""container 명령별 승인된 상태만 고정 순서로 투영한다."""
|
|
967
|
+
if command not in {"up", "status", "logs", "stop-watcher", "down"}:
|
|
968
|
+
raise ValueError("unknown container text purpose")
|
|
969
|
+
rows = [f"Okstra container {command}\n", line("Status", "ready")]
|
|
970
|
+
for label, key in (("Project name", "projectName"), ("Watchers dir", "watchersDir"),
|
|
971
|
+
("Note", "note")):
|
|
972
|
+
if key in payload:
|
|
973
|
+
rows.append(line(label, payload.get(key)))
|
|
974
|
+
if command == "up":
|
|
975
|
+
rows.extend(_render_container_up(payload))
|
|
976
|
+
elif command in {"status", "logs"}:
|
|
977
|
+
rows.extend(_render_container_watchers(payload))
|
|
978
|
+
if command == "status":
|
|
979
|
+
rows.extend(_render_container_status(payload))
|
|
980
|
+
elif command == "stop-watcher":
|
|
981
|
+
rows.extend(_render_reaped_panes("Reaped pane", payload.get("reapedPanes")))
|
|
982
|
+
else:
|
|
983
|
+
rows.extend(_render_container_down(payload))
|
|
984
|
+
return "".join(rows)
|
|
985
|
+
|
|
986
|
+
|
|
987
|
+
def _render_container_up(payload: dict) -> list[str]:
|
|
988
|
+
rows: list[str] = []
|
|
989
|
+
for index, service in enumerate(payload.get("services") or [], 1):
|
|
990
|
+
rows.append(line(f"Service {index} name", service))
|
|
991
|
+
for index, warning in enumerate(payload.get("warnings") or [], 1):
|
|
992
|
+
rows.append(line(f"Warning {index} text", warning))
|
|
993
|
+
watch = payload.get("watch") if isinstance(payload.get("watch"), dict) else {}
|
|
994
|
+
for label, key in (("Watcher enabled", "enabled"), ("Watcher session", "session"),
|
|
995
|
+
("Watcher note", "note")):
|
|
996
|
+
if key in watch:
|
|
997
|
+
rows.append(line(label, watch.get(key)))
|
|
998
|
+
for index, service in enumerate(watch.get("services") or [], 1):
|
|
999
|
+
rows.append(line(f"Watcher service {index} name", service))
|
|
1000
|
+
return rows
|
|
1001
|
+
|
|
1002
|
+
|
|
1003
|
+
def _render_container_watchers(payload: dict) -> list[str]:
|
|
1004
|
+
watchers = payload.get("watchers") if isinstance(payload.get("watchers"), dict) else {}
|
|
1005
|
+
rows: list[str] = []
|
|
1006
|
+
for index, (service, metadata) in enumerate(sorted(watchers.items()), 1):
|
|
1007
|
+
rows.append(line(f"Watcher {index} service", service))
|
|
1008
|
+
values = metadata if isinstance(metadata, dict) else {}
|
|
1009
|
+
for label, key in (("pane ID", "paneId"), ("session", "sessionName"),
|
|
1010
|
+
("findings path", "findingsPath")):
|
|
1011
|
+
if key in values:
|
|
1012
|
+
rows.append(line(f"Watcher {index} {label}", values.get(key)))
|
|
1013
|
+
return rows
|
|
1014
|
+
|
|
1015
|
+
|
|
1016
|
+
def _render_container_status(payload: dict) -> list[str]:
|
|
1017
|
+
containers = payload.get("containers") if isinstance(payload.get("containers"), list) else []
|
|
1018
|
+
rows: list[str] = []
|
|
1019
|
+
for index, item in enumerate(containers, 1):
|
|
1020
|
+
values = item if isinstance(item, dict) else {}
|
|
1021
|
+
for label, key in (("name", "name"), ("service", "service"), ("state", "state"),
|
|
1022
|
+
("status", "status"), ("ports", "ports")):
|
|
1023
|
+
if key in values:
|
|
1024
|
+
rows.append(line(f"Container {index} {label}", values.get(key)))
|
|
1025
|
+
return rows
|
|
1026
|
+
|
|
1027
|
+
|
|
1028
|
+
def _render_reaped_panes(prefix: str, raw_panes: object) -> list[str]:
|
|
1029
|
+
panes = raw_panes if isinstance(raw_panes, list) else []
|
|
1030
|
+
return [line(f"{prefix} {index}", pane) for index, pane in enumerate(panes, 1)]
|
|
1031
|
+
|
|
1032
|
+
|
|
1033
|
+
def _render_container_down(payload: dict) -> list[str]:
|
|
1034
|
+
downed = payload.get("downed") if isinstance(payload.get("downed"), list) else []
|
|
1035
|
+
rows: list[str] = []
|
|
1036
|
+
for index, item in enumerate(downed, 1):
|
|
1037
|
+
values = item if isinstance(item, dict) else {}
|
|
1038
|
+
rows.append(line(f"Downed {index} project name", values.get("projectName")))
|
|
1039
|
+
rows.extend(_render_reaped_panes(
|
|
1040
|
+
f"Downed {index} reaped pane", values.get("reapedPanes")
|
|
1041
|
+
))
|
|
1042
|
+
rows.extend(_render_reaped_panes("Orphan pane", payload.get("orphanPanesReaped")))
|
|
1043
|
+
return rows
|
|
1044
|
+
|
|
1045
|
+
|
|
951
1046
|
def _dispatch(args) -> dict:
|
|
952
1047
|
pr = args.project_root
|
|
953
1048
|
if args.command == "up":
|
|
@@ -15,6 +15,7 @@ from typing import Iterable
|
|
|
15
15
|
|
|
16
16
|
from okstra_ctl.paths import RunRef, runs_dir_of, task_manifest_file
|
|
17
17
|
from okstra_ctl.task_target import resolve_task_root, project_rel
|
|
18
|
+
from okstra_ctl.json_boundary import load_owned_object
|
|
18
19
|
|
|
19
20
|
|
|
20
21
|
INPUT_FILES = (
|
|
@@ -83,7 +84,7 @@ def _load_json(path: Path) -> dict:
|
|
|
83
84
|
if not path.is_file():
|
|
84
85
|
return {}
|
|
85
86
|
try:
|
|
86
|
-
data =
|
|
87
|
+
data = load_owned_object(path, artifact="context cost input")
|
|
87
88
|
except Exception:
|
|
88
89
|
return {}
|
|
89
90
|
return data if isinstance(data, dict) else {}
|
|
@@ -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))
|