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
@@ -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,7 @@ 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
49
50
  from .report_contract import CURRENT_REPORT_SCHEMA_VERSION
50
51
  from .worker_artifacts import artifacts_from_context
51
52
  from .workflow import PHASE_SEQUENCE
@@ -74,7 +75,9 @@ def _load_ctx(ctx_arg: str) -> dict:
74
75
  s = ctx_arg.lstrip()
75
76
  if s.startswith("{"):
76
77
  return hydrate_run_context(json.loads(ctx_arg))
77
- 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
+ )
78
81
 
79
82
 
80
83
  def _write_text(path: Path, text: str) -> None:
@@ -83,7 +86,7 @@ def _write_text(path: Path, text: str) -> None:
83
86
 
84
87
 
85
88
  def _write_json(path: Path, payload: dict) -> None:
86
- _write_text(path, json.dumps(payload, indent=2, ensure_ascii=False) + "\n")
89
+ write_owned_object_atomic(path, payload, artifact="rendered runtime object")
87
90
 
88
91
 
89
92
  def _execution_identity(ctx: Mapping[str, object]) -> dict:
@@ -874,7 +877,7 @@ def render_task_catalog_discovery(output_path: str, ctx: dict) -> None:
874
877
  entries = []
875
878
  for manifest_path in sorted(tasks_root.rglob(TASK_MANIFEST_FILENAME)):
876
879
  try:
877
- manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
880
+ manifest = load_owned_object(manifest_path, artifact="task manifest")
878
881
  except Exception:
879
882
  continue
880
883
  if not isinstance(manifest, dict):
@@ -892,7 +895,7 @@ def render_task_catalog_discovery(output_path: str, ctx: dict) -> None:
892
895
  latest_run = {}
893
896
  if timeline_file.is_file():
894
897
  try:
895
- payload = json.loads(timeline_file.read_text(encoding="utf-8"))
898
+ payload = load_owned_object(timeline_file, artifact="task timeline")
896
899
  except Exception:
897
900
  payload = {}
898
901
  runs = payload.get("runs", []) if isinstance(payload, dict) else []
@@ -982,7 +985,9 @@ def render_latest_task_discovery(output_path: str, ctx: dict) -> None:
982
985
  task_manifest = {}
983
986
  if task_manifest_path.exists():
984
987
  try:
985
- 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
+ )
986
991
  except Exception:
987
992
  task_manifest = {}
988
993
  workflow = (
@@ -1344,7 +1349,7 @@ def render_task_manifest(manifest_path: str, ctx: dict) -> None:
1344
1349
  existing = {}
1345
1350
  if path.exists():
1346
1351
  try:
1347
- existing = json.loads(path.read_text(encoding="utf-8"))
1352
+ existing = load_owned_object(path, artifact="task manifest")
1348
1353
  except Exception:
1349
1354
  existing = {}
1350
1355
  reviewers = _resolve_workers(ctx)
@@ -1646,21 +1651,23 @@ def render_run_manifest(run_manifest_path: str, ctx: dict) -> None:
1646
1651
  existing_run_manifest = {}
1647
1652
  if run_manifest_exists:
1648
1653
  try:
1649
- loaded_run_manifest = json.loads(
1650
- run_manifest_file.read_text(encoding="utf-8")
1654
+ loaded_run_manifest = load_owned_object(
1655
+ run_manifest_file, artifact="run manifest"
1651
1656
  )
1652
1657
  existing_run_manifest = (
1653
1658
  loaded_run_manifest
1654
1659
  if isinstance(loaded_run_manifest, dict)
1655
1660
  else {}
1656
1661
  )
1657
- except (OSError, json.JSONDecodeError):
1662
+ except JsonBoundaryError:
1658
1663
  existing_run_manifest = {}
1659
1664
  task_manifest_path = Path(ctx.get("TASK_MANIFEST_PATH", ""))
1660
1665
  task_manifest = {}
1661
1666
  if task_manifest_path.exists():
1662
1667
  try:
1663
- 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
+ )
1664
1671
  except Exception:
1665
1672
  task_manifest = {}
1666
1673
  reviewers = _resolve_workers(ctx)
@@ -2086,7 +2093,9 @@ def render_timeline(timeline_path: str, ctx: dict) -> None:
2086
2093
  task_manifest = {}
2087
2094
  if task_manifest_path.exists():
2088
2095
  try:
2089
- 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
+ )
2090
2099
  except Exception:
2091
2100
  task_manifest = {}
2092
2101
  reviewers = _resolve_workers(ctx)
@@ -2095,7 +2104,7 @@ def render_timeline(timeline_path: str, ctx: dict) -> None:
2095
2104
  existing = {}
2096
2105
  if path.exists():
2097
2106
  try:
2098
- existing = json.loads(path.read_text(encoding="utf-8"))
2107
+ existing = load_owned_object(path, artifact="task timeline")
2099
2108
  except Exception:
2100
2109
  existing = {}
2101
2110
  runs = existing.get("runs", [])
@@ -2190,7 +2199,9 @@ def render_task_index(template_path: str, output_path: str, ctx: dict) -> None:
2190
2199
  task_manifest = {}
2191
2200
  if task_manifest_path.exists():
2192
2201
  try:
2193
- 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
+ )
2194
2205
  except Exception:
2195
2206
  task_manifest = {}
2196
2207
  workflow = (
@@ -2385,8 +2396,8 @@ def build_available_mcp_servers_block(project_root: Path) -> str:
2385
2396
  """
2386
2397
  config_path = project_json_path(project_root)
2387
2398
  try:
2388
- raw = json.loads(config_path.read_text(encoding="utf-8"))
2389
- except (FileNotFoundError, json.JSONDecodeError):
2399
+ raw = load_owned_object(config_path, artifact="project config")
2400
+ except JsonBoundaryError:
2390
2401
  return _NO_MCP_SERVERS_LINE
2391
2402
  servers = raw.get("mcpServers") if isinstance(raw, dict) else None
2392
2403
  if not isinstance(servers, list) or not servers:
@@ -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
@@ -803,8 +804,8 @@ def render_to_file(
803
804
  if not data_path.is_file():
804
805
  raise FinalReportRenderError(f"data file not found: {data_path}")
805
806
  try:
806
- data = json.loads(data_path.read_text(encoding="utf-8"))
807
- except json.JSONDecodeError as exc:
807
+ data = load_owned_object(data_path, artifact="final report record")
808
+ except JsonBoundaryError as exc:
808
809
  raise FinalReportRenderError(f"invalid JSON in {data_path}: {exc}") from exc
809
810
 
810
811
  # 템플릿은 data.json 위치가 아니라 이 모듈(설치본은 ~/.okstra/lib/python,
@@ -6,12 +6,17 @@ import os
6
6
  import tempfile
7
7
  from dataclasses import dataclass
8
8
  from pathlib import Path
9
- from typing import Any, Callable, Mapping
9
+ from typing import Any, Callable, Mapping, Sequence
10
10
 
11
11
  from .agent_activity import agent_activity_rows
12
12
  from .final_report_schema import load_schema_version, validate
13
13
  from .report_inputs import ReportInputPath, report_input_paths, uses_report_contract_v3
14
+ from .json_boundary import JsonBoundaryError, load_owned_object, serialize_owned_object
14
15
  from .report_narrative import parse_narrative
16
+ from .report_synthesis_packet import (
17
+ report_synthesis_packet_paths,
18
+ verify_report_synthesis_packet_sources,
19
+ )
15
20
  from .report_projections import (
16
21
  ReportProjectionError,
17
22
  project_convergence,
@@ -32,12 +37,20 @@ class AssemblyIssue:
32
37
  class ReportAssemblyError(ValueError):
33
38
  """게시 전 입력 검증 실패와 실제 소유자를 전달한다."""
34
39
 
35
- def __init__(self, issue: AssemblyIssue) -> None:
36
- self.issue = issue
37
- super().__init__(
38
- f"owner={issue.owner} artifactPath={issue.artifact_path} "
39
- f"fieldPath={issue.field_path} reason={issue.reason}"
40
- )
40
+ def __init__(self, issues: AssemblyIssue | Sequence[AssemblyIssue]) -> None:
41
+ rows = (issues,) if isinstance(issues, AssemblyIssue) else tuple(issues)
42
+ if not rows:
43
+ raise ValueError("report assembly error requires at least one issue")
44
+ self.issues = rows
45
+ self.issue = rows[0]
46
+ super().__init__("; ".join(_issue_message(issue) for issue in rows))
47
+
48
+
49
+ def _issue_message(issue: AssemblyIssue) -> str:
50
+ return (
51
+ f"owner={issue.owner} artifactPath={issue.artifact_path} "
52
+ f"fieldPath={issue.field_path} reason={issue.reason}"
53
+ )
41
54
 
42
55
 
43
56
  def _fail(owner: str, path: Path, field: str, reason: object) -> None:
@@ -48,8 +61,8 @@ def _read_json(row: ReportInputPath) -> dict[str, Any]:
48
61
  if not row.path.is_file():
49
62
  _fail(row.owner, row.path, "$", "required input is missing")
50
63
  try:
51
- value = json.loads(row.path.read_text(encoding="utf-8"))
52
- except (OSError, UnicodeError, json.JSONDecodeError) as exc:
64
+ value = load_owned_object(row.path, artifact=f"{row.owner} report input")
65
+ except JsonBoundaryError as exc:
53
66
  _fail(row.owner, row.path, "$", exc)
54
67
  if not isinstance(value, dict):
55
68
  _fail(row.owner, row.path, "$", "input must be a JSON object")
@@ -63,6 +76,39 @@ def _input_map(project_root: Path, manifest: Mapping[str, Any]) -> dict[str, Rep
63
76
  _fail("orchestrator", project_root, "manifest", exc)
64
77
 
65
78
 
79
+ def _input_preflight_issues(
80
+ inputs: Mapping[str, ReportInputPath],
81
+ schema: Mapping[str, Any],
82
+ ) -> tuple[AssemblyIssue, ...]:
83
+ issues: list[AssemblyIssue] = []
84
+ for row in inputs.values():
85
+ if not row.path.is_file():
86
+ issues.append(
87
+ AssemblyIssue(
88
+ row.owner,
89
+ str(row.path),
90
+ "$",
91
+ "required input is missing",
92
+ )
93
+ )
94
+ continue
95
+ try:
96
+ if row.key == "narrative":
97
+ parse_narrative(row.path.read_text(encoding="utf-8"), schema)
98
+ elif row.key == "agent-activity":
99
+ row.path.read_text(encoding="utf-8")
100
+ else:
101
+ value = load_owned_object(
102
+ row.path,
103
+ artifact=f"{row.owner} report input",
104
+ )
105
+ if not isinstance(value, dict):
106
+ raise ValueError("input must be a JSON object")
107
+ except (JsonBoundaryError, OSError, UnicodeError, ValueError) as exc:
108
+ issues.append(AssemblyIssue(row.owner, str(row.path), "$", str(exc)))
109
+ return tuple(issues)
110
+
111
+
66
112
  def _narrative(row: ReportInputPath, schema: Mapping[str, Any]) -> dict[str, Any]:
67
113
  if not row.path.is_file():
68
114
  _fail(row.owner, row.path, "$", "required input is missing")
@@ -90,6 +136,81 @@ def _clarifications(
90
136
  ]
91
137
 
92
138
 
139
+ def _project_relative(project_root: Path, path: Path) -> str:
140
+ try:
141
+ return path.relative_to(project_root).as_posix()
142
+ except ValueError:
143
+ return str(path)
144
+
145
+
146
+ def _clarification_response_value(
147
+ project_root: Path,
148
+ manifest: Mapping[str, Any],
149
+ ) -> str:
150
+ direct = manifest.get("clarificationResponsePath")
151
+ if isinstance(direct, str) and direct.strip():
152
+ return direct.strip()
153
+ context_value = manifest.get("activeRunContextPath")
154
+ if isinstance(context_value, str) and context_value.strip():
155
+ context_path = Path(context_value)
156
+ if not context_path.is_absolute():
157
+ context_path = project_root / context_path
158
+ context = _read_json(
159
+ ReportInputPath("active-run-context", "orchestrator", context_path)
160
+ )
161
+ instruction_set = context.get("instructionSet")
162
+ value = (
163
+ instruction_set.get("clarificationResponsePath")
164
+ if isinstance(instruction_set, Mapping)
165
+ else ""
166
+ )
167
+ if isinstance(value, str) and value.strip():
168
+ return value.strip()
169
+ return _latest_user_response_value(project_root, manifest)
170
+
171
+
172
+ def _latest_user_response_value(
173
+ project_root: Path,
174
+ manifest: Mapping[str, Any],
175
+ ) -> str:
176
+ record_value = manifest.get("expectedReportRecordPath")
177
+ if not isinstance(record_value, str) or not record_value.strip():
178
+ return ""
179
+ record_path = Path(record_value)
180
+ if not record_path.is_absolute():
181
+ record_path = project_root / record_path
182
+ if record_path.parent.name != "reports":
183
+ return ""
184
+ task_type = str(manifest.get("taskType") or "").strip()
185
+ if not task_type:
186
+ return ""
187
+ responses = sorted(
188
+ path
189
+ for path in (record_path.parent.parent / "user-responses").glob(
190
+ f"user-response-{task_type}-*.md"
191
+ )
192
+ if path.is_file()
193
+ )
194
+ if not responses:
195
+ return ""
196
+ return _project_relative(project_root, responses[-1])
197
+
198
+
199
+ def _clarification_carry_in(
200
+ project_root: Path,
201
+ manifest: Mapping[str, Any],
202
+ ) -> dict[str, str] | None:
203
+ value = _clarification_response_value(project_root, manifest)
204
+ if not value:
205
+ return None
206
+ path = Path(value)
207
+ if not path.is_absolute():
208
+ path = project_root / path
209
+ if not path.is_file():
210
+ _fail("user", path, "$", "attached clarification response is missing")
211
+ return {"sourceFile": _project_relative(project_root, path)}
212
+
213
+
93
214
  def _clarification_row(
94
215
  source: Mapping[str, Any], activities: Mapping[object, Mapping[str, Any]], path: Path,
95
216
  ) -> dict[str, Any]:
@@ -148,10 +269,12 @@ def _attach_plan_backlinks(data: dict[str, Any], activities: list[dict[str, Any]
148
269
 
149
270
 
150
271
  def _compose(
151
- project_root: Path, manifest_path: Path, manifest: Mapping[str, Any],
272
+ project_root: Path,
273
+ manifest_path: Path,
274
+ manifest: Mapping[str, Any],
275
+ inputs: Mapping[str, ReportInputPath],
276
+ schema: Mapping[str, Any],
152
277
  ) -> dict[str, Any]:
153
- schema = load_schema_version("3.0")
154
- inputs = _input_map(project_root, manifest)
155
278
  narrative = _narrative(inputs["narrative"], schema)
156
279
  ledger = _read_json(inputs["approval-decisions"])
157
280
  team_state = _read_json(inputs["execution-status"])
@@ -175,6 +298,9 @@ def _compose(
175
298
  data["tokenUsage"] = usage
176
299
  data["crossVerification"] = convergence_data["crossVerification"]
177
300
  data["clarificationItems"] = _clarifications(ledger, activities, inputs["approval-decisions"].path)
301
+ carry_in = _clarification_carry_in(project_root, manifest)
302
+ if carry_in is not None:
303
+ data["clarificationCarryIn"] = carry_in
178
304
  data["agentActivity"] = activities
179
305
  data["evidence"] = {"primary": _promoted_evidence(convergence_data)}
180
306
  data["missingInformation"] = []
@@ -317,11 +443,11 @@ def _publish(
317
443
  target: Path, data: Mapping[str, Any], replace: Callable[[str, str], None],
318
444
  ) -> None:
319
445
  target.parent.mkdir(parents=True, exist_ok=True)
446
+ serialized = serialize_owned_object(target, data, artifact="final report record")
320
447
  descriptor, name = tempfile.mkstemp(prefix=f".{target.name}.", dir=target.parent)
321
448
  try:
322
449
  with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
323
- json.dump(data, handle, ensure_ascii=False, indent=2)
324
- handle.write("\n")
450
+ handle.write(serialized)
325
451
  handle.flush()
326
452
  os.fsync(handle.fileno())
327
453
  replace(name, str(target))
@@ -339,12 +465,41 @@ def assemble_report(
339
465
  manifest = _read_json(manifest_row)
340
466
  if not uses_report_contract_v3(manifest):
341
467
  _fail("orchestrator", manifest_path, "reportContractVersion", "requires 3.0")
342
- data = _compose(project_root, manifest_path, manifest)
343
- errors = validate(data, load_schema_version("3.0"))
468
+ schema = load_schema_version("3.0")
469
+ inputs = _input_map(project_root, manifest)
470
+ input_issues = list(_input_preflight_issues(inputs, schema))
471
+ packet_data_path, _ = report_synthesis_packet_paths(inputs["narrative"].path)
472
+ if packet_data_path.is_file():
473
+ input_issues.extend(
474
+ AssemblyIssue(
475
+ issue.owner,
476
+ str(issue.path),
477
+ f"sources.{issue.label}",
478
+ issue.reason,
479
+ )
480
+ for issue in verify_report_synthesis_packet_sources(
481
+ project_root,
482
+ packet_data_path,
483
+ )
484
+ )
485
+ if input_issues:
486
+ raise ReportAssemblyError(tuple(input_issues))
487
+ data = _compose(project_root, manifest_path, manifest, inputs, schema)
488
+ errors = validate(data, schema)
344
489
  if errors:
345
490
  target = Path(str(manifest.get("expectedReportRecordPath") or "data.json"))
346
491
  target = target if target.is_absolute() else project_root / target
347
- _fail(_schema_owner(errors[0]), target, errors[0].split(" ", 1)[0], errors[0])
492
+ raise ReportAssemblyError(
493
+ tuple(
494
+ AssemblyIssue(
495
+ _schema_owner(error),
496
+ str(target),
497
+ error.split(" ", 1)[0],
498
+ error,
499
+ )
500
+ for error in errors
501
+ )
502
+ )
348
503
  value = manifest.get("expectedReportRecordPath")
349
504
  if not isinstance(value, str) or not value:
350
505
  _fail("orchestrator", manifest_path, "expectedReportRecordPath", "required")