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
@@ -15,7 +15,9 @@ from datetime import datetime, timezone
15
15
  from pathlib import Path
16
16
 
17
17
  from okstra_ctl.ids import slugify_task_segment
18
+ from okstra_ctl.fixed_text import line, scalar
18
19
  from okstra_ctl.paths import task_dir, task_manifest_file
20
+ from okstra_ctl.json_boundary import load_owned_object, write_owned_object_atomic
19
21
  from okstra_project import (
20
22
  ResolverError,
21
23
  StateError,
@@ -26,8 +28,23 @@ from okstra_project import (
26
28
  ALLOWED_WORK_STATUSES = ("todo", "in-progress", "blocked", "done")
27
29
 
28
30
 
29
- def _emit(payload: dict) -> None:
30
- print(json.dumps(payload, ensure_ascii=False, indent=2))
31
+ def _emit(payload: dict, *, text: bool = False) -> None:
32
+ if not text:
33
+ print(json.dumps(payload, ensure_ascii=False, indent=2))
34
+ return
35
+ lines = ["# Okstra Work Status Result", ""]
36
+ for key, label in (
37
+ ("ok", "OK"), ("stage", "Stage"), ("reason", "Reason"),
38
+ ("taskKey", "Task key"), ("previousWorkStatus", "Previous work status"),
39
+ ("workStatus", "Work status"), ("workStatusUpdatedAt", "Updated at"),
40
+ ("workStatusNote", "Note"), ("taskManifestPath", "Task manifest"),
41
+ ):
42
+ if key in payload:
43
+ lines.append(line(label, payload.get(key)).rstrip("\n"))
44
+ for match in payload.get("matches", []) if isinstance(payload.get("matches"), list) else []:
45
+ if isinstance(match, dict):
46
+ lines.append(f"- Match: `{scalar(match.get('taskKey'))}`")
47
+ print("\n".join(lines))
31
48
 
32
49
 
33
50
  def _manifest_path(project_root: Path, entry: dict) -> Path:
@@ -44,7 +61,7 @@ def _manifest_path(project_root: Path, entry: dict) -> Path:
44
61
  return task_manifest_file(task_dir(project_root, group_seg, id_seg))
45
62
 
46
63
 
47
- def main(argv: list[str] | None = None) -> int:
64
+ def _parse_args(argv: list[str] | None):
48
65
  parser = argparse.ArgumentParser(
49
66
  description="Set a task's user-managed workStatus in task-manifest.json."
50
67
  )
@@ -64,13 +81,16 @@ def main(argv: list[str] | None = None) -> int:
64
81
  parser.add_argument("--project-root", default="", help="project root for catalog lookup")
65
82
  parser.add_argument("--cwd", default=".", help="cwd for project root resolution")
66
83
  parser.add_argument("--json", action="store_true", help="emit JSON (always on)")
67
- args = parser.parse_args(argv)
84
+ parser.add_argument("--text", action="store_true", help="emit fixed text fields")
85
+ return parser.parse_args(argv)
86
+
68
87
 
88
+ def _resolve_entry(args):
69
89
  try:
70
90
  project_root = resolve_project_root(explicit_root=args.project_root, cwd=args.cwd)
71
91
  except ResolverError as exc:
72
- _emit({"ok": False, "stage": "resolve", "reason": str(exc)})
73
- return 2
92
+ _emit({"ok": False, "stage": "resolve", "reason": str(exc)}, text=args.text)
93
+ return None, None, 2
74
94
 
75
95
  token = args.token
76
96
  task_group = args.task_group
@@ -84,17 +104,42 @@ def main(argv: list[str] | None = None) -> int:
84
104
  project_root, token, task_group=task_group or None
85
105
  )
86
106
  except StateError as exc:
87
- _emit({"ok": False, "stage": "catalog", "reason": str(exc)})
88
- return 2
107
+ _emit({"ok": False, "stage": "catalog", "reason": str(exc)}, text=args.text)
108
+ return None, None, 2
89
109
 
90
110
  if not matches:
91
- _emit({"ok": False, "stage": "not-found", "token": args.token})
92
- return 1
111
+ _emit({"ok": False, "stage": "not-found", "token": args.token}, text=args.text)
112
+ return None, None, 1
93
113
  if len(matches) > 1:
94
- _emit({"ok": False, "stage": "ambiguous", "matches": matches})
95
- return 2
114
+ _emit({"ok": False, "stage": "ambiguous", "matches": matches}, text=args.text)
115
+ return None, None, 2
116
+ return project_root, matches[0], 0
117
+
118
+
119
+ def _updated_payload(manifest_path: Path, manifest: dict, entry: dict, args) -> dict:
120
+ previous = manifest.get("workStatus", "")
121
+ manifest["workStatus"] = args.status
122
+ manifest["workStatusUpdatedAt"] = datetime.now(timezone.utc).strftime(
123
+ "%Y-%m-%dT%H:%M:%SZ"
124
+ )
125
+ if args.note is not None:
126
+ manifest["workStatusNote"] = args.note
127
+ write_owned_object_atomic(manifest_path, manifest, artifact="task manifest")
128
+ return {
129
+ "ok": True, "taskKey": entry.get("taskKey", ""),
130
+ "previousWorkStatus": previous, "workStatus": args.status,
131
+ "workStatusUpdatedAt": manifest["workStatusUpdatedAt"],
132
+ "workStatusNote": manifest.get("workStatusNote", ""),
133
+ "taskManifestPath": str(manifest_path),
134
+ }
96
135
 
97
- entry = matches[0]
136
+
137
+ def main(argv: list[str] | None = None) -> int:
138
+ args = _parse_args(argv)
139
+ project_root, entry, resolution_exit = _resolve_entry(args)
140
+ if resolution_exit:
141
+ return resolution_exit
142
+ assert project_root is not None and entry is not None
98
143
  manifest_path = _manifest_path(project_root, entry)
99
144
  if not manifest_path.exists():
100
145
  _emit(
@@ -103,11 +148,12 @@ def main(argv: list[str] | None = None) -> int:
103
148
  "stage": "manifest-missing",
104
149
  "taskKey": entry.get("taskKey", ""),
105
150
  "taskManifestPath": str(manifest_path),
106
- }
151
+ },
152
+ text=args.text,
107
153
  )
108
154
  return 1
109
155
  try:
110
- manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
156
+ manifest = load_owned_object(manifest_path, artifact="task manifest")
111
157
  except ValueError as exc:
112
158
  _emit(
113
159
  {
@@ -115,32 +161,12 @@ def main(argv: list[str] | None = None) -> int:
115
161
  "stage": "manifest-invalid",
116
162
  "taskManifestPath": str(manifest_path),
117
163
  "reason": str(exc),
118
- }
164
+ },
165
+ text=args.text,
119
166
  )
120
167
  return 1
121
168
 
122
- previous = manifest.get("workStatus", "")
123
- manifest["workStatus"] = args.status
124
- manifest["workStatusUpdatedAt"] = datetime.now(timezone.utc).strftime(
125
- "%Y-%m-%dT%H:%M:%SZ"
126
- )
127
- if args.note is not None:
128
- manifest["workStatusNote"] = args.note
129
- manifest_path.write_text(
130
- json.dumps(manifest, indent=2, ensure_ascii=False) + "\n", encoding="utf-8"
131
- )
132
-
133
- _emit(
134
- {
135
- "ok": True,
136
- "taskKey": entry.get("taskKey", ""),
137
- "previousWorkStatus": previous,
138
- "workStatus": args.status,
139
- "workStatusUpdatedAt": manifest["workStatusUpdatedAt"],
140
- "workStatusNote": manifest.get("workStatusNote", ""),
141
- "taskManifestPath": str(manifest_path),
142
- }
143
- )
169
+ _emit(_updated_payload(manifest_path, manifest, entry, args), text=args.text)
144
170
  return 0
145
171
 
146
172
 
@@ -1,8 +1,8 @@
1
1
  """Derive the fix-run carry for an implementation stage whose previous run
2
2
  ended with one or more verifier FAIL verdicts.
3
3
 
4
- Pure read-side: scans the stage run directory's final-report data.json files
5
- (the JSON SSOT written by the report writer) and the stage worktree HEAD.
4
+ Pure read-side: scans the stage run directory's assembled final-report data.json
5
+ files and the stage worktree HEAD.
6
6
  Returns None when the upcoming run is not a fix run, so callers can treat
7
7
  "no carry" and "first run on this stage" identically.
8
8
  """
@@ -14,6 +14,8 @@ import subprocess
14
14
  from dataclasses import dataclass
15
15
  from pathlib import Path
16
16
 
17
+ from .json_boundary import JsonBoundaryError, load_owned_object
18
+
17
19
  _REPORT_RE = re.compile(r"final-report-implementation-(\d+)\.data\.json$")
18
20
 
19
21
 
@@ -47,8 +49,8 @@ class StageFixCarry:
47
49
  "`report-writer.md` § Fix-run incremental authoring).",
48
50
  "This block is inlined into the executor and verifier prompts by "
49
51
  "`initial_prompt_materialization`; do not transcribe it by hand. "
50
- "Lead duties: pass the previous data.json path above to the "
51
- "report-writer dispatch so it authors incrementally.",
52
+ "Lead duties: pass the previous data.json path above as read context "
53
+ "to the report-writer dispatch so it updates only changed narrative blocks.",
52
54
  ])
53
55
  return "\n".join(lines)
54
56
 
@@ -60,8 +62,8 @@ def derive_stage_fix_carry(
60
62
  if report is None:
61
63
  return None
62
64
  try:
63
- data = json.loads(report.read_text(encoding="utf-8"))
64
- except (OSError, json.JSONDecodeError):
65
+ data = load_owned_object(report, artifact="implementation final report")
66
+ except (OSError, JsonBoundaryError):
65
67
  return None
66
68
  impl = data.get("implementation")
67
69
  if not isinstance(impl, dict):
@@ -4,8 +4,17 @@
4
4
  이 모듈은 그 사실만 모아 준다 — 무엇을 계획해야 하는지는 말하지 않는다.
5
5
 
6
6
  판정은 소유하지 않는다. 상태 어휘와 lifecycle 판정은 stage_targets 가, stage
7
- map 의 출처 판정은 stage_map.load_task_stage_map 이 소유한다. 여기서는 둘을
8
- 잇고 직렬화만 한다.
7
+ map 의 출처 판정은 stage_map 이 소유한다. 여기서는 둘을 잇고 직렬화만 한다.
8
+
9
+ 원장은 두 질문에 답하고, 답의 출처가 서로 다르다.
10
+
11
+ - "무엇이 이미 지어졌나" — carry 사이드카가 가리키는 계획이 답한다. 실행이
12
+ 실제로 따른 문서이기 때문이다.
13
+ - "어떤 stage 번호가 이미 쓰였나" — 최신 계획이 답한다. ADR-0015 의
14
+ append-only 는 최신 계획의 `max` 로만 판정할 수 있다.
15
+
16
+ 한 소스가 둘 다 답하면, 완료 이후 stage 를 덧붙인 계획이 있을 때 그 번호가
17
+ 비어 있는 것처럼 보이고 새 stage 가 그 번호를 다시 받는다.
9
18
  """
10
19
  from __future__ import annotations
11
20
 
@@ -15,25 +24,31 @@ from typing import Any
15
24
 
16
25
  from .consumers import read_stage_consumer_state
17
26
  from .paths import RunRef
18
- from .stage_map import StageMapError, load_task_stage_map
27
+ from .stage_map import (
28
+ StageMapError,
29
+ load_latest_plan_stage_map,
30
+ load_task_stage_map,
31
+ )
19
32
  from .stage_targets import stage_lifecycle_snapshot_from_state
20
33
  from .task_target import infer_project_root
21
34
 
22
35
 
23
36
  def build_stage_ledger(task_root: Path) -> dict[str, Any] | None:
24
- """이 task 의 Stage 원장. 계획 리포트가 아직 없으면 ``None``.
37
+ """이 task 의 Stage 원장.
38
+
39
+ 세 결과를 구분한다. 셋이 하나로 접히면 소비처가 "계획이 아직 없다" 와
40
+ "계획을 못 읽었다" 를 같은 것으로 읽는다.
25
41
 
26
- ``None`` 리스트를 구분한다 계획이 없는 첫 run 과 stage 가 하나도
27
- 없는 계획은 저작 쪽에 다른 뜻이다.
42
+ - ``None`` 계획 리포트가 아직 없다. task 계획 run 이다.
43
+ - ``{"unreadable": <사유>}`` — 계획은 있는데 Stage Map 을 못 읽었다.
44
+ - 그 외 — 사실 기록.
28
45
  """
29
46
  task_root = Path(task_root)
30
47
  try:
31
- snapshot = load_task_stage_map(task_root, {})
32
- except StageMapError:
33
- # 계획 출처가 충돌하거나 읽히지 않는 경우. 원장을 추측해 싣느니 싣지
34
- # 않는다 틀린 원장은 없는 원장보다 나쁘다.
35
- return None
36
- if snapshot.state != "ready" or not snapshot.stages:
48
+ latest = load_latest_plan_stage_map(task_root)
49
+ except StageMapError as exc:
50
+ return {"unreadable": _failure_reason(task_root, exc)}
51
+ if latest.state != "ready" or not latest.stages:
37
52
  return None
38
53
 
39
54
  plan_run_root = RunRef.from_task_root(
@@ -43,20 +58,119 @@ def build_stage_ledger(task_root: Path) -> dict[str, Any] | None:
43
58
  # 같은 멱등 복구이며, 이걸 건너뛰면 크래시 창에 걸린 완료 stage 가 원장에
44
59
  # 미완으로 실려 저작 쪽이 이미 구현된 stage 를 다시 계획한다.
45
60
  state = read_stage_consumer_state(plan_run_root, recover_from_carry=True)
46
- lifecycle = stage_lifecycle_snapshot_from_state(snapshot.stages, state)
47
- return {
48
- "sourcePlan": _project_relative(task_root, snapshot.source_plan_path),
49
- "stages": lifecycle.ledger_records(),
61
+ lifecycle = stage_lifecycle_snapshot_from_state(latest.stages, state)
62
+ records = lifecycle.ledger_records()
63
+
64
+ built_from, divergence = _built_from(task_root, latest)
65
+ divergence += _renumbering_divergence(
66
+ latest, built_from, state.done_stages
67
+ )
68
+ ledger: dict[str, Any] = {
69
+ "sourcePlan": _project_relative(
70
+ task_root, built_from.source_plan_path if built_from else ""
71
+ ),
72
+ "latestPlan": _project_relative(task_root, latest.source_plan_path),
73
+ "stages": records,
50
74
  }
75
+ if divergence:
76
+ ledger["planDivergence"] = divergence
77
+ return ledger
51
78
 
52
79
 
53
80
  def render_stage_ledger(ledger: dict[str, Any] | None) -> str:
54
- """packet 에 실릴 JSON 본문. 원장이 없으면 빈 문자열."""
55
- if not ledger:
81
+ """packet 에 실릴 JSON 본문. 사실 기록이 없으면 빈 문자열."""
82
+ if not ledger or ledger.get("unreadable"):
56
83
  return ""
57
84
  return json.dumps(ledger, ensure_ascii=False, indent=2)
58
85
 
59
86
 
87
+ def stage_ledger_notice(ledger: dict[str, Any] | None) -> str:
88
+ """원장을 실을 수 없는 사유. 실을 수 있으면 빈 문자열.
89
+
90
+ 평문이다. 실패 통지는 저작 쪽이 파싱할 데이터가 아니라 읽을 판정이고,
91
+ JSON 표면을 하나 더 만들면 그만큼 오독할 키가 늘어난다.
92
+ """
93
+ if not ledger:
94
+ return ""
95
+ return str(ledger.get("unreadable") or "")
96
+
97
+
98
+ def _built_from(task_root: Path, latest: Any):
99
+ """완료된 stage 가 따른 계획. 못 읽으면 ``(None, [사유])``.
100
+
101
+ 이걸 못 읽어도 원장 자체는 낼 수 있다 — 상태는 consumers 원장에서 오고,
102
+ stage 목록은 최신 계획에서 온다. 잃는 것은 번호 재배치 검사뿐이므로,
103
+ 실패를 원장 부재로 승격하지 않고 그 사실만 함께 싣는다.
104
+ """
105
+ try:
106
+ return load_task_stage_map(task_root, {}), []
107
+ except StageMapError as exc:
108
+ return None, [
109
+ "could not read the plan the completed stages were built against, "
110
+ "so stage renumbering could not be checked: "
111
+ + _failure_reason(task_root, exc)
112
+ ]
113
+
114
+
115
+ def _renumbering_divergence(
116
+ latest: Any,
117
+ built_from: Any,
118
+ done_stages: set[int],
119
+ ) -> list[str]:
120
+ """완료된 stage 가 두 계획에서 같은 작업을 가리키는지.
121
+
122
+ ADR-0015 는 stage 번호의 재사용과 재배치를 금지하므로, 규칙이 지켜졌다면
123
+ 최신 계획 위에 실행 상태를 겹쳐 쓰는 것이 안전하다. 지켜졌는지는 검사할
124
+ 사실이지 가정할 사실이 아니다 — 과거 replan 이 번호를 다시 매겼다면 완료
125
+ 커밋이 엉뚱한 stage 에 귀속되고, 그 오류는 통합 시점까지 조용하다.
126
+ """
127
+ if built_from is None or built_from.state != "ready":
128
+ return []
129
+ if built_from.source_plan_path == latest.source_plan_path:
130
+ # 완료된 stage 를 지은 계획이 곧 최신 계획이다. 비교할 두 번째 계획이
131
+ # 없으므로 재배치가 일어날 자리 자체가 없다.
132
+ return []
133
+ latest_titles = {
134
+ int(row["stage_number"]): str(row.get("title") or "")
135
+ for row in latest.stages
136
+ }
137
+ built_titles = {
138
+ int(row["stage_number"]): str(row.get("title") or "")
139
+ for row in built_from.stages
140
+ }
141
+ notes: list[str] = []
142
+ for stage in sorted(done_stages):
143
+ if stage not in built_titles:
144
+ continue
145
+ if stage not in latest_titles:
146
+ notes.append(
147
+ f"stage {stage} is recorded done but the latest plan does not "
148
+ "declare it; a completed stage was dropped rather than cancelled"
149
+ )
150
+ continue
151
+ if built_titles[stage] != latest_titles[stage]:
152
+ notes.append(
153
+ f"stage {stage} names different work in the two plans "
154
+ f"(built: {built_titles[stage]!r}; latest: "
155
+ f"{latest_titles[stage]!r}); its completion may be attributed "
156
+ "to the wrong stage"
157
+ )
158
+ return notes
159
+
160
+
161
+ def _failure_reason(task_root: Path, exc: StageMapError) -> str:
162
+ """StageMapError 를 프로젝트 상대 경로로 낮춘 한 줄."""
163
+ parts = [exc.reason]
164
+ if exc.source_plan_path:
165
+ parts.append(f"source={_project_relative(task_root, exc.source_plan_path)}")
166
+ if exc.conflicting_paths:
167
+ conflicts = ", ".join(
168
+ _project_relative(task_root, path) for path in exc.conflicting_paths
169
+ )
170
+ parts.append(f"conflicts={conflicts}")
171
+ return "; ".join(parts)
172
+
173
+
60
174
  def _project_relative(task_root: Path, source_plan_path: str) -> str:
61
175
  """절대 경로를 프로젝트 상대로 낮춘다.
62
176
 
@@ -7,6 +7,9 @@ from dataclasses import dataclass
7
7
  from pathlib import Path
8
8
  from typing import Any, Iterable
9
9
 
10
+ from .final_report_paths import final_report_data_path, is_full_reading_copy_path
11
+ from .json_boundary import JsonBoundaryError, load_owned_object
12
+ from .fixed_text import line, value_lines
10
13
  from .md_table import is_separator_row, split_pipe_row
11
14
  from .paths import RunRef
12
15
  from .plan_run_root import list_implementation_planning_reports
@@ -47,6 +50,24 @@ class StageMapSnapshot:
47
50
  stages: list[dict[str, Any]]
48
51
 
49
52
 
53
+ def render_stage_map_text(payload: dict[str, Any]) -> str:
54
+ """모델 소비용 Stage Map 스냅샷을 고정 텍스트로 투영한다."""
55
+ rows = ["Okstra stage map\n"]
56
+ rows.append(line("Status", "ready" if payload.get("ok") else "error"))
57
+ for label, key in (
58
+ ("Task key", "taskKey"), ("Task root", "taskRoot"),
59
+ ("State", "state"), ("Source plan path", "sourcePlanPath"),
60
+ ):
61
+ rows.append(line(label, payload.get(key)))
62
+ for label, key in (("Stages", "stages"), ("Done stages", "doneStages"),
63
+ ("Planning", "planning")):
64
+ rows.extend(value_lines(label, payload.get(key)))
65
+ if not payload.get("ok"):
66
+ rows.append(line("Failure stage", payload.get("stage")))
67
+ rows.append(line("Failure reason", payload.get("reason")))
68
+ return "".join(rows)
69
+
70
+
50
71
  # `stepwiseExecution` is what the stage DOES; the other three are what becomes
51
72
  # true when it is finished. A schedule carrying only the latter reads as an
52
73
  # analysis of a plan rather than the plan itself.
@@ -353,8 +374,8 @@ def parse_stage_map_file(plan_path: Path) -> list[StageMapStage]:
353
374
  resolved = Path(plan_path).resolve()
354
375
  if is_report_record_path(resolved):
355
376
  try:
356
- data = json.loads(resolved.read_text(encoding="utf-8"))
357
- except (OSError, UnicodeError, json.JSONDecodeError) as exc:
377
+ data = load_owned_object(resolved, artifact="planning final report")
378
+ except (OSError, UnicodeError, JsonBoundaryError) as exc:
358
379
  raise StageMapError("stage_map", str(exc), str(resolved)) from exc
359
380
  if not isinstance(data, dict):
360
381
  raise StageMapError(
@@ -364,8 +385,8 @@ def parse_stage_map_file(plan_path: Path) -> list[StageMapStage]:
364
385
  return _parse_stage_map_markdown(resolved)
365
386
 
366
387
 
367
- def schema_v2_report(plan_path: Path) -> dict[str, Any]:
368
- """The schema-v2 report record as a whole, `{}` for a v1 markdown path.
388
+ def structured_report(plan_path: Path) -> dict[str, Any]:
389
+ """계약 2.0·3.0 구조화 리포트를 반환하고 Markdown에는 값을 반환한다.
369
390
 
370
391
  Public because every caller that must branch on report schema needs it —
371
392
  including `validators/validate-implementation-plan-stages.py`, which is a
@@ -378,17 +399,22 @@ def schema_v2_report(plan_path: Path) -> dict[str, Any]:
378
399
  if not is_report_record_path(resolved) or not resolved.exists():
379
400
  return {}
380
401
  try:
381
- data = json.loads(resolved.read_text(encoding="utf-8"))
382
- except (OSError, UnicodeError, json.JSONDecodeError) as exc:
402
+ data = load_owned_object(resolved, artifact="planning final report")
403
+ except (OSError, UnicodeError, JsonBoundaryError) as exc:
383
404
  raise StageMapError("stage_map", str(exc), str(resolved)) from exc
384
- if not isinstance(data, dict) or data.get("schemaVersion") != "2.0":
405
+ if not isinstance(data, dict) or data.get("schemaVersion") not in {"2.0", "3.0"}:
385
406
  return {}
386
407
  return data
387
408
 
388
409
 
410
+ def schema_v2_report(plan_path: Path) -> dict[str, Any]:
411
+ """기존 호출자를 위한 구조화 리포트 판독 별칭."""
412
+ return structured_report(plan_path)
413
+
414
+
389
415
  def _planning_section(markdown_path: Path) -> dict[str, Any]:
390
416
  """The report's `implementationPlanning` block, `{}` for v1."""
391
- planning = schema_v2_report(markdown_path).get("implementationPlanning")
417
+ planning = structured_report(markdown_path).get("implementationPlanning")
392
418
  return planning if isinstance(planning, dict) else {}
393
419
 
394
420
 
@@ -412,7 +438,7 @@ def _stage_narratives(value: Any) -> dict[int, dict[str, Any]]:
412
438
 
413
439
  def load_planning_detail(markdown_path: Path) -> PlanningDetail:
414
440
  """Read one report's narrative rows; empty for a schema-v1 report."""
415
- report = schema_v2_report(markdown_path)
441
+ report = structured_report(markdown_path)
416
442
  planning = report.get("implementationPlanning")
417
443
  if not isinstance(planning, dict) or not planning:
418
444
  return PlanningDetail({}, {})
@@ -456,9 +482,34 @@ def stage_map_records(stages: Iterable[StageMapStage]) -> list[dict[str, Any]]:
456
482
  ]
457
483
 
458
484
 
485
+ def _latest_plan_report(task_root: Path) -> Path | None:
486
+ reports_dir = RunRef.from_task_root(
487
+ task_root, "implementation-planning"
488
+ ).reports_dir
489
+ reports = list_implementation_planning_reports(reports_dir)
490
+ return reports[0] if reports else None
491
+
492
+
493
+ def _snapshot_for(source: Path | None) -> StageMapSnapshot:
494
+ if source is None:
495
+ return StageMapSnapshot("missing", "", [])
496
+ resolved = source.resolve()
497
+ return StageMapSnapshot(
498
+ "ready",
499
+ str(resolved),
500
+ stage_map_records(parse_stage_map_file(resolved)),
501
+ )
502
+
503
+
459
504
  def load_task_stage_map(
460
505
  task_root: Path, manifest: dict[str, Any],
461
506
  ) -> StageMapSnapshot:
507
+ """완료된 stage 를 무엇으로 지었는지 답하는 Stage Map.
508
+
509
+ carry 사이드카가 가리키는 계획을 우선한다 — 그게 실행이 실제로 따른
510
+ 문서이기 때문이다. "어떤 stage 번호가 이미 쓰였나" 는 다른 질문이며
511
+ `load_latest_plan_stage_map` 이 답한다.
512
+ """
462
513
  carried = _unique_carry_source_paths(task_root)
463
514
  if len(carried) > 1:
464
515
  paths = tuple(str(path) for path in carried)
@@ -467,22 +518,19 @@ def load_task_stage_map(
467
518
  "implementation carries reference different source plans",
468
519
  conflicting_paths=paths,
469
520
  )
470
- if carried:
471
- source = carried[0]
472
- else:
473
- reports_dir = RunRef.from_task_root(
474
- task_root, "implementation-planning"
475
- ).reports_dir
476
- reports = list_implementation_planning_reports(reports_dir)
477
- source = reports[0] if reports else None
478
- if source is None:
479
- return StageMapSnapshot("missing", "", [])
480
- resolved = source.resolve()
481
- return StageMapSnapshot(
482
- "ready",
483
- str(resolved),
484
- stage_map_records(parse_stage_map_file(resolved)),
485
- )
521
+ source = carried[0] if carried else _latest_plan_report(task_root)
522
+ return _snapshot_for(source)
523
+
524
+
525
+ def load_latest_plan_stage_map(task_root: Path) -> StageMapSnapshot:
526
+ """이 task 의 최신 계획 리포트가 선언한 Stage Map.
527
+
528
+ 번호 점유의 판정 기준이다. ADR-0015 stage 번호를 append-only 로 두고
529
+ stage `max+1` 로 붙이라고 정하는데, 그 `max` 는 최신 계획에서만
530
+ 나온다 완료된 stage 를 지을 때 쓴 계획 뒤에 stage 를 덧붙인 계획이
531
+ 있으면 번호들도 이미 점유된 상태다.
532
+ """
533
+ return _snapshot_for(_latest_plan_report(task_root))
486
534
 
487
535
 
488
536
  def _unique_carry_source_paths(task_root: Path) -> list[Path]:
@@ -490,15 +538,35 @@ def _unique_carry_source_paths(task_root: Path) -> list[Path]:
490
538
  paths: set[Path] = set()
491
539
  for carry_path in sorted(carry_dir.glob("stage-*.json")):
492
540
  try:
493
- carry = json.loads(carry_path.read_text(encoding="utf-8"))
494
- except (OSError, UnicodeError, json.JSONDecodeError):
541
+ carry = load_owned_object(carry_path, artifact="implementation stage carry")
542
+ except (OSError, UnicodeError, JsonBoundaryError):
495
543
  continue
496
544
  value = carry.get("sourcePlanPath") if isinstance(carry, dict) else None
497
545
  if isinstance(value, str) and value:
498
- paths.add(_resolve_plan_path(task_root, value).resolve())
546
+ # carry 의 이 값은 executor 워커가 손으로 적는 자유 문자열이고,
547
+ # `--approved-plan` 이 레코드 전용으로 조여지기 전에 준비된 run 은
548
+ # 읽기본(`.md`) 경로를 남겼다. v2 리포트의 `.md` 에는 Stage Map
549
+ # 섹션이 없으므로 그대로 읽으면 원장이 통째로 사라진다. 같은 legacy
550
+ # 포인터 정규화를 run-index·타임라인 소비자는 이미 하고 있다.
551
+ # 읽는 쪽에서만 낮춘다 — 동결된 carry 사이드카는 고치지 않는다.
552
+ paths.add(_carry_source_path(task_root, value))
499
553
  return sorted(paths, key=str)
500
554
 
501
555
 
556
+ def _carry_source_path(task_root: Path, value: str) -> Path:
557
+ """carry 가 가리키는 계획의 읽을 경로.
558
+
559
+ 레코드 형제가 실재할 때만 읽기본(`.md`)을 레코드로 낮춘다. schema-v1 계획은
560
+ 레코드가 아예 없고 마크다운이 유일한 정본이므로, 무조건 낮추면 v1 태스크의
561
+ Stage Map 을 없는 파일에서 찾게 된다.
562
+ """
563
+ resolved = _resolve_plan_path(task_root, value).resolve()
564
+ if not is_full_reading_copy_path(resolved):
565
+ return resolved
566
+ record = final_report_data_path(resolved)
567
+ return record if record.is_file() else resolved
568
+
569
+
502
570
  def _resolve_plan_path(task_root: Path, value: str) -> Path:
503
571
  path = Path(value)
504
572
  if path.is_absolute():
@@ -853,18 +853,17 @@ def integrate_and_teardown_whole_task(
853
853
  stage_map 은 done 행에서 만든다. 정리 대상은 완료된 stage 뿐이고, 계획에만 있고
854
854
  완료되지 않은 stage 는 Phase A 가 어차피 건너뛰기 때문이다.
855
855
  """
856
- from json import loads as _loads
857
-
858
856
  from okstra_project.dirs import okstra_home, project_json_path
859
857
 
860
858
  from . import consumers, worktree_registry
859
+ from .json_boundary import load_owned_object
861
860
  from .locks import worktree_provision_mutex
862
861
  from .paths import task_runs_dir
863
862
  from .stage_integrate import integrate_stages
864
863
 
865
864
  try:
866
- project_id = _loads(
867
- project_json_path(project_root).read_text(encoding="utf-8")
865
+ project_id = load_owned_object(
866
+ project_json_path(project_root), artifact="project config"
868
867
  ).get("projectId", "")
869
868
  except (OSError, ValueError):
870
869
  project_id = ""
@@ -39,6 +39,7 @@ from .ports.worker_dispatch import WorkerDispatchRequest
39
39
  from .registry.host_registry import default_host_registry
40
40
  from .registry.provider_registry import default_provider_registry
41
41
  from .session import observe_lead_session
42
+ from .json_boundary import load_owned_object
42
43
 
43
44
 
44
45
  _SUPPORTED_WRAPPERS = provider_worker_wrappers(default_provider_registry())
@@ -81,7 +82,7 @@ def _add_dispatch_parser(sub) -> None:
81
82
  parser.add_argument("--workers", default="")
82
83
  parser.add_argument("--jobs-file", default="")
83
84
  parser.add_argument("--dry-run", action="store_true")
84
- parser.add_argument("--idle-timeout-seconds", type=int, default=600)
85
+ parser.add_argument("--idle-timeout-seconds", type=int, default=None)
85
86
  parser.add_argument("--dispatch-kind", default="initial")
86
87
 
87
88
 
@@ -417,7 +418,7 @@ def _load_manifest(project_root: str, run_manifest: str) -> dict[str, Any]:
417
418
  def _load_json(path: Path, label: str) -> dict[str, Any]:
418
419
  if not path.is_file():
419
420
  raise DispatchError(f"{label} not found: {path}")
420
- payload = json.loads(path.read_text(encoding="utf-8"))
421
+ payload = load_owned_object(path, artifact=label)
421
422
  if not isinstance(payload, dict):
422
423
  raise DispatchError(f"{label} must be a JSON object: {path}")
423
424
  return payload