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.
Files changed (258) hide show
  1. package/README.md +1 -1
  2. package/dist/cli-registry.mjs +30 -2
  3. package/dist/cli-registry.mjs.map +1 -1
  4. package/dist/commands/execute/incremental-carry.mjs +9 -8
  5. package/dist/commands/execute/incremental-carry.mjs.map +1 -1
  6. package/dist/commands/execute/plan-verify.mjs +3 -1
  7. package/dist/commands/execute/plan-verify.mjs.map +1 -1
  8. package/dist/commands/execute/render-bundle.d.mts +4 -2
  9. package/dist/commands/execute/render-bundle.mjs +46 -5
  10. package/dist/commands/execute/render-bundle.mjs.map +1 -1
  11. package/dist/commands/execute/run.mjs +11 -3
  12. package/dist/commands/execute/run.mjs.map +1 -1
  13. package/dist/commands/inspect/model-io.d.mts +1 -0
  14. package/dist/commands/inspect/model-io.mjs +25 -0
  15. package/dist/commands/inspect/model-io.mjs.map +1 -0
  16. package/dist/commands/inspect/stage-map.mjs +29 -8
  17. package/dist/commands/inspect/stage-map.mjs.map +1 -1
  18. package/dist/commands/inspect/task-list.mjs +52 -6
  19. package/dist/commands/inspect/task-list.mjs.map +1 -1
  20. package/dist/commands/inspect/user-response.mjs +14 -4
  21. package/dist/commands/inspect/user-response.mjs.map +1 -1
  22. package/dist/commands/lifecycle/check-project.d.mts +1 -0
  23. package/dist/commands/lifecycle/check-project.mjs +69 -50
  24. package/dist/commands/lifecycle/check-project.mjs.map +1 -1
  25. package/dist/commands/lifecycle/contract-check.d.mts +1 -0
  26. package/dist/commands/lifecycle/contract-check.mjs +18 -0
  27. package/dist/commands/lifecycle/contract-check.mjs.map +1 -0
  28. package/dist/commands/lifecycle/preflight.mjs +154 -51
  29. package/dist/commands/lifecycle/preflight.mjs.map +1 -1
  30. package/dist/commands/pr/pr.d.mts +1 -0
  31. package/dist/commands/pr/pr.mjs +19 -1
  32. package/dist/commands/pr/pr.mjs.map +1 -1
  33. package/dist/commands/report/agent-activity.mjs +2 -2
  34. package/dist/commands/report/approval-decision.d.mts +1 -0
  35. package/dist/commands/report/approval-decision.mjs +21 -0
  36. package/dist/commands/report/approval-decision.mjs.map +1 -0
  37. package/dist/commands/report/design-snapshot.d.mts +1 -0
  38. package/dist/commands/report/design-snapshot.mjs +19 -0
  39. package/dist/commands/report/design-snapshot.mjs.map +1 -0
  40. package/dist/commands/report/translate.mjs +3 -0
  41. package/dist/commands/report/translate.mjs.map +1 -1
  42. package/dist/lib/host-registry-client.mjs +13 -9
  43. package/dist/lib/host-registry-client.mjs.map +1 -1
  44. package/docs/architecture/storage-model.md +1 -1
  45. package/docs/architecture.md +21 -10
  46. package/docs/cli.md +36 -20
  47. package/docs/container.md +6 -4
  48. package/docs/contributor-change-matrix.md +1 -1
  49. package/docs/for-ai/README.md +2 -2
  50. package/docs/for-ai/skills/okstra-brief-gen.md +5 -3
  51. package/docs/for-ai/skills/okstra-code-review.md +4 -4
  52. package/docs/for-ai/skills/okstra-container-build.md +20 -17
  53. package/docs/for-ai/skills/okstra-inspect.md +20 -23
  54. package/docs/for-ai/skills/okstra-manager.md +19 -18
  55. package/docs/for-ai/skills/okstra-memory.md +2 -2
  56. package/docs/for-ai/skills/okstra-pr-gen.md +3 -3
  57. package/docs/for-ai/skills/okstra-rollup.md +14 -13
  58. package/docs/for-ai/skills/okstra-run.md +7 -3
  59. package/docs/for-ai/skills/okstra-schedule-gen.md +15 -18
  60. package/docs/for-ai/skills/okstra-setup.md +7 -7
  61. package/docs/for-ai/skills/okstra-usage.md +5 -4
  62. package/docs/for-ai/skills/okstra-user-response.md +50 -32
  63. package/docs/project-structure-overview.md +43 -31
  64. package/docs/task-process/implementation-planning.md +2 -2
  65. package/docs/task-process/implementation.md +7 -4
  66. package/package.json +1 -1
  67. package/runtime/BUILD.json +2 -2
  68. package/runtime/agents/workers/claude-worker.md +4 -4
  69. package/runtime/agents/workers/report-writer-worker.md +15 -164
  70. package/runtime/agents/workers/translator-worker.md +5 -13
  71. package/runtime/bin/okstra-error-log.py +51 -11
  72. package/runtime/bin/okstra-report-translate.py +210 -23
  73. package/runtime/prompts/host-orchestration/implementation.md +1 -1
  74. package/runtime/prompts/launch.template.md +9 -12
  75. package/runtime/prompts/lead/adapters/cmux.md +1 -1
  76. package/runtime/prompts/lead/context-loader.md +41 -141
  77. package/runtime/prompts/lead/convergence.md +10 -8
  78. package/runtime/prompts/lead/okstra-lead-contract.md +40 -49
  79. package/runtime/prompts/lead/plan-body-verification.md +217 -23
  80. package/runtime/prompts/lead/report-writer.md +81 -421
  81. package/runtime/prompts/lead/team-contract.md +9 -54
  82. package/runtime/prompts/profiles/_clarification-recommendation.md +5 -4
  83. package/runtime/prompts/profiles/_coding-conventions-preflight.md +3 -2
  84. package/runtime/prompts/profiles/_common-contract.md +4 -4
  85. package/runtime/prompts/profiles/_implementation-deliverable.md +1 -1
  86. package/runtime/prompts/profiles/_implementation-diff-review.md +1 -1
  87. package/runtime/prompts/profiles/_implementation-verifier.md +3 -3
  88. package/runtime/prompts/profiles/change-impact-analysis.md +1 -1
  89. package/runtime/prompts/profiles/error-analysis.md +1 -1
  90. package/runtime/prompts/profiles/feature-analysis.md +1 -1
  91. package/runtime/prompts/profiles/final-verification.md +1 -1
  92. package/runtime/prompts/profiles/implementation-planning.md +20 -12
  93. package/runtime/prompts/profiles/improvement-discovery.md +1 -1
  94. package/runtime/prompts/profiles/project-analysis.md +1 -1
  95. package/runtime/prompts/profiles/release-handoff.md +0 -1
  96. package/runtime/prompts/profiles/requirements-discovery.md +1 -1
  97. package/runtime/prompts/wizard/prompts.ko.json +0 -1
  98. package/runtime/python/okstra_ctl/adapters/hosts/capability_adapter.py +69 -17
  99. package/runtime/python/okstra_ctl/adapters/hosts/claude-code/adapter.py +13 -4
  100. package/runtime/python/okstra_ctl/adapters/hosts/claude-code/relay.md +8 -2
  101. package/runtime/python/okstra_ctl/adapters/hosts/codex/adapter.py +2 -2
  102. package/runtime/python/okstra_ctl/adapters/hosts/codex/relay.md +50 -5
  103. package/runtime/python/okstra_ctl/adapters/hosts/external/relay.md +1 -1
  104. package/runtime/python/okstra_ctl/adapters/hosts/grok/adapter.py +2 -2
  105. package/runtime/python/okstra_ctl/adapters/hosts/grok/relay.md +66 -5
  106. package/runtime/python/okstra_ctl/adapters/providers/grok/adapter.py +70 -2
  107. package/runtime/python/okstra_ctl/agent_activity.py +141 -38
  108. package/runtime/python/okstra_ctl/agent_invocation.py +19 -6
  109. package/runtime/python/okstra_ctl/agent_prompt_cli.py +70 -23
  110. package/runtime/python/okstra_ctl/analysis_inputs.py +5 -4
  111. package/runtime/python/okstra_ctl/analysis_packet.py +124 -3
  112. package/runtime/python/okstra_ctl/approval_decisions.py +328 -0
  113. package/runtime/python/okstra_ctl/attempt_evidence.py +2 -2
  114. package/runtime/python/okstra_ctl/backfill.py +13 -10
  115. package/runtime/python/okstra_ctl/batch.py +2 -4
  116. package/runtime/python/okstra_ctl/build_tools.py +6 -3
  117. package/runtime/python/okstra_ctl/claim_reproduction.py +101 -0
  118. package/runtime/python/okstra_ctl/clarification_items.py +27 -13
  119. package/runtime/python/okstra_ctl/cmux.py +130 -52
  120. package/runtime/python/okstra_ctl/code_review_target.py +34 -8
  121. package/runtime/python/okstra_ctl/consumers.py +5 -4
  122. package/runtime/python/okstra_ctl/container.py +103 -8
  123. package/runtime/python/okstra_ctl/context_cost.py +2 -1
  124. package/runtime/python/okstra_ctl/contract_graph.py +497 -0
  125. package/runtime/python/okstra_ctl/contract_graph_cli.py +62 -0
  126. package/runtime/python/okstra_ctl/convergence.py +338 -17
  127. package/runtime/python/okstra_ctl/convergence_engine.py +10 -18
  128. package/runtime/python/okstra_ctl/convergence_provenance.py +58 -8
  129. package/runtime/python/okstra_ctl/convergence_store.py +55 -34
  130. package/runtime/python/okstra_ctl/design_prep.py +7 -4
  131. package/runtime/python/okstra_ctl/design_snapshot.py +134 -0
  132. package/runtime/python/okstra_ctl/dispatch_core.py +97 -69
  133. package/runtime/python/okstra_ctl/dispatch_state.py +163 -63
  134. package/runtime/python/okstra_ctl/doctor.py +6 -3
  135. package/runtime/python/okstra_ctl/domain/worker_presentation.py +70 -9
  136. package/runtime/python/okstra_ctl/entrypoints/hosts.py +16 -30
  137. package/runtime/python/okstra_ctl/error_log_write.py +35 -30
  138. package/runtime/python/okstra_ctl/error_report.py +26 -1
  139. package/runtime/python/okstra_ctl/error_zip.py +27 -5
  140. package/runtime/python/okstra_ctl/execution_identity.py +3 -2
  141. package/runtime/python/okstra_ctl/execution_manifest.py +7 -4
  142. package/runtime/python/okstra_ctl/execution_mutation_audit.py +6 -2
  143. package/runtime/python/okstra_ctl/final_report_schema.py +26 -17
  144. package/runtime/python/okstra_ctl/fix_cycles.py +2 -2
  145. package/runtime/python/okstra_ctl/fixed_text.py +39 -0
  146. package/runtime/python/okstra_ctl/git_reconcile.py +41 -9
  147. package/runtime/python/okstra_ctl/handoff.py +5 -4
  148. package/runtime/python/okstra_ctl/i18n.py +4 -2
  149. package/runtime/python/okstra_ctl/implementation_direction.py +22 -14
  150. package/runtime/python/okstra_ctl/implementation_outcome.py +4 -7
  151. package/runtime/python/okstra_ctl/incremental_carry.py +130 -17
  152. package/runtime/python/okstra_ctl/incremental_scope.py +12 -4
  153. package/runtime/python/okstra_ctl/index.py +8 -11
  154. package/runtime/python/okstra_ctl/initial_prompt_materialization.py +79 -7
  155. package/runtime/python/okstra_ctl/invocation.py +3 -6
  156. package/runtime/python/okstra_ctl/json_boundary.py +366 -0
  157. package/runtime/python/okstra_ctl/json_registry.py +10 -12
  158. package/runtime/python/okstra_ctl/jsonl.py +19 -2
  159. package/runtime/python/okstra_ctl/lead_events.py +33 -1
  160. package/runtime/python/okstra_ctl/listing.py +3 -3
  161. package/runtime/python/okstra_ctl/log_report.py +24 -2
  162. package/runtime/python/okstra_ctl/manager_cli.py +92 -7
  163. package/runtime/python/okstra_ctl/manager_store.py +12 -10
  164. package/runtime/python/okstra_ctl/material.py +5 -1
  165. package/runtime/python/okstra_ctl/migrate.py +29 -25
  166. package/runtime/python/okstra_ctl/model_cli.py +3 -15
  167. package/runtime/python/okstra_ctl/model_io_cli.py +1051 -0
  168. package/runtime/python/okstra_ctl/mutation_probe.py +13 -4
  169. package/runtime/python/okstra_ctl/pane_reclaim.py +3 -2
  170. package/runtime/python/okstra_ctl/path_hints.py +12 -0
  171. package/runtime/python/okstra_ctl/paths.py +21 -0
  172. package/runtime/python/okstra_ctl/plan_items.py +115 -4
  173. package/runtime/python/okstra_ctl/plan_items_cli.py +626 -34
  174. package/runtime/python/okstra_ctl/ports/worker_dispatch.py +2 -1
  175. package/runtime/python/okstra_ctl/pr_template.py +3 -2
  176. package/runtime/python/okstra_ctl/project_meta.py +5 -7
  177. package/runtime/python/okstra_ctl/recap.py +5 -4
  178. package/runtime/python/okstra_ctl/reconcile.py +21 -27
  179. package/runtime/python/okstra_ctl/registry/host_discovery.py +3 -2
  180. package/runtime/python/okstra_ctl/registry/provider_registry.py +3 -2
  181. package/runtime/python/okstra_ctl/render.py +74 -16
  182. package/runtime/python/okstra_ctl/render_final_report.py +10 -8
  183. package/runtime/python/okstra_ctl/report_assembly.py +509 -0
  184. package/runtime/python/okstra_ctl/report_contract.py +2 -1
  185. package/runtime/python/okstra_ctl/report_finalize.py +67 -32
  186. package/runtime/python/okstra_ctl/report_html/render.py +3 -2
  187. package/runtime/python/okstra_ctl/report_inputs.py +72 -0
  188. package/runtime/python/okstra_ctl/report_language.py +3 -2
  189. package/runtime/python/okstra_ctl/report_markdown.py +81 -8
  190. package/runtime/python/okstra_ctl/report_narrative.py +351 -0
  191. package/runtime/python/okstra_ctl/report_projections.py +265 -0
  192. package/runtime/python/okstra_ctl/report_synthesis_packet.py +518 -0
  193. package/runtime/python/okstra_ctl/report_views.py +3 -2
  194. package/runtime/python/okstra_ctl/rollup.py +65 -4
  195. package/runtime/python/okstra_ctl/run.py +184 -65
  196. package/runtime/python/okstra_ctl/run_audit.py +3 -2
  197. package/runtime/python/okstra_ctl/run_context.py +6 -9
  198. package/runtime/python/okstra_ctl/run_index_row.py +2 -8
  199. package/runtime/python/okstra_ctl/schedule_semantics.py +5 -2
  200. package/runtime/python/okstra_ctl/schema_excerpt.py +15 -8
  201. package/runtime/python/okstra_ctl/session_transcript.py +27 -1
  202. package/runtime/python/okstra_ctl/set_work_status.py +64 -38
  203. package/runtime/python/okstra_ctl/stage_fix_carry.py +8 -6
  204. package/runtime/python/okstra_ctl/stage_ledger.py +132 -18
  205. package/runtime/python/okstra_ctl/stage_map.py +96 -28
  206. package/runtime/python/okstra_ctl/stage_targets.py +3 -4
  207. package/runtime/python/okstra_ctl/team.py +3 -2
  208. package/runtime/python/okstra_ctl/team_reconcile.py +11 -2
  209. package/runtime/python/okstra_ctl/time_report.py +51 -4
  210. package/runtime/python/okstra_ctl/usage_identity.py +2 -1
  211. package/runtime/python/okstra_ctl/usage_report.py +58 -4
  212. package/runtime/python/okstra_ctl/user_response.py +1431 -66
  213. package/runtime/python/okstra_ctl/wizard.py +11 -44
  214. package/runtime/python/okstra_ctl/work_categories.py +3 -2
  215. package/runtime/python/okstra_ctl/worker_dispatch.py +5 -2
  216. package/runtime/python/okstra_ctl/worker_prompt_body.py +49 -3
  217. package/runtime/python/okstra_ctl/worker_prompt_contract.py +3 -2
  218. package/runtime/python/okstra_ctl/worker_prompt_policy.py +31 -3
  219. package/runtime/python/okstra_ctl/worker_runner.py +14 -12
  220. package/runtime/python/okstra_ctl/workflow.py +2 -1
  221. package/runtime/python/okstra_ctl/worktree.py +3 -2
  222. package/runtime/python/okstra_ctl/wrapper_status.py +4 -2
  223. package/runtime/python/okstra_ctl/write_policy.py +4 -2
  224. package/runtime/python/okstra_token_usage/antigravity.py +39 -12
  225. package/runtime/python/okstra_token_usage/collect.py +90 -38
  226. package/runtime/python/okstra_token_usage/grok.py +127 -0
  227. package/runtime/schemas/final-report-v2.0.schema.json +9 -0
  228. package/runtime/schemas/final-report-v3.0.schema.json +10219 -0
  229. package/runtime/schemas/report-narrative-v3.0.schema.json +30 -0
  230. package/runtime/schemas/report-synthesis-packet-v1.0.schema.json +140 -0
  231. package/runtime/skills/okstra-brief-gen/SKILL.md +9 -7
  232. package/runtime/skills/okstra-code-review/SKILL.md +21 -11
  233. package/runtime/skills/okstra-container-build/SKILL.md +18 -18
  234. package/runtime/skills/okstra-inspect/SKILL.md +12 -11
  235. package/runtime/skills/okstra-inspect/facets/error-zip.md +8 -8
  236. package/runtime/skills/okstra-inspect/facets/errors.md +2 -2
  237. package/runtime/skills/okstra-inspect/facets/history.md +9 -14
  238. package/runtime/skills/okstra-inspect/facets/logs.md +2 -2
  239. package/runtime/skills/okstra-inspect/facets/recap.md +5 -5
  240. package/runtime/skills/okstra-inspect/facets/report.md +6 -10
  241. package/runtime/skills/okstra-inspect/facets/status.md +9 -8
  242. package/runtime/skills/okstra-inspect/facets/time.md +3 -3
  243. package/runtime/skills/okstra-manager/SKILL.md +16 -14
  244. package/runtime/skills/okstra-memory/SKILL.md +3 -3
  245. package/runtime/skills/okstra-pr-gen/SKILL.md +5 -4
  246. package/runtime/skills/okstra-rollup/SKILL.md +6 -16
  247. package/runtime/skills/okstra-run/SKILL.md +7 -7
  248. package/runtime/skills/okstra-schedule-gen/SKILL.md +21 -17
  249. package/runtime/skills/okstra-setup/SKILL.md +21 -13
  250. package/runtime/skills/okstra-setup/references/project-config.md +2 -2
  251. package/runtime/skills/okstra-usage/SKILL.md +10 -10
  252. package/runtime/skills/okstra-user-response/SKILL.md +78 -107
  253. package/runtime/templates/report-writer-prompt-preamble.md +29 -19
  254. package/runtime/templates/reports/html/macros/forms.html +6 -4
  255. package/runtime/templates/reports/schedule.template.md +4 -4
  256. package/runtime/templates/worker-error-contract.md +17 -29
  257. package/runtime/validators/validate-run.py +636 -72
  258. 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", "{{.ID}}\t{{.Names}}\t{{.State}}",
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) >= 3:
435
- rows.append({"id": parts[0], "name": parts[1], "state": parts[2]})
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"].write_text(
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 json.loads(project_json.read_text(encoding="utf-8")).get("projectId", "")
883
- except (OSError, json.JSONDecodeError):
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(json.dumps(result, ensure_ascii=False, indent=2))
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 = json.loads(path.read_text(encoding="utf-8"))
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))