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,8 @@ class WorkerDispatchRequest:
15
15
  workspace_root: Path | None = None
16
16
  okstra_bin: Path | None = None
17
17
  requested_workers: tuple[str, ...] = ()
18
- idle_timeout_seconds: int = 600
18
+ # 기본은 "정하지 않음" 이다. 그래야 엔트리포인트가 역할의 예산을 쓴다.
19
+ idle_timeout_seconds: int | None = None
19
20
  dispatch_kind: str = "initial"
20
21
  jobs_file: Path | None = None
21
22
 
@@ -21,6 +21,7 @@ from dataclasses import dataclass
21
21
  from pathlib import Path
22
22
 
23
23
  from okstra_project.dirs import okstra_home, project_json_path
24
+ from .json_boundary import JsonBoundaryError, load_owned_object
24
25
 
25
26
  _DEFAULT_FILENAME = "pr-body.template.md"
26
27
 
@@ -49,8 +50,8 @@ def _read_json_field(path: Path, field: str) -> str:
49
50
  if not path.is_file():
50
51
  return ""
51
52
  try:
52
- data = json.loads(path.read_text(encoding="utf-8"))
53
- except (OSError, json.JSONDecodeError):
53
+ data = load_owned_object(path, artifact="PR template configuration")
54
+ except JsonBoundaryError:
54
55
  return ""
55
56
  if not isinstance(data, dict):
56
57
  return ""
@@ -1,11 +1,11 @@
1
1
  """프로젝트 meta.json 업서트."""
2
2
  from __future__ import annotations
3
3
 
4
- import json
5
- import os
6
4
  from pathlib import Path
7
5
  from typing import Optional
8
6
 
7
+ from .json_boundary import load_owned_object, write_owned_object_atomic
8
+
9
9
 
10
10
  def _project_meta_path(home: Path, project_id: str) -> Path:
11
11
  return home / "projects" / project_id / "meta.json"
@@ -16,7 +16,7 @@ def load_project_meta(home: Path, project_id: str) -> Optional[dict]:
16
16
  p = _project_meta_path(home, project_id)
17
17
  if not p.is_file():
18
18
  return None
19
- return json.loads(p.read_text())
19
+ return load_owned_object(p, artifact="project metadata")
20
20
 
21
21
 
22
22
  def upsert_project_meta(home: Path, project_id: str, *,
@@ -29,7 +29,7 @@ def upsert_project_meta(home: Path, project_id: str, *,
29
29
  target = _project_meta_path(home, project_id)
30
30
  target.parent.mkdir(parents=True, exist_ok=True)
31
31
  if target.is_file():
32
- meta = json.loads(target.read_text())
32
+ meta = load_owned_object(target, artifact="project metadata")
33
33
  else:
34
34
  meta = {
35
35
  "projectId": project_id,
@@ -46,6 +46,4 @@ def upsert_project_meta(home: Path, project_id: str, *,
46
46
  meta["activeCount"] = int(meta.get("activeCount", 0)) + 1
47
47
  if finished:
48
48
  meta["activeCount"] = max(0, int(meta.get("activeCount", 0)) - 1)
49
- tmp = target.with_suffix(".json.tmp")
50
- tmp.write_text(json.dumps(meta, indent=2) + "\n")
51
- os.replace(tmp, target)
49
+ write_owned_object_atomic(target, meta, artifact="project metadata")
@@ -15,6 +15,8 @@ from okstra_ctl import next_phase
15
15
  from okstra_ctl.clarification_items import sidecar_answers, user_response_sidecars
16
16
  from okstra_ctl.ids import slugify_task_segment
17
17
  from okstra_ctl.incremental_scope import preview_link_availability_for_report
18
+ from okstra_ctl.json_boundary import JsonBoundaryError, load_owned_object
19
+ from okstra_ctl.jsonl import append_jsonl
18
20
  from okstra_ctl.paths import task_timeline_file
19
21
  from okstra_project import read_task_key
20
22
  from okstra_ctl.run_context import dir_flock
@@ -29,8 +31,8 @@ def _load_timeline(task_root: Path) -> list[dict]:
29
31
  if not path.is_file():
30
32
  return []
31
33
  try:
32
- data = json.loads(path.read_text(encoding="utf-8"))
33
- except Exception:
34
+ data = load_owned_object(path, artifact="task timeline")
35
+ except (JsonBoundaryError, OSError):
34
36
  return []
35
37
  runs = data.get("runs", [])
36
38
  return runs if isinstance(runs, list) else []
@@ -150,8 +152,7 @@ def append_recap_entry(task_root, project_root, *, kind, mode, question,
150
152
  # 과 같은 append-only idiom — 데이터 fd 가 아닌 별도 .lock 파일을 잠가야
151
153
  # flush 가 락 안에서 끝난다).
152
154
  with dir_flock(recap_dir, ".recap-log.lock"):
153
- with log_path.open("a", encoding="utf-8") as fh:
154
- fh.write(json.dumps(entry, ensure_ascii=False) + "\n")
155
+ append_jsonl(log_path, entry, ensure_ascii=False, compact=False)
155
156
  return {"logPath": project_rel(log_path, project_root), "entry": entry}
156
157
 
157
158
 
@@ -1,17 +1,16 @@
1
1
  """reconciliation/normalization."""
2
2
  from __future__ import annotations
3
3
 
4
- import json
5
- import os
6
4
  from datetime import datetime, timezone
7
5
  from pathlib import Path
8
6
  from typing import Optional
9
7
 
10
- from .jsonl import append_jsonl, rotate_recent_if_needed
8
+ from .jsonl import append_jsonl, rewrite_jsonl, rotate_recent_if_needed
11
9
  from .locks import central_lock
12
10
  from .paths import resolve_under_root
13
11
  from .project_meta import upsert_project_meta
14
12
  from .run_index_row import read_run_index, slim_run_row
13
+ from .json_boundary import JsonBoundaryError, load_owned_object
15
14
 
16
15
 
17
16
  DEFAULT_ABORT_AFTER_SECONDS = 12 * 3600 # 12시간 무진척이면 aborted 로 간주
@@ -70,8 +69,8 @@ def _read_run_manifest_validation(project_root: Path, run_dir_rel: str,
70
69
  if not manifest.is_file():
71
70
  return "not-run"
72
71
  try:
73
- data = json.loads(manifest.read_text())
74
- except (OSError, json.JSONDecodeError):
72
+ data = load_owned_object(manifest, artifact="run manifest")
73
+ except JsonBoundaryError:
75
74
  return "not-run"
76
75
  validation = data.get("validation")
77
76
  if isinstance(validation, dict):
@@ -94,11 +93,7 @@ def _sync_project_index(home: Path, project_id: str, updated_rows: list) -> None
94
93
  src = by_id.get(er.get("runId"))
95
94
  if src:
96
95
  er.update({k: src[k] for k in ("status", "finishedAt", "validation")})
97
- tmp = proj_index.with_suffix(".jsonl.tmp")
98
- with tmp.open("w") as f:
99
- for er in existing:
100
- f.write(json.dumps(slim_run_row(er), separators=(",", ":")) + "\n")
101
- os.replace(tmp, proj_index)
96
+ rewrite_jsonl(proj_index, [slim_run_row(row) for row in existing])
102
97
 
103
98
 
104
99
  def reconcile_active(home: Path, *,
@@ -116,6 +111,20 @@ def reconcile_active(home: Path, *,
116
111
  project=project)
117
112
 
118
113
 
114
+ def _publish_promoted(
115
+ home: Path, active: Path, survivors: list[dict], promoted: list[dict]
116
+ ) -> None:
117
+ rewrite_jsonl(active, [slim_run_row(row) for row in survivors])
118
+ for row in promoted:
119
+ append_jsonl(home / "recent.jsonl", slim_run_row(row))
120
+ _sync_project_index(home, row["projectId"], [row])
121
+ upsert_project_meta(
122
+ home, row["projectId"], project_root=row["projectRoot"],
123
+ when=row["finishedAt"], finished=True,
124
+ )
125
+ rotate_recent_if_needed(home)
126
+
127
+
119
128
  def _reconcile_active_locked(home: Path, *,
120
129
  abort_after_seconds: int,
121
130
  project: Optional[str] = None) -> dict:
@@ -171,18 +180,7 @@ def _reconcile_active_locked(home: Path, *,
171
180
  summary["running"] += 1
172
181
  if not promoted:
173
182
  return summary
174
- tmp = active.with_suffix(".jsonl.tmp")
175
- with tmp.open("w") as f:
176
- for row in survivors:
177
- f.write(json.dumps(slim_run_row(row), separators=(",", ":")) + "\n")
178
- os.replace(tmp, active)
179
- for row in promoted:
180
- append_jsonl(home / "recent.jsonl", slim_run_row(row))
181
- _sync_project_index(home, row["projectId"], [row])
182
- upsert_project_meta(home, row["projectId"],
183
- project_root=row["projectRoot"],
184
- when=row["finishedAt"], finished=True)
185
- rotate_recent_if_needed(home)
183
+ _publish_promoted(home, active, survivors, promoted)
186
184
  return summary
187
185
 
188
186
 
@@ -225,11 +223,7 @@ def _reconcile_recent_locked(home: Path, *, project: Optional[str] = None) -> di
225
223
  summary["completed" if new_status == "completed" else "failed"] += 1
226
224
  if not updated_by_project:
227
225
  return summary
228
- tmp = recent.with_suffix(".jsonl.tmp")
229
- with tmp.open("w") as f:
230
- for row in rows:
231
- f.write(json.dumps(slim_run_row(row), separators=(",", ":")) + "\n")
232
- os.replace(tmp, recent)
226
+ rewrite_jsonl(recent, [slim_run_row(row) for row in rows])
233
227
  for pid, urows in updated_by_project.items():
234
228
  _sync_project_index(home, pid, urows)
235
229
  return summary
@@ -7,6 +7,7 @@ from dataclasses import dataclass
7
7
  from pathlib import Path
8
8
 
9
9
  from ..domain.host import HostAdapterContractError
10
+ from ..json_boundary import JsonBoundaryError, load_owned_object
10
11
 
11
12
 
12
13
  @dataclass(frozen=True)
@@ -66,8 +67,8 @@ def _read_manifest(manifest_path: Path) -> HostManifest:
66
67
 
67
68
  def _read_json_object(manifest_path: Path) -> dict[str, object]:
68
69
  try:
69
- raw = json.loads(manifest_path.read_text(encoding="utf-8"))
70
- except (OSError, json.JSONDecodeError) as exc:
70
+ raw = load_owned_object(manifest_path, artifact="host adapter manifest")
71
+ except JsonBoundaryError as exc:
71
72
  raise HostAdapterContractError(
72
73
  f"invalid host manifest: {manifest_path}"
73
74
  ) from exc
@@ -9,6 +9,7 @@ from pathlib import Path
9
9
  from okstra_project.dirs import okstra_home
10
10
 
11
11
  from ..domain.provider import ProviderSpec, UnknownProviderError
12
+ from ..json_boundary import JsonBoundaryError, load_owned_object
12
13
  from .factory_loader import FactoryLoadError, load_relative_factory
13
14
 
14
15
 
@@ -121,8 +122,8 @@ def _manifest_paths(roots: Iterable[Path]) -> tuple[Path, ...]:
121
122
 
122
123
  def _read_manifest(manifest_path: Path) -> tuple[str, str]:
123
124
  try:
124
- raw = json.loads(manifest_path.read_text(encoding="utf-8"))
125
- except (OSError, json.JSONDecodeError) as exc:
125
+ raw = load_owned_object(manifest_path, artifact="provider adapter manifest")
126
+ except JsonBoundaryError as exc:
126
127
  raise ProviderAdapterContractError(f"invalid provider manifest: {manifest_path}") from exc
127
128
  if not isinstance(raw, dict) or raw.get("schemaVersion") != 1:
128
129
  raise ProviderAdapterContractError(f"invalid provider manifest: {manifest_path}")
@@ -46,6 +46,8 @@ from .registry.host_registry import default_host_registry
46
46
  from .registry.provider_registry import default_provider_registry
47
47
  from .path_hints import compact_active_run_context, hydrate_run_context
48
48
  from .paths import task_timeline_file
49
+ from .json_boundary import JsonBoundaryError, load_owned_object, write_owned_object_atomic
50
+ from .report_contract import CURRENT_REPORT_SCHEMA_VERSION
49
51
  from .worker_artifacts import artifacts_from_context
50
52
  from .workflow import PHASE_SEQUENCE
51
53
 
@@ -73,7 +75,9 @@ def _load_ctx(ctx_arg: str) -> dict:
73
75
  s = ctx_arg.lstrip()
74
76
  if s.startswith("{"):
75
77
  return hydrate_run_context(json.loads(ctx_arg))
76
- return hydrate_run_context(json.loads(Path(ctx_arg).read_text(encoding="utf-8")))
78
+ return hydrate_run_context(
79
+ load_owned_object(Path(ctx_arg), artifact="render context")
80
+ )
77
81
 
78
82
 
79
83
  def _write_text(path: Path, text: str) -> None:
@@ -82,7 +86,7 @@ def _write_text(path: Path, text: str) -> None:
82
86
 
83
87
 
84
88
  def _write_json(path: Path, payload: dict) -> None:
85
- _write_text(path, json.dumps(payload, indent=2, ensure_ascii=False) + "\n")
89
+ write_owned_object_atomic(path, payload, artifact="rendered runtime object")
86
90
 
87
91
 
88
92
  def _execution_identity(ctx: Mapping[str, object]) -> dict:
@@ -873,7 +877,7 @@ def render_task_catalog_discovery(output_path: str, ctx: dict) -> None:
873
877
  entries = []
874
878
  for manifest_path in sorted(tasks_root.rglob(TASK_MANIFEST_FILENAME)):
875
879
  try:
876
- manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
880
+ manifest = load_owned_object(manifest_path, artifact="task manifest")
877
881
  except Exception:
878
882
  continue
879
883
  if not isinstance(manifest, dict):
@@ -891,7 +895,7 @@ def render_task_catalog_discovery(output_path: str, ctx: dict) -> None:
891
895
  latest_run = {}
892
896
  if timeline_file.is_file():
893
897
  try:
894
- payload = json.loads(timeline_file.read_text(encoding="utf-8"))
898
+ payload = load_owned_object(timeline_file, artifact="task timeline")
895
899
  except Exception:
896
900
  payload = {}
897
901
  runs = payload.get("runs", []) if isinstance(payload, dict) else []
@@ -981,7 +985,9 @@ def render_latest_task_discovery(output_path: str, ctx: dict) -> None:
981
985
  task_manifest = {}
982
986
  if task_manifest_path.exists():
983
987
  try:
984
- task_manifest = json.loads(task_manifest_path.read_text(encoding="utf-8"))
988
+ task_manifest = load_owned_object(
989
+ task_manifest_path, artifact="task manifest"
990
+ )
985
991
  except Exception:
986
992
  task_manifest = {}
987
993
  workflow = (
@@ -1343,7 +1349,7 @@ def render_task_manifest(manifest_path: str, ctx: dict) -> None:
1343
1349
  existing = {}
1344
1350
  if path.exists():
1345
1351
  try:
1346
- existing = json.loads(path.read_text(encoding="utf-8"))
1352
+ existing = load_owned_object(path, artifact="task manifest")
1347
1353
  except Exception:
1348
1354
  existing = {}
1349
1355
  reviewers = _resolve_workers(ctx)
@@ -1645,21 +1651,23 @@ def render_run_manifest(run_manifest_path: str, ctx: dict) -> None:
1645
1651
  existing_run_manifest = {}
1646
1652
  if run_manifest_exists:
1647
1653
  try:
1648
- loaded_run_manifest = json.loads(
1649
- run_manifest_file.read_text(encoding="utf-8")
1654
+ loaded_run_manifest = load_owned_object(
1655
+ run_manifest_file, artifact="run manifest"
1650
1656
  )
1651
1657
  existing_run_manifest = (
1652
1658
  loaded_run_manifest
1653
1659
  if isinstance(loaded_run_manifest, dict)
1654
1660
  else {}
1655
1661
  )
1656
- except (OSError, json.JSONDecodeError):
1662
+ except JsonBoundaryError:
1657
1663
  existing_run_manifest = {}
1658
1664
  task_manifest_path = Path(ctx.get("TASK_MANIFEST_PATH", ""))
1659
1665
  task_manifest = {}
1660
1666
  if task_manifest_path.exists():
1661
1667
  try:
1662
- task_manifest = json.loads(task_manifest_path.read_text(encoding="utf-8"))
1668
+ task_manifest = load_owned_object(
1669
+ task_manifest_path, artifact="task manifest"
1670
+ )
1663
1671
  except Exception:
1664
1672
  task_manifest = {}
1665
1673
  reviewers = _resolve_workers(ctx)
@@ -1686,12 +1694,16 @@ def render_run_manifest(run_manifest_path: str, ctx: dict) -> None:
1686
1694
  execution_identity = _execution_identity(ctx)
1687
1695
  payload = {
1688
1696
  "schemaVersion": execution_identity.get("schemaVersion", "1.0"),
1697
+ "reportContractVersion": CURRENT_REPORT_SCHEMA_VERSION,
1689
1698
  "okstraVersion": ctx.get("OKSTRA_VERSION", ""),
1690
1699
  "projectId": ctx.get("PROJECT_ID", ""),
1691
1700
  "taskGroup": ctx.get("TASK_GROUP", ""),
1692
1701
  "taskId": ctx.get("TASK_ID", ""),
1693
1702
  "taskKey": ctx.get("TASK_KEY", ""),
1694
1703
  "taskType": ctx.get("TASK_TYPE", ""),
1704
+ "runTimestamp": ctx.get("RUN_TIMESTAMP_ISO", ""),
1705
+ "reportLanguage": ctx.get("REPORT_LANGUAGE", "en") or "en",
1706
+ "leadModel": ctx.get("LEAD_MODEL", ""),
1695
1707
  # Every other path in this manifest is project-relative; this is the
1696
1708
  # anchor they resolve against. Convergence reads it to place run
1697
1709
  # artifacts, so a manifest without it cannot seed a round.
@@ -1763,6 +1775,10 @@ def render_run_manifest(run_manifest_path: str, ctx: dict) -> None:
1763
1775
  "workerPromptsDirectoryPath": ctx.get("RUN_PROMPTS_RELATIVE_PATH", ""),
1764
1776
  "workerPromptPathByWorkerId": worker_prompt_paths,
1765
1777
  "expectedReportRecordPath": ctx.get("FINAL_REPORT_RECORD_RELATIVE_PATH", ""),
1778
+ "reportNarrativePath": ctx.get("REPORT_WRITER_NARRATIVE_RELATIVE_PATH", ""),
1779
+ "approvalDecisionsPath": ctx.get("APPROVAL_DECISIONS_RELATIVE_PATH", ""),
1780
+ "designPreparationPath": ctx.get("DESIGN_PREPARATION_RELATIVE_PATH", ""),
1781
+ "planBodyVerificationPath": ctx.get("PLAN_BODY_VERIFICATION_STATE_RELATIVE_PATH", ""),
1766
1782
  "expectedStatusPath": ctx.get("FINAL_STATUS_RELATIVE_PATH", ""),
1767
1783
  "teamStatePath": ctx.get("TEAM_STATE_RELATIVE_PATH", ""),
1768
1784
  "activeRunContextPath": ctx.get("ACTIVE_RUN_CONTEXT_RELATIVE_PATH", ""),
@@ -1847,7 +1863,10 @@ def render_run_manifest(run_manifest_path: str, ctx: dict) -> None:
1847
1863
  payload["analysisScopeConfirmation"] = scope_confirmation
1848
1864
  if ctx.get("FIX_CYCLE_ID"):
1849
1865
  payload["fixCycleId"] = ctx["FIX_CYCLE_ID"]
1850
- if ctx.get("TASK_TYPE") == "implementation-planning":
1866
+ if (
1867
+ CURRENT_REPORT_SCHEMA_VERSION == "3.0"
1868
+ or ctx.get("TASK_TYPE") == "implementation-planning"
1869
+ ):
1851
1870
  if not run_manifest_exists:
1852
1871
  payload["activityContractVersion"] = 1
1853
1872
  elif "activityContractVersion" in existing_run_manifest:
@@ -1864,6 +1883,27 @@ def render_run_manifest(run_manifest_path: str, ctx: dict) -> None:
1864
1883
  payload.update(execution_identity)
1865
1884
  _guard_invocation_manifest_rewrite(existing_run_manifest, payload)
1866
1885
  _write_json(Path(run_manifest_path), payload)
1886
+ if CURRENT_REPORT_SCHEMA_VERSION == "3.0":
1887
+ _initialize_report_ledgers(ctx, payload)
1888
+
1889
+
1890
+ def _initialize_report_ledgers(ctx: Mapping[str, Any], manifest: Mapping[str, Any]) -> None:
1891
+ approval_value = str(ctx.get("APPROVAL_DECISIONS_PATH") or "")
1892
+ approval_path = Path(approval_value)
1893
+ if approval_value and not approval_path.is_file():
1894
+ _write_json(approval_path, {
1895
+ "schemaVersion": "1.0",
1896
+ "owner": "lead",
1897
+ "taskKey": manifest.get("taskKey"),
1898
+ "taskType": manifest.get("taskType"),
1899
+ "runSeq": (manifest.get("runSequencesByCategory") or {}).get("manifests"),
1900
+ "activeClarifications": [],
1901
+ "carriedDecisions": [],
1902
+ })
1903
+ activity_value = str(ctx.get("LEAD_EVENTS_PATH") or "")
1904
+ activity_path = Path(activity_value)
1905
+ if activity_value and not activity_path.is_file():
1906
+ _write_text(activity_path, "")
1867
1907
 
1868
1908
 
1869
1909
  _MODEL_ASSIGNMENT_KEYS = {
@@ -1904,8 +1944,18 @@ _IMMUTABLE_INVOCATION_KEYS = (
1904
1944
  "workerPromptPathByWorkerId",
1905
1945
  "workerResultsDirectoryPath",
1906
1946
  "expectedReportRecordPath",
1947
+ "reportNarrativePath",
1948
+ "approvalDecisionsPath",
1949
+ "designPreparationPath",
1950
+ "planBodyVerificationPath",
1907
1951
  "reportTemplatePath",
1908
1952
  )
1953
+ _V3_REPORT_PATH_KEYS = frozenset({
1954
+ "reportNarrativePath",
1955
+ "approvalDecisionsPath",
1956
+ "designPreparationPath",
1957
+ "planBodyVerificationPath",
1958
+ })
1909
1959
 
1910
1960
 
1911
1961
  def _guard_invocation_manifest_rewrite(
@@ -1951,6 +2001,10 @@ def _validate_invocation_manifest(payload: Mapping[str, object]) -> None:
1951
2001
  item
1952
2002
  for item in _IMMUTABLE_INVOCATION_KEYS[4:]
1953
2003
  if item != "workerPromptPathByWorkerId"
2004
+ and (
2005
+ item not in _V3_REPORT_PATH_KEYS
2006
+ or payload.get("reportContractVersion") == "3.0"
2007
+ )
1954
2008
  ):
1955
2009
  if not payload.get(key):
1956
2010
  raise ValueError(f"incomplete invocation manifest path: {key}")
@@ -2039,7 +2093,9 @@ def render_timeline(timeline_path: str, ctx: dict) -> None:
2039
2093
  task_manifest = {}
2040
2094
  if task_manifest_path.exists():
2041
2095
  try:
2042
- task_manifest = json.loads(task_manifest_path.read_text(encoding="utf-8"))
2096
+ task_manifest = load_owned_object(
2097
+ task_manifest_path, artifact="task manifest"
2098
+ )
2043
2099
  except Exception:
2044
2100
  task_manifest = {}
2045
2101
  reviewers = _resolve_workers(ctx)
@@ -2048,7 +2104,7 @@ def render_timeline(timeline_path: str, ctx: dict) -> None:
2048
2104
  existing = {}
2049
2105
  if path.exists():
2050
2106
  try:
2051
- existing = json.loads(path.read_text(encoding="utf-8"))
2107
+ existing = load_owned_object(path, artifact="task timeline")
2052
2108
  except Exception:
2053
2109
  existing = {}
2054
2110
  runs = existing.get("runs", [])
@@ -2143,7 +2199,9 @@ def render_task_index(template_path: str, output_path: str, ctx: dict) -> None:
2143
2199
  task_manifest = {}
2144
2200
  if task_manifest_path.exists():
2145
2201
  try:
2146
- task_manifest = json.loads(task_manifest_path.read_text(encoding="utf-8"))
2202
+ task_manifest = load_owned_object(
2203
+ task_manifest_path, artifact="task manifest"
2204
+ )
2147
2205
  except Exception:
2148
2206
  task_manifest = {}
2149
2207
  workflow = (
@@ -2338,8 +2396,8 @@ def build_available_mcp_servers_block(project_root: Path) -> str:
2338
2396
  """
2339
2397
  config_path = project_json_path(project_root)
2340
2398
  try:
2341
- raw = json.loads(config_path.read_text(encoding="utf-8"))
2342
- except (FileNotFoundError, json.JSONDecodeError):
2399
+ raw = load_owned_object(config_path, artifact="project config")
2400
+ except JsonBoundaryError:
2343
2401
  return _NO_MCP_SERVERS_LINE
2344
2402
  servers = raw.get("mcpServers") if isinstance(raw, dict) else None
2345
2403
  if not isinstance(servers, list) or not servers:
@@ -2,7 +2,7 @@
2
2
 
3
3
  The JSON SSOT lives next to the rendered markdown as
4
4
  ``final-report-<task-type>-<seq>.data.json``. Its ``schemaVersion`` selects the
5
- matching final-report schema. Report-writer-worker writes the data.json in
5
+ matching final-report schema. Report assembly writes the data.json in
6
6
  Phase 6; this renderer + the matching Jinja2 template deterministically
7
7
  produce the canonical AI-facing markdown.
8
8
 
@@ -49,6 +49,7 @@ from okstra_ctl.final_report_schema import (
49
49
  load_schema_for_data,
50
50
  validate as schema_validate,
51
51
  )
52
+ from okstra_ctl.json_boundary import JsonBoundaryError, load_owned_object
52
53
  from okstra_ctl.i18n import I18nError, SUPPORTED_LANGS, load_dictionary, make_jinja_global
53
54
  from okstra_ctl.md_table import UNESCAPED_PIPE_RE, to_cell_text
54
55
  from okstra_ctl.models import UnknownModelError, resolve_model_metadata
@@ -66,6 +67,7 @@ from okstra_ctl.usage_cells import format_duration_ms, format_int, format_usd
66
67
 
67
68
  TEMPLATE_BY_SCHEMA_VERSION = {
68
69
  "2.0": ("templates", "reports", "final-report-v2.template.md"),
70
+ "3.0": ("templates", "reports", "final-report-v2.template.md"),
69
71
  }
70
72
  DEFAULT_TEMPLATE_REL = TEMPLATE_BY_SCHEMA_VERSION[CURRENT_REPORT_SCHEMA_VERSION]
71
73
 
@@ -601,8 +603,8 @@ def validate_report_language(data: dict) -> str:
601
603
  if candidate == "auto":
602
604
  raise FinalReportRenderError(
603
605
  "reportLanguage 'auto' must be resolved by the lead before "
604
- "the renderer runs; the report-writer worker writes the "
605
- "resolved 'en' or 'ko' into data.json.meta.reportLanguage."
606
+ "the renderer runs; report assembly copies the resolved 'en' or "
607
+ "'ko' from the run manifest into data.json.meta.reportLanguage."
606
608
  )
607
609
  if candidate not in SUPPORTED_LANGS:
608
610
  raise FinalReportRenderError(
@@ -757,9 +759,9 @@ def _with_excerpt_drift_hint(
757
759
  ) -> FinalReportRenderError:
758
760
  """Name the contract drift behind a schema failure, when that is the cause.
759
761
 
760
- A long run straddles its own runtime upgrade: the report-writer authors
761
- against the excerpt frozen into the bundle at prep time, while the renderer
762
- validates against the installed schema. Without this the author sees only
762
+ A long run straddles its own runtime upgrade: the report-writer authors its
763
+ narrative against the excerpt frozen into the bundle at prep time, while
764
+ assembly and the renderer validate against the installed schema. Without this the author sees only
763
765
  `additional property ... not allowed` for a field the excerpt told it to
764
766
  write, and has no way to tell a real mistake from a stale bundle.
765
767
 
@@ -802,8 +804,8 @@ def render_to_file(
802
804
  if not data_path.is_file():
803
805
  raise FinalReportRenderError(f"data file not found: {data_path}")
804
806
  try:
805
- data = json.loads(data_path.read_text(encoding="utf-8"))
806
- except json.JSONDecodeError as exc:
807
+ data = load_owned_object(data_path, artifact="final report record")
808
+ except JsonBoundaryError as exc:
807
809
  raise FinalReportRenderError(f"invalid JSON in {data_path}: {exc}") from exc
808
810
 
809
811
  # 템플릿은 data.json 위치가 아니라 이 모듈(설치본은 ~/.okstra/lib/python,