okstra 0.180.0 → 0.183.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (223) hide show
  1. package/dist/cli-registry.mjs +16 -2
  2. package/dist/cli-registry.mjs.map +1 -1
  3. package/dist/commands/execute/render-bundle.d.mts +4 -2
  4. package/dist/commands/execute/render-bundle.mjs +46 -5
  5. package/dist/commands/execute/render-bundle.mjs.map +1 -1
  6. package/dist/commands/execute/run.mjs +11 -3
  7. package/dist/commands/execute/run.mjs.map +1 -1
  8. package/dist/commands/inspect/model-io.d.mts +1 -0
  9. package/dist/commands/inspect/model-io.mjs +25 -0
  10. package/dist/commands/inspect/model-io.mjs.map +1 -0
  11. package/dist/commands/inspect/stage-map.mjs +29 -8
  12. package/dist/commands/inspect/stage-map.mjs.map +1 -1
  13. package/dist/commands/inspect/task-list.mjs +52 -6
  14. package/dist/commands/inspect/task-list.mjs.map +1 -1
  15. package/dist/commands/inspect/user-response.mjs +14 -4
  16. package/dist/commands/inspect/user-response.mjs.map +1 -1
  17. package/dist/commands/lifecycle/check-project.d.mts +1 -0
  18. package/dist/commands/lifecycle/check-project.mjs +69 -50
  19. package/dist/commands/lifecycle/check-project.mjs.map +1 -1
  20. package/dist/commands/lifecycle/contract-check.d.mts +1 -0
  21. package/dist/commands/lifecycle/contract-check.mjs +18 -0
  22. package/dist/commands/lifecycle/contract-check.mjs.map +1 -0
  23. package/dist/commands/lifecycle/preflight.mjs +154 -51
  24. package/dist/commands/lifecycle/preflight.mjs.map +1 -1
  25. package/dist/commands/pr/pr.d.mts +1 -0
  26. package/dist/commands/pr/pr.mjs +19 -1
  27. package/dist/commands/pr/pr.mjs.map +1 -1
  28. package/dist/commands/report/agent-activity.mjs +2 -2
  29. package/dist/commands/report/translate.mjs +3 -0
  30. package/dist/commands/report/translate.mjs.map +1 -1
  31. package/dist/lib/host-registry-client.mjs +13 -9
  32. package/dist/lib/host-registry-client.mjs.map +1 -1
  33. package/docs/architecture.md +11 -0
  34. package/docs/cli.md +26 -13
  35. package/docs/container.md +6 -4
  36. package/docs/contributor-change-matrix.md +1 -1
  37. package/docs/for-ai/README.md +2 -2
  38. package/docs/for-ai/skills/okstra-brief-gen.md +5 -3
  39. package/docs/for-ai/skills/okstra-code-review.md +4 -4
  40. package/docs/for-ai/skills/okstra-container-build.md +20 -17
  41. package/docs/for-ai/skills/okstra-inspect.md +20 -23
  42. package/docs/for-ai/skills/okstra-manager.md +19 -18
  43. package/docs/for-ai/skills/okstra-memory.md +2 -2
  44. package/docs/for-ai/skills/okstra-pr-gen.md +3 -3
  45. package/docs/for-ai/skills/okstra-rollup.md +14 -13
  46. package/docs/for-ai/skills/okstra-run.md +7 -3
  47. package/docs/for-ai/skills/okstra-schedule-gen.md +15 -18
  48. package/docs/for-ai/skills/okstra-setup.md +7 -7
  49. package/docs/for-ai/skills/okstra-usage.md +5 -4
  50. package/docs/for-ai/skills/okstra-user-response.md +50 -32
  51. package/docs/project-structure-overview.md +29 -26
  52. package/docs/task-process/implementation.md +7 -4
  53. package/package.json +1 -1
  54. package/runtime/BUILD.json +2 -2
  55. package/runtime/agents/workers/claude-worker.md +4 -4
  56. package/runtime/agents/workers/report-writer-worker.md +3 -3
  57. package/runtime/agents/workers/translator-worker.md +5 -13
  58. package/runtime/bin/okstra-error-log.py +51 -11
  59. package/runtime/bin/okstra-report-translate.py +210 -23
  60. package/runtime/prompts/host-orchestration/implementation.md +1 -1
  61. package/runtime/prompts/launch.template.md +3 -7
  62. package/runtime/prompts/lead/context-loader.md +41 -141
  63. package/runtime/prompts/lead/convergence.md +8 -6
  64. package/runtime/prompts/lead/okstra-lead-contract.md +25 -35
  65. package/runtime/prompts/lead/plan-body-verification.md +187 -14
  66. package/runtime/prompts/lead/report-writer.md +20 -1
  67. package/runtime/prompts/lead/team-contract.md +8 -53
  68. package/runtime/prompts/profiles/_coding-conventions-preflight.md +3 -2
  69. package/runtime/prompts/profiles/_common-contract.md +1 -1
  70. package/runtime/prompts/profiles/_implementation-diff-review.md +1 -1
  71. package/runtime/prompts/profiles/_implementation-verifier.md +3 -3
  72. package/runtime/prompts/profiles/final-verification.md +1 -1
  73. package/runtime/prompts/profiles/implementation-planning.md +8 -2
  74. package/runtime/prompts/profiles/release-handoff.md +0 -1
  75. package/runtime/prompts/wizard/prompts.ko.json +0 -1
  76. package/runtime/python/okstra_ctl/adapters/hosts/capability_adapter.py +69 -17
  77. package/runtime/python/okstra_ctl/adapters/hosts/claude-code/adapter.py +13 -4
  78. package/runtime/python/okstra_ctl/adapters/hosts/claude-code/relay.md +6 -1
  79. package/runtime/python/okstra_ctl/adapters/hosts/codex/adapter.py +2 -2
  80. package/runtime/python/okstra_ctl/adapters/hosts/codex/relay.md +50 -5
  81. package/runtime/python/okstra_ctl/adapters/hosts/grok/adapter.py +2 -2
  82. package/runtime/python/okstra_ctl/adapters/hosts/grok/relay.md +66 -5
  83. package/runtime/python/okstra_ctl/adapters/providers/grok/adapter.py +70 -2
  84. package/runtime/python/okstra_ctl/agent_activity.py +118 -35
  85. package/runtime/python/okstra_ctl/agent_invocation.py +19 -6
  86. package/runtime/python/okstra_ctl/agent_prompt_cli.py +65 -18
  87. package/runtime/python/okstra_ctl/analysis_inputs.py +5 -4
  88. package/runtime/python/okstra_ctl/analysis_packet.py +81 -1
  89. package/runtime/python/okstra_ctl/approval_decisions.py +3 -2
  90. package/runtime/python/okstra_ctl/attempt_evidence.py +2 -2
  91. package/runtime/python/okstra_ctl/backfill.py +13 -10
  92. package/runtime/python/okstra_ctl/batch.py +2 -4
  93. package/runtime/python/okstra_ctl/build_tools.py +6 -3
  94. package/runtime/python/okstra_ctl/claim_reproduction.py +101 -0
  95. package/runtime/python/okstra_ctl/clarification_items.py +27 -13
  96. package/runtime/python/okstra_ctl/cmux.py +130 -52
  97. package/runtime/python/okstra_ctl/code_review_target.py +34 -8
  98. package/runtime/python/okstra_ctl/consumers.py +5 -4
  99. package/runtime/python/okstra_ctl/container.py +103 -8
  100. package/runtime/python/okstra_ctl/context_cost.py +2 -1
  101. package/runtime/python/okstra_ctl/contract_graph.py +497 -0
  102. package/runtime/python/okstra_ctl/contract_graph_cli.py +62 -0
  103. package/runtime/python/okstra_ctl/convergence.py +338 -17
  104. package/runtime/python/okstra_ctl/convergence_engine.py +10 -18
  105. package/runtime/python/okstra_ctl/convergence_provenance.py +58 -8
  106. package/runtime/python/okstra_ctl/convergence_store.py +55 -34
  107. package/runtime/python/okstra_ctl/design_prep.py +7 -4
  108. package/runtime/python/okstra_ctl/dispatch_core.py +35 -65
  109. package/runtime/python/okstra_ctl/dispatch_state.py +134 -59
  110. package/runtime/python/okstra_ctl/doctor.py +6 -3
  111. package/runtime/python/okstra_ctl/domain/worker_presentation.py +70 -9
  112. package/runtime/python/okstra_ctl/entrypoints/hosts.py +16 -30
  113. package/runtime/python/okstra_ctl/error_log_write.py +35 -30
  114. package/runtime/python/okstra_ctl/error_report.py +26 -1
  115. package/runtime/python/okstra_ctl/error_zip.py +27 -5
  116. package/runtime/python/okstra_ctl/execution_identity.py +3 -2
  117. package/runtime/python/okstra_ctl/execution_manifest.py +7 -4
  118. package/runtime/python/okstra_ctl/final_report_schema.py +2 -2
  119. package/runtime/python/okstra_ctl/fix_cycles.py +2 -2
  120. package/runtime/python/okstra_ctl/fixed_text.py +39 -0
  121. package/runtime/python/okstra_ctl/git_reconcile.py +41 -9
  122. package/runtime/python/okstra_ctl/handoff.py +5 -4
  123. package/runtime/python/okstra_ctl/i18n.py +4 -2
  124. package/runtime/python/okstra_ctl/implementation_direction.py +22 -14
  125. package/runtime/python/okstra_ctl/implementation_outcome.py +4 -7
  126. package/runtime/python/okstra_ctl/incremental_carry.py +2 -1
  127. package/runtime/python/okstra_ctl/incremental_scope.py +8 -3
  128. package/runtime/python/okstra_ctl/index.py +8 -11
  129. package/runtime/python/okstra_ctl/initial_prompt_materialization.py +79 -7
  130. package/runtime/python/okstra_ctl/invocation.py +3 -6
  131. package/runtime/python/okstra_ctl/json_boundary.py +366 -0
  132. package/runtime/python/okstra_ctl/json_registry.py +10 -12
  133. package/runtime/python/okstra_ctl/jsonl.py +19 -2
  134. package/runtime/python/okstra_ctl/lead_events.py +33 -1
  135. package/runtime/python/okstra_ctl/listing.py +3 -3
  136. package/runtime/python/okstra_ctl/log_report.py +24 -2
  137. package/runtime/python/okstra_ctl/manager_cli.py +92 -7
  138. package/runtime/python/okstra_ctl/manager_store.py +12 -10
  139. package/runtime/python/okstra_ctl/material.py +5 -1
  140. package/runtime/python/okstra_ctl/migrate.py +29 -25
  141. package/runtime/python/okstra_ctl/model_cli.py +3 -15
  142. package/runtime/python/okstra_ctl/model_io_cli.py +1051 -0
  143. package/runtime/python/okstra_ctl/mutation_probe.py +13 -4
  144. package/runtime/python/okstra_ctl/pane_reclaim.py +3 -2
  145. package/runtime/python/okstra_ctl/paths.py +9 -0
  146. package/runtime/python/okstra_ctl/plan_items.py +115 -4
  147. package/runtime/python/okstra_ctl/plan_items_cli.py +513 -18
  148. package/runtime/python/okstra_ctl/pr_template.py +3 -2
  149. package/runtime/python/okstra_ctl/project_meta.py +5 -7
  150. package/runtime/python/okstra_ctl/recap.py +5 -4
  151. package/runtime/python/okstra_ctl/reconcile.py +21 -27
  152. package/runtime/python/okstra_ctl/registry/host_discovery.py +3 -2
  153. package/runtime/python/okstra_ctl/registry/provider_registry.py +3 -2
  154. package/runtime/python/okstra_ctl/render.py +26 -15
  155. package/runtime/python/okstra_ctl/render_final_report.py +3 -2
  156. package/runtime/python/okstra_ctl/report_assembly.py +172 -17
  157. package/runtime/python/okstra_ctl/report_finalize.py +7 -10
  158. package/runtime/python/okstra_ctl/report_html/render.py +3 -2
  159. package/runtime/python/okstra_ctl/report_language.py +3 -2
  160. package/runtime/python/okstra_ctl/report_markdown.py +13 -1
  161. package/runtime/python/okstra_ctl/report_narrative.py +40 -8
  162. package/runtime/python/okstra_ctl/report_synthesis_packet.py +518 -0
  163. package/runtime/python/okstra_ctl/report_views.py +3 -2
  164. package/runtime/python/okstra_ctl/rollup.py +65 -4
  165. package/runtime/python/okstra_ctl/run.py +159 -56
  166. package/runtime/python/okstra_ctl/run_audit.py +3 -2
  167. package/runtime/python/okstra_ctl/run_context.py +6 -9
  168. package/runtime/python/okstra_ctl/run_index_row.py +2 -8
  169. package/runtime/python/okstra_ctl/schedule_semantics.py +5 -2
  170. package/runtime/python/okstra_ctl/schema_excerpt.py +4 -2
  171. package/runtime/python/okstra_ctl/session_transcript.py +27 -1
  172. package/runtime/python/okstra_ctl/set_work_status.py +64 -38
  173. package/runtime/python/okstra_ctl/stage_fix_carry.py +4 -2
  174. package/runtime/python/okstra_ctl/stage_map.py +26 -6
  175. package/runtime/python/okstra_ctl/stage_targets.py +3 -4
  176. package/runtime/python/okstra_ctl/team.py +2 -1
  177. package/runtime/python/okstra_ctl/team_reconcile.py +11 -2
  178. package/runtime/python/okstra_ctl/time_report.py +51 -4
  179. package/runtime/python/okstra_ctl/usage_identity.py +2 -1
  180. package/runtime/python/okstra_ctl/usage_report.py +58 -4
  181. package/runtime/python/okstra_ctl/user_response.py +1431 -66
  182. package/runtime/python/okstra_ctl/wizard.py +11 -44
  183. package/runtime/python/okstra_ctl/work_categories.py +3 -2
  184. package/runtime/python/okstra_ctl/worker_prompt_body.py +18 -7
  185. package/runtime/python/okstra_ctl/worker_prompt_contract.py +3 -2
  186. package/runtime/python/okstra_ctl/worker_runner.py +14 -12
  187. package/runtime/python/okstra_ctl/workflow.py +2 -1
  188. package/runtime/python/okstra_ctl/worktree.py +3 -2
  189. package/runtime/python/okstra_ctl/wrapper_status.py +4 -2
  190. package/runtime/python/okstra_ctl/write_policy.py +4 -2
  191. package/runtime/python/okstra_token_usage/antigravity.py +39 -12
  192. package/runtime/python/okstra_token_usage/collect.py +90 -38
  193. package/runtime/python/okstra_token_usage/grok.py +127 -0
  194. package/runtime/schemas/final-report-v2.0.schema.json +9 -0
  195. package/runtime/schemas/final-report-v3.0.schema.json +9 -0
  196. package/runtime/schemas/report-synthesis-packet-v1.0.schema.json +140 -0
  197. package/runtime/skills/okstra-brief-gen/SKILL.md +9 -7
  198. package/runtime/skills/okstra-code-review/SKILL.md +21 -11
  199. package/runtime/skills/okstra-container-build/SKILL.md +18 -18
  200. package/runtime/skills/okstra-inspect/SKILL.md +12 -11
  201. package/runtime/skills/okstra-inspect/facets/error-zip.md +8 -8
  202. package/runtime/skills/okstra-inspect/facets/errors.md +2 -2
  203. package/runtime/skills/okstra-inspect/facets/history.md +9 -14
  204. package/runtime/skills/okstra-inspect/facets/logs.md +2 -2
  205. package/runtime/skills/okstra-inspect/facets/recap.md +5 -5
  206. package/runtime/skills/okstra-inspect/facets/report.md +6 -10
  207. package/runtime/skills/okstra-inspect/facets/status.md +9 -8
  208. package/runtime/skills/okstra-inspect/facets/time.md +3 -3
  209. package/runtime/skills/okstra-manager/SKILL.md +16 -14
  210. package/runtime/skills/okstra-memory/SKILL.md +3 -3
  211. package/runtime/skills/okstra-pr-gen/SKILL.md +5 -4
  212. package/runtime/skills/okstra-rollup/SKILL.md +6 -16
  213. package/runtime/skills/okstra-run/SKILL.md +7 -7
  214. package/runtime/skills/okstra-schedule-gen/SKILL.md +21 -17
  215. package/runtime/skills/okstra-setup/SKILL.md +21 -13
  216. package/runtime/skills/okstra-setup/references/project-config.md +2 -2
  217. package/runtime/skills/okstra-usage/SKILL.md +10 -10
  218. package/runtime/skills/okstra-user-response/SKILL.md +78 -107
  219. package/runtime/templates/report-writer-prompt-preamble.md +17 -1
  220. package/runtime/templates/reports/schedule.template.md +4 -4
  221. package/runtime/templates/worker-error-contract.md +17 -29
  222. package/runtime/validators/validate-run.py +379 -63
  223. package/runtime/validators/validate_session_conformance.py +43 -7
@@ -1,10 +1,7 @@
1
1
  """Validated JSON loading and atomic persistence for convergence artifacts."""
2
2
  from __future__ import annotations
3
3
 
4
- import json
5
- import os
6
4
  from pathlib import Path
7
- import tempfile
8
5
  from typing import Any, Mapping
9
6
 
10
7
  from .convergence_engine import ConvergenceContractError
@@ -14,6 +11,8 @@ from .execution_manifest import (
14
11
  read_execution_manifest,
15
12
  reserve_derived_role_invocation,
16
13
  )
14
+ from .final_report_schema import load_named_schema
15
+ from .json_boundary import JsonBoundaryError, load_owned_object, write_owned_object_atomic
17
16
  from .worker_request import verifier_extra_dirs
18
17
  from .write_policy import build_invocation_write_contract
19
18
 
@@ -25,44 +24,66 @@ DYNAMIC_VERIFIER_SOURCE_ROLES = frozenset({
25
24
  "verifier",
26
25
  })
27
26
 
27
+ _CRITIC_BATCH_SCHEMA = {
28
+ "type": "object",
29
+ "required": [
30
+ "schemaVersion", "taskKey", "mode", "provider",
31
+ "modelExecutionValue", "dispatches", "gaps",
32
+ ],
33
+ "additionalProperties": False,
34
+ "properties": {
35
+ "schemaVersion": {"const": "1.0"},
36
+ "taskKey": {"type": "string", "pattern": "\\S"},
37
+ "mode": {"const": "coverage"},
38
+ "provider": {"type": "string", "pattern": "\\S"},
39
+ "modelExecutionValue": {"type": "string", "pattern": "\\S"},
40
+ "dispatches": {"type": "array", "items": {"type": "object"}},
41
+ "gaps": {"type": "array", "items": {"type": "object"}},
42
+ },
43
+ }
44
+
45
+
46
+ def load_owned_json_object(path: Path) -> dict[str, Any]:
47
+ try:
48
+ return load_owned_object(path, artifact="convergence artifact")
49
+ except JsonBoundaryError as exc:
50
+ raise ConvergenceContractError(str(exc)) from exc
51
+
52
+
53
+ def _load_convergence_result(
54
+ path: Path, *, schema: Mapping[str, Any]
55
+ ) -> dict[str, Any]:
56
+ try:
57
+ return load_owned_object(
58
+ path, artifact="convergence result", schema=schema
59
+ )
60
+ except JsonBoundaryError as exc:
61
+ raise ConvergenceContractError(str(exc)) from exc
62
+
63
+
64
+ def load_convergence_round_results(path: Path) -> dict[str, Any]:
65
+ return _load_convergence_result(
66
+ path,
67
+ schema=load_named_schema("convergence-round-results-v1.0.schema.json"),
68
+ )
69
+
70
+
71
+ def load_convergence_critic_batch(path: Path) -> dict[str, Any]:
72
+ return _load_convergence_result(path, schema=_CRITIC_BATCH_SCHEMA)
73
+
28
74
 
29
75
  def load_json_object(path: Path) -> dict[str, Any]:
30
- value = json.loads(path.read_text(encoding="utf-8"))
31
- if not isinstance(value, dict):
32
- raise ConvergenceContractError(f"JSON input must be an object: {path}")
33
- return value
76
+ """Compatibility alias for convergence artifacts owned by okstra."""
77
+ return load_owned_json_object(path)
34
78
 
35
79
 
36
80
  def write_json_atomic(path: Path, payload: Mapping[str, Any]) -> None:
37
81
  if not isinstance(payload, Mapping):
38
82
  raise ConvergenceContractError("JSON output payload must be an object")
39
- path.parent.mkdir(parents=True, exist_ok=True)
40
- temp_path: Path | None = None
41
83
  try:
42
- with tempfile.NamedTemporaryFile(
43
- mode="w",
44
- encoding="utf-8",
45
- dir=path.parent,
46
- prefix=f".{path.name}.",
47
- suffix=".tmp",
48
- delete=False,
49
- ) as handle:
50
- temp_path = Path(handle.name)
51
- json.dump(
52
- payload,
53
- handle,
54
- ensure_ascii=False,
55
- sort_keys=True,
56
- indent=2,
57
- )
58
- handle.write("\n")
59
- handle.flush()
60
- os.fsync(handle.fileno())
61
- os.replace(temp_path, path)
62
- temp_path = None
63
- finally:
64
- if temp_path is not None:
65
- temp_path.unlink(missing_ok=True)
84
+ write_owned_object_atomic(path, payload, artifact="convergence artifact")
85
+ except JsonBoundaryError as exc:
86
+ raise ConvergenceContractError(str(exc)) from exc
66
87
 
67
88
 
68
89
  def write_final_state_atomic(
@@ -111,7 +132,7 @@ def reserve_dynamic_verifier(
111
132
  source_role_execution_ref,
112
133
  duty_id,
113
134
  )
114
- authority = load_json_object(manifest_path)
135
+ authority = load_owned_json_object(manifest_path)
115
136
  if authority.get("taskKey") not in (None, task_key):
116
137
  raise ExecutionManifestError("dynamic verifier taskKey does not match manifest")
117
138
  project_root = _manifest_project_root(authority, manifest_path)
@@ -21,6 +21,7 @@ from pathlib import Path
21
21
  from typing import Any, Iterator
22
22
 
23
23
  from .final_report_schema import load_schema, validate as validate_schema
24
+ from .json_boundary import JsonBoundaryError, load_owned_object
24
25
  from okstra_project import ResolverError, resolve_project_root
25
26
 
26
27
 
@@ -166,8 +167,10 @@ def assessment_fingerprint(item: Mapping[str, Any]) -> str:
166
167
 
167
168
  def _load_plan_data(data_path: Path) -> dict[str, Any]:
168
169
  try:
169
- data = json.loads(data_path.read_text(encoding="utf-8"))
170
- except (OSError, UnicodeError, json.JSONDecodeError) as exc:
170
+ data = load_owned_object(data_path, artifact="planning report record")
171
+ except JsonBoundaryError as exc:
172
+ if exc.reason == "top-level JSON value must be an object":
173
+ raise DesignPrepError(f"planning data must be an object: {data_path}") from exc
171
174
  raise DesignPrepError(f"cannot read planning data {data_path}: {exc}") from exc
172
175
  if not isinstance(data, dict):
173
176
  raise DesignPrepError(f"planning data must be an object: {data_path}")
@@ -1045,8 +1048,8 @@ def _read_carry(path: Path, stage: int, warnings: list[str]) -> Mapping[str, Any
1045
1048
  if not path.is_file():
1046
1049
  return None
1047
1050
  try:
1048
- payload = json.loads(path.read_text(encoding="utf-8"))
1049
- except (OSError, UnicodeError, json.JSONDecodeError) as exc:
1051
+ payload = load_owned_object(path, artifact="design preparation carry")
1052
+ except JsonBoundaryError as exc:
1050
1053
  warnings.append(f"malformed carry stage {stage}; ignored: {exc}")
1051
1054
  return None
1052
1055
  if not isinstance(payload, Mapping):
@@ -78,6 +78,7 @@ from .execution_mutation_audit import (
78
78
  assert_compatible_batch,
79
79
  )
80
80
  from .convergence_store import write_json_atomic
81
+ from .json_boundary import JsonBoundaryError, external_worker_json_source, load_external_json
81
82
  from .assignment_environment import load_assignment_context
82
83
  from .model_pool import ModelPool
83
84
  from .error_log_write import append_observed
@@ -97,12 +98,6 @@ from .schema_excerpt import (
97
98
  excerpt_contract_skew,
98
99
  )
99
100
  from .seeding import installed_version
100
- from .report_finalize import (
101
- STEP_VALIDATE_RUN,
102
- FinalizeContext,
103
- FinalizeError,
104
- run_finalize,
105
- )
106
101
  from .worker_audit_ledger import (
107
102
  check_worker_results_audit,
108
103
  parse_worker_result_name,
@@ -463,6 +458,12 @@ def _orchestrator_artifact_roots(plan: DispatchPlan) -> tuple[Path, ...]:
463
458
  return tuple(roots)
464
459
 
465
460
 
461
+ # 이 모듈이 정산하는 모든 디스패치의 집행 방식. 상수인 이유는
462
+ # `dispatch_state._AGENT_ENFORCEMENT_MODES` 의 다른 값(`host-native-spec-link-gate`)
463
+ # 이 호스트 원시 호출 전용이고 이 경로로 오지 않기 때문이다.
464
+ CLI_DISPATCH_ENFORCEMENT_MODE = "core-pre-dispatch"
465
+
466
+
466
467
  def _round_artifact_paths(plan: DispatchPlan) -> tuple[Path, ...]:
467
468
  """Every artifact this round's workers are entitled to write.
468
469
 
@@ -1245,7 +1246,7 @@ def _invocation_job_fields(
1245
1246
  "instruction_digest": _require_string(digests, "instructionDigest"),
1246
1247
  "prompt_digest": _require_string(digests, "promptDigest"),
1247
1248
  "host_model_value": host_model_value,
1248
- "enforcement_mode": "core-pre-dispatch",
1249
+ "enforcement_mode": CLI_DISPATCH_ENFORCEMENT_MODE,
1249
1250
  }
1250
1251
  schema = metadata.get("schemaVersion")
1251
1252
  identity_version = metadata.get("executionIdentityVersion")
@@ -1924,28 +1925,6 @@ def _finish_attempt(
1924
1925
  settlement = _settle(plan, job, attempt, outcome)
1925
1926
  if settlement.completed:
1926
1927
  result_link = _link_result(plan, job, attempt)
1927
- post_process = _post_process_report_writer_result(plan, job)
1928
- if not post_process["ok"]:
1929
- reason = _require_string(post_process, "reason")
1930
- _transition_job_status(plan, job, "error", reason)
1931
- _update_dispatch_status(
1932
- plan.team_state_path,
1933
- job,
1934
- attempt,
1935
- "error",
1936
- reason,
1937
- )
1938
- details = _failure_details(
1939
- job,
1940
- attempt,
1941
- outcome,
1942
- reason,
1943
- exit_code=1,
1944
- )
1945
- details["postProcessing"] = post_process["steps"]
1946
- _append_event(plan, "worker-failed", details)
1947
- _finish_manifest_attempt(plan, job, attempt, mutation, None)
1948
- return False
1949
1928
  _transition_job_status(plan, job, "completed", "")
1950
1929
  _update_dispatch_status(
1951
1930
  plan.team_state_path,
@@ -1955,7 +1934,6 @@ def _finish_attempt(
1955
1934
  "; ".join(part for part in (settlement.note, result_link["reason"]) if part),
1956
1935
  )
1957
1936
  details = _result_details(job, attempt, outcome)
1958
- details["postProcessing"] = post_process["steps"]
1959
1937
  details["resultLink"] = result_link
1960
1938
  if settlement.error_log_append is not None:
1961
1939
  details["errorLogAppend"] = settlement.error_log_append
@@ -2007,7 +1985,9 @@ def _audit_attempt(
2007
1985
  return ExecutionMutationAudit().compare(
2008
1986
  snapshot,
2009
1987
  policies,
2010
- out_of_plan_edits=_out_of_plan_edit_paths(job.result_path),
1988
+ out_of_plan_edits=_out_of_plan_edit_paths(
1989
+ job.result_path, project_root=job.project_root
1990
+ ),
2011
1991
  attempt_succeeded=succeeded,
2012
1992
  result_present=result_present,
2013
1993
  )
@@ -2044,14 +2024,26 @@ def _declared_out_of_plan_paths(result_path: Path) -> tuple[str, ...]:
2044
2024
  return tuple(paths)
2045
2025
 
2046
2026
 
2047
- def _out_of_plan_edit_paths(result_path: Path) -> tuple[str, ...]:
2027
+ def _out_of_plan_edit_paths(
2028
+ result_path: Path, *, project_root: Path | None = None
2029
+ ) -> tuple[str, ...]:
2048
2030
  if result_path.suffix != ".json":
2049
2031
  return _declared_out_of_plan_paths(result_path)
2050
2032
  if not result_path.is_file():
2051
2033
  return ()
2034
+ if project_root is None:
2035
+ raise ValueError("JSON worker result requires project_root")
2052
2036
  try:
2053
- payload = json.loads(result_path.read_text(encoding="utf-8"))
2054
- except (OSError, UnicodeError, json.JSONDecodeError):
2037
+ # 외부 입력: 워커 모델이 게시한 결과 봉투를 디스패처가 수락 전에 판독한다.
2038
+ payload = load_external_json(
2039
+ external_worker_json_source(
2040
+ result_path,
2041
+ trusted_root=project_root,
2042
+ lane_root=result_path.parent,
2043
+ ),
2044
+ artifact="worker result",
2045
+ )
2046
+ except JsonBoundaryError:
2055
2047
  return ()
2056
2048
  implementation = payload.get("implementation") if isinstance(payload, Mapping) else None
2057
2049
  rows = implementation.get("outOfPlanEdits") if isinstance(implementation, Mapping) else None
@@ -2437,36 +2429,6 @@ def _wrapper_log_tail(job: WorkerJob) -> str | None:
2437
2429
  return tail.decode("utf-8", errors="replace").strip() or None
2438
2430
 
2439
2431
 
2440
- def _post_process_report_writer_result(
2441
- plan: DispatchPlan,
2442
- job: WorkerJob,
2443
- ) -> dict[str, Any]:
2444
- if job.worker_id != REPORT_WRITER_WORKER_ID:
2445
- return {"ok": True, "reason": "", "steps": []}
2446
- try:
2447
- context = FinalizeContext.from_manifest(
2448
- project_root=plan.project_root,
2449
- workspace_root=plan.workspace_root,
2450
- manifest_path=plan.manifest_path,
2451
- team_state_path=plan.team_state_path,
2452
- data_path=job.result_path,
2453
- manifest=plan.manifest,
2454
- )
2455
-
2456
- def settle_before(step_name: str) -> None:
2457
- if step_name == STEP_VALIDATE_RUN:
2458
- _transition_worker_status(
2459
- plan.team_state_path,
2460
- job.worker_id,
2461
- "completed",
2462
- "",
2463
- )
2464
-
2465
- return run_finalize(context, before_step=settle_before)
2466
- except FinalizeError as exc:
2467
- return {"ok": False, "reason": str(exc), "steps": []}
2468
-
2469
-
2470
2432
  def _projectionless_translator(plan: DispatchPlan, job: WorkerJob) -> bool:
2471
2433
  if not (
2472
2434
  job.has_execution_identity
@@ -2946,7 +2908,15 @@ def _job_from_record(project_root: Path, record: Mapping[str, Any]) -> WorkerJob
2946
2908
  if isinstance(record.get("hostModelValue"), str)
2947
2909
  else None
2948
2910
  ),
2949
- enforcement_mode=_string_value(record.get("enforcementMode")),
2911
+ # jobs 파일은 `agent-prompt materialize` 가 쓴 `.meta.json` 을 옮겨 적는
2912
+ # 것이 정상 경로인데, materialize 는 이 값을 기록하지 않는다 — 기록할
2913
+ # 것이 없기 때문이다. worker-dispatch 를 타는 디스패치의 집행 방식은
2914
+ # 하나뿐이고, 다른 하나(`host-native-spec-link-gate`)는 리드가 호스트
2915
+ # 원시 호출을 직접 기록할 때만 쓴다. 그래서 손으로 채우게 하지 않는다.
2916
+ enforcement_mode=(
2917
+ _string_value(record.get("enforcementMode"))
2918
+ or CLI_DISPATCH_ENFORCEMENT_MODE
2919
+ ),
2950
2920
  **(identity or {}),
2951
2921
  )
2952
2922
 
@@ -68,6 +68,13 @@ from .write_policy import (
68
68
  write_policy_from_payload,
69
69
  )
70
70
  from .convergence_store import write_json_atomic
71
+ from .json_boundary import (
72
+ JsonBoundaryError,
73
+ external_worker_json_source,
74
+ load_external_json,
75
+ load_owned_object,
76
+ write_owned_object_atomic,
77
+ )
71
78
 
72
79
  BACKEND_CLI_WRAPPER = "cli-wrapper"
73
80
  BACKEND_CMUX_PANE = "cmux-pane"
@@ -369,8 +376,8 @@ def load_json_object(path: Path, label: str) -> dict[str, Any]:
369
376
  if not path.is_file():
370
377
  raise DispatchError(f"{label} not found: {path}")
371
378
  try:
372
- payload = json.loads(path.read_text(encoding="utf-8"))
373
- except json.JSONDecodeError as exc:
379
+ payload = load_owned_object(path, artifact=label)
380
+ except JsonBoundaryError as exc:
374
381
  raise DispatchError(f"{label} is invalid JSON: {path}: {exc}") from exc
375
382
  if not isinstance(payload, dict):
376
383
  raise DispatchError(f"{label} must be a JSON object: {path}")
@@ -381,26 +388,7 @@ def write_json(path: Path, payload: Mapping[str, Any]) -> None:
381
388
  # Write via temp file + os.replace so a crash mid-write cannot leave a
382
389
  # truncated team-state.json that every later load_json_object rejects;
383
390
  # os.replace is atomic on POSIX, matching run_context._atomic_write_json.
384
- path.parent.mkdir(parents=True, exist_ok=True)
385
- temp_path: Path | None = None
386
- try:
387
- with tempfile.NamedTemporaryFile(
388
- mode="w",
389
- encoding="utf-8",
390
- dir=path.parent,
391
- prefix=f".{path.name}.",
392
- suffix=".tmp",
393
- delete=False,
394
- ) as handle:
395
- temp_path = Path(handle.name)
396
- handle.write(json.dumps(payload, ensure_ascii=False, indent=2) + "\n")
397
- handle.flush()
398
- os.fsync(handle.fileno())
399
- os.replace(temp_path, path)
400
- temp_path = None
401
- finally:
402
- if temp_path is not None:
403
- temp_path.unlink(missing_ok=True)
391
+ write_owned_object_atomic(path, payload, artifact="team state")
404
392
 
405
393
 
406
394
  @contextlib.contextmanager
@@ -1107,6 +1095,33 @@ def _finish_native_agent_attempt(
1107
1095
  if dispatch.get("writeEnforcement") != invocation.write_enforcement:
1108
1096
  raise DispatchError("agent dispatch write enforcement changed")
1109
1097
  policy = write_policy_from_payload(invocation.write_policy)
1098
+ status, summary = _native_mutation_result(
1099
+ project_root=project_root,
1100
+ dispatch=dispatch,
1101
+ result_path=result_path,
1102
+ policy=policy,
1103
+ )
1104
+ _close_native_attempt(
1105
+ manifest_path,
1106
+ manifest,
1107
+ identity,
1108
+ status,
1109
+ result_relative,
1110
+ summary,
1111
+ )
1112
+ if status != "ok":
1113
+ violations = summary.get("violations")
1114
+ detail = "; ".join(violations) if isinstance(violations, list) else status
1115
+ raise DispatchError(f"agent mutation audit failed: {detail or status}")
1116
+
1117
+
1118
+ def _native_mutation_result(
1119
+ *,
1120
+ project_root: Path,
1121
+ dispatch: Mapping[str, Any],
1122
+ result_path: Path,
1123
+ policy: WritePolicy,
1124
+ ) -> tuple[str, Mapping[str, Any]]:
1110
1125
  snapshot_value = string_value(dispatch.get("mutationAuditSnapshotPath"))
1111
1126
  if snapshot_value:
1112
1127
  snapshot_payload = load_json_object(
@@ -1120,7 +1135,9 @@ def _finish_native_agent_attempt(
1120
1135
  result = ExecutionMutationAudit().compare(
1121
1136
  snapshot,
1122
1137
  (policy,),
1123
- out_of_plan_edits=_native_out_of_plan_paths(result_path),
1138
+ out_of_plan_edits=_native_out_of_plan_paths(
1139
+ result_path, project_root=project_root
1140
+ ),
1124
1141
  attempt_succeeded=True,
1125
1142
  result_present=True,
1126
1143
  )
@@ -1139,18 +1156,7 @@ def _finish_native_agent_attempt(
1139
1156
  "beforeDigest": "",
1140
1157
  "afterDigest": "",
1141
1158
  }
1142
- _close_native_attempt(
1143
- manifest_path,
1144
- manifest,
1145
- identity,
1146
- status,
1147
- result_relative,
1148
- summary,
1149
- )
1150
- if status != "ok":
1151
- violations = summary.get("violations")
1152
- detail = "; ".join(violations) if isinstance(violations, list) else status
1153
- raise DispatchError(f"agent mutation audit failed: {detail or status}")
1159
+ return status, summary
1154
1160
 
1155
1161
 
1156
1162
  def _close_native_attempt(
@@ -1191,12 +1197,22 @@ def _close_native_attempt(
1191
1197
  )
1192
1198
 
1193
1199
 
1194
- def _native_out_of_plan_paths(result_path: Path) -> tuple[str, ...]:
1200
+ def _native_out_of_plan_paths(
1201
+ result_path: Path, *, project_root: Path
1202
+ ) -> tuple[str, ...]:
1195
1203
  if result_path.suffix != ".json":
1196
1204
  return ()
1197
1205
  try:
1198
- payload = json.loads(result_path.read_text(encoding="utf-8"))
1199
- except (OSError, UnicodeError, json.JSONDecodeError):
1206
+ # 외부 입력: 네이티브 모델 결과에서 선택적 수정 경로만 투영한다.
1207
+ payload = load_external_json(
1208
+ external_worker_json_source(
1209
+ result_path,
1210
+ trusted_root=project_root,
1211
+ lane_root=result_path.parent,
1212
+ ),
1213
+ artifact="native worker result",
1214
+ )
1215
+ except JsonBoundaryError:
1200
1216
  return ()
1201
1217
  implementation = payload.get("implementation") if isinstance(payload, Mapping) else None
1202
1218
  rows = implementation.get("outOfPlanEdits") if isinstance(implementation, Mapping) else None
@@ -1668,6 +1684,26 @@ def _validate_agent_invocations(
1668
1684
  raise DispatchError("agent invocation contract: " + "; ".join(errors))
1669
1685
 
1670
1686
 
1687
+ def divergent_fields(
1688
+ pairs: Sequence[tuple[str, Any, Any]],
1689
+ *,
1690
+ left: str,
1691
+ right: str,
1692
+ ) -> list[str]:
1693
+ """어긋난 필드만 이름과 두 값으로 적는다.
1694
+
1695
+ 이 두 검사는 각각 일곱 개와 다섯 개의 조건을 `or` 로 묶어 한 문장으로
1696
+ 보고했다. 그래서 재시도 프롬프트의 `dispatchKind` 하나가 틀린 경우와
1697
+ provider 가 틀린 경우가 같은 문장으로 나왔고, 읽는 쪽이 어느 값을 봐야 할지
1698
+ 알 수 없었다. 판정에 쓴 값은 이미 손에 있으므로 감출 이유가 없다.
1699
+ """
1700
+ return [
1701
+ f"{name}: {left}={found!r} {right}={expected!r}"
1702
+ for name, found, expected in pairs
1703
+ if found != expected
1704
+ ]
1705
+
1706
+
1671
1707
  def _v2_job_authority_errors(
1672
1708
  job: WorkerJob,
1673
1709
  metadata: Mapping[str, Any],
@@ -1682,14 +1718,22 @@ def _v2_job_authority_errors(
1682
1718
  errors: list[str] = []
1683
1719
  if job.invocation_ref != identity.invocation_ref:
1684
1720
  errors.append("jobs file invocationRef does not match invocation metadata")
1685
- if (
1686
- job.participant_ref != identity.participant_ref
1687
- or job.role_execution_ref != identity.role_execution_ref
1688
- or job.execution_label != identity.execution_label
1689
- or job.duty_id != identity.duty_id
1690
- or job.attempt != identity.attempt
1691
- ):
1692
- errors.append("jobs file v2 identity does not match invocation metadata")
1721
+ divergent = divergent_fields(
1722
+ (
1723
+ ("participantRef", identity.participant_ref, job.participant_ref),
1724
+ ("roleExecutionRef", identity.role_execution_ref, job.role_execution_ref),
1725
+ ("executionLabel", identity.execution_label, job.execution_label),
1726
+ ("dutyId", identity.duty_id, job.duty_id),
1727
+ ("attempt", identity.attempt, job.attempt),
1728
+ ),
1729
+ left="metadata",
1730
+ right="job",
1731
+ )
1732
+ if divergent:
1733
+ errors.append(
1734
+ "jobs file v2 identity does not match invocation metadata — "
1735
+ + "; ".join(divergent)
1736
+ )
1693
1737
  prompt = metadata.get("prompt")
1694
1738
  metadata_prompt_value = (
1695
1739
  string_value(prompt.get("path")) if isinstance(prompt, Mapping) else ""
@@ -1734,19 +1778,50 @@ def _v2_job_authority_errors(
1734
1778
  assignment=parsed_assignment,
1735
1779
  )
1736
1780
  )
1737
- if (
1738
- not isinstance(execution, Mapping)
1739
- or not isinstance(participant, Mapping)
1740
- or not isinstance(assignment, Mapping)
1741
- or execution.get("participantRef") != job.participant_ref
1742
- or execution.get("executionLabel") != job.execution_label
1743
- or execution.get("role") != job.role
1744
- or execution.get("role") != duty_role
1745
- or execution.get("provider") != job.provider
1746
- or assignment.get("modelExecutionValue") != job.model_execution_value
1747
- or metadata.get("dispatchKind") != job.dispatch_kind
1748
- ):
1749
- errors.append("jobs file v2 identity does not match role execution authority")
1781
+ missing = [
1782
+ name for name, value in (
1783
+ ("roleExecution", execution),
1784
+ ("participant", participant),
1785
+ ("modelAssignment", assignment),
1786
+ )
1787
+ if not isinstance(value, Mapping)
1788
+ ]
1789
+ if missing:
1790
+ errors.append(
1791
+ "jobs file v2 identity does not match role execution authority: "
1792
+ f"the run manifest has no {', '.join(missing)} for this job"
1793
+ )
1794
+ return errors
1795
+ # 어긋난 필드를 이름으로 댄다. 일곱 조건을 한 문장으로 접던 동안, dispatchKind
1796
+ # 하나가 틀린 경우와 provider 가 틀린 경우가 같은 문장으로 보고됐다 —
1797
+ # 재시도 프롬프트를 만들다 `initial-retry` 로 적은 리드가 원인을 좁히지
1798
+ # 못했다. 판정에 쓴 값은 여기 다 있으므로 굳이 감출 이유가 없다.
1799
+ divergent = divergent_fields(
1800
+ (
1801
+ ("participantRef",
1802
+ execution.get("participantRef"), job.participant_ref),
1803
+ ("executionLabel",
1804
+ execution.get("executionLabel"), job.execution_label),
1805
+ ("role", execution.get("role"), job.role),
1806
+ ("modelExecutionValue",
1807
+ assignment.get("modelExecutionValue"), job.model_execution_value),
1808
+ ("dispatchKind",
1809
+ metadata.get("dispatchKind"), job.dispatch_kind),
1810
+ ("provider", execution.get("provider"), job.provider),
1811
+ ),
1812
+ left="manifest",
1813
+ right="job",
1814
+ )
1815
+ if execution.get("role") != duty_role:
1816
+ divergent.append(
1817
+ f"role: roleExecution={execution.get('role')!r} "
1818
+ f"duty {job.duty_id!r} requires {duty_role!r}"
1819
+ )
1820
+ if divergent:
1821
+ errors.append(
1822
+ "jobs file v2 identity does not match role execution authority — "
1823
+ + "; ".join(divergent)
1824
+ )
1750
1825
  return errors
1751
1826
 
1752
1827
 
@@ -15,6 +15,7 @@ from . import improvement_lenses, worktree_registry
15
15
  from .models import provider_wrappers
16
16
  from .workers import resolve_profile_workers
17
17
  from .worktree import is_git_work_tree, main_worktree_path
18
+ from .json_boundary import JsonBoundaryError, load_owned_object
18
19
 
19
20
  SUPPORTED_PHASES: tuple[str, ...] = (
20
21
  "implementation",
@@ -156,7 +157,9 @@ def _architecture_declaration_check(project_root: Path) -> DoctorCheck | None:
156
157
  value is read here: `resolve_architecture()` maps absent and `none` alike.
157
158
  """
158
159
  try:
159
- payload = json.loads(project_json_path(project_root).read_text(encoding="utf-8"))
160
+ payload = load_owned_object(
161
+ project_json_path(project_root), artifact="project configuration"
162
+ )
160
163
  except (OSError, ValueError):
161
164
  return None
162
165
  architecture = payload.get("architecture") if isinstance(payload, dict) else None
@@ -383,8 +386,8 @@ def _worktree_registry_check() -> DoctorCheck:
383
386
  if not path.exists():
384
387
  return _ok("worktree registry", f"no registry yet ({path})")
385
388
  try:
386
- data = json.loads(path.read_text())
387
- except (OSError, json.JSONDecodeError) as exc:
389
+ data = load_owned_object(path, artifact="worktree registry")
390
+ except JsonBoundaryError as exc:
388
391
  return _fail("worktree registry", f"{path} is unreadable: {exc}")
389
392
  if not isinstance(data, dict) or not isinstance(data.get("tasks"), dict):
390
393
  return _fail("worktree registry", f"{path} is missing a valid 'tasks' table")