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
@@ -13,12 +13,14 @@ CLI 는 갈라 읽어야 한다.
13
13
  from __future__ import annotations
14
14
 
15
15
  import json
16
- from dataclasses import dataclass
16
+ from dataclasses import dataclass, field
17
17
  from pathlib import Path
18
18
  from typing import Any, Callable, Literal, Mapping, Protocol, runtime_checkable
19
19
 
20
20
  from .worker_stream import (
21
21
  Normalise,
22
+ StreamEvent,
23
+ Text,
22
24
  final_text,
23
25
  format_live,
24
26
  format_log,
@@ -33,16 +35,22 @@ WORKER = "worker"
33
35
 
34
36
 
35
37
  class TranscriptWriter(Protocol):
36
- """세션 기록에 한 줄을 남긴다. 화면 출력도 이 구현이 함께 맡는다."""
38
+ """세션 기록에 남긴다. 화면 출력도 이 구현이 함께 맡는다."""
37
39
 
38
40
  def write(self, speaker: str, line: str) -> None: ...
39
41
 
42
+ def write_event(
43
+ self, speaker: str, *, screen: list[str], archive: list[str]
44
+ ) -> None: ...
45
+
40
46
 
41
47
  @runtime_checkable
42
48
  class Presentation(Protocol):
43
49
  def merges_stderr(self) -> bool: ...
44
50
 
45
- def sinks(self, writer: TranscriptWriter, live: bool) -> tuple[SinkSpec, ...]: ...
51
+ def sinks(self, writer: TranscriptWriter) -> tuple[SinkSpec, ...]: ...
52
+
53
+ def flush(self, writer: TranscriptWriter) -> None: ...
46
54
 
47
55
 
48
56
  @dataclass(frozen=True)
@@ -58,13 +66,16 @@ class MergedText:
58
66
  def merges_stderr(self) -> bool:
59
67
  return True
60
68
 
61
- def sinks(self, writer: TranscriptWriter, live: bool) -> tuple[SinkSpec, ...]:
69
+ def sinks(self, writer: TranscriptWriter) -> tuple[SinkSpec, ...]:
62
70
  def emit(line: str) -> str | None:
63
71
  writer.write(WORKER, line)
64
72
  return line
65
73
 
66
74
  return (("stdout", emit),)
67
75
 
76
+ def flush(self, writer: TranscriptWriter) -> None:
77
+ return
78
+
68
79
 
69
80
  @dataclass(frozen=True)
70
81
  class SplitText:
@@ -77,7 +88,7 @@ class SplitText:
77
88
  def merges_stderr(self) -> bool:
78
89
  return False
79
90
 
80
- def sinks(self, writer: TranscriptWriter, live: bool) -> tuple[SinkSpec, ...]:
91
+ def sinks(self, writer: TranscriptWriter) -> tuple[SinkSpec, ...]:
81
92
  def result(line: str) -> str | None:
82
93
  writer.write(WORKER, line)
83
94
  return line
@@ -88,6 +99,9 @@ class SplitText:
88
99
 
89
100
  return (("stdout", result), ("stderr", progress))
90
101
 
102
+ def flush(self, writer: TranscriptWriter) -> None:
103
+ return
104
+
91
105
 
92
106
  @dataclass(frozen=True)
93
107
  class JsonEvents:
@@ -99,11 +113,16 @@ class JsonEvents:
99
113
 
100
114
  normalise: Normalise
101
115
  observe: ObserveServedModel
116
+ # 어떤 CLI 의 streaming-json 은 문장이 아니라 토큰을 한 줄씩 보낸다. 붙이지
117
+ # 않으면 pane 이 한 글자 한 줄이 된다. 기본은 끄고, 그런 CLI 의 어댑터가 켠다 —
118
+ # 어느 CLI 가 그런지는 이 계층이 알 필요도, 알아서도 안 되는 사실이다.
119
+ join_adjacent_text: bool = False
120
+ _pending_text: list[str] = field(default_factory=list, repr=False, compare=False)
102
121
 
103
122
  def merges_stderr(self) -> bool:
104
123
  return True
105
124
 
106
- def sinks(self, writer: TranscriptWriter, live: bool) -> tuple[SinkSpec, ...]:
125
+ def sinks(self, writer: TranscriptWriter) -> tuple[SinkSpec, ...]:
107
126
  def emit(line: str) -> str | None:
108
127
  stripped = line.strip()
109
128
  if not stripped:
@@ -113,6 +132,7 @@ class JsonEvents:
113
132
  except ValueError:
114
133
  # 이벤트가 아니다. CLI 자신의 오류 텍스트가 이 스트림으로
115
134
  # 오므로 삼키면 아무도 못 본다.
135
+ self._publish_text(writer)
116
136
  writer.write(WORKER, stripped)
117
137
  return None
118
138
  if not isinstance(event, dict):
@@ -120,9 +140,50 @@ class JsonEvents:
120
140
  self.observe(event)
121
141
  closing: str | None = None
122
142
  for entry in self.normalise(event):
123
- for row in (format_live(entry) if live else format_log(entry)):
124
- writer.write(WORKER, row)
125
- closing = final_text(entry) or closing
143
+ closing = self._write_entry(writer, entry) or closing
126
144
  return closing
127
145
 
128
146
  return (("stdout", emit),)
147
+
148
+ def flush(self, writer: TranscriptWriter) -> None:
149
+ self._publish_text(writer)
150
+
151
+ def _write_entry(
152
+ self, writer: TranscriptWriter, entry: StreamEvent
153
+ ) -> str | None:
154
+ if isinstance(entry, Text) and self.join_adjacent_text:
155
+ self._pending_text.append(entry.body)
156
+ self._publish_complete_lines(writer)
157
+ return None
158
+ self._publish_text(writer)
159
+ # 두 투영을 고르는 것이 아니라 둘 다 보낸다. 화면은 요약, 기록은
160
+ # 본문까지. 고르던 동안에는 pane 이 붙은 run 의 기록에서 도구 결과
161
+ # 본문이 통째로 사라졌다.
162
+ writer.write_event(
163
+ WORKER,
164
+ screen=format_live(entry),
165
+ archive=format_log(entry),
166
+ )
167
+ return final_text(entry)
168
+
169
+ def _publish_complete_lines(self, writer: TranscriptWriter) -> None:
170
+ combined = "".join(self._pending_text)
171
+ if "\n" not in combined:
172
+ return
173
+ *complete, remainder = combined.split("\n")
174
+ self._pending_text.clear()
175
+ if remainder:
176
+ self._pending_text.append(remainder)
177
+ self._write_text_rows(writer, "\n".join(complete))
178
+
179
+ def _publish_text(self, writer: TranscriptWriter) -> None:
180
+ combined = "".join(self._pending_text)
181
+ self._pending_text.clear()
182
+ self._write_text_rows(writer, combined)
183
+
184
+ def _write_text_rows(self, writer: TranscriptWriter, body: str) -> None:
185
+ event = Text(body=body)
186
+ screen = format_live(event)
187
+ archive = format_log(event)
188
+ if screen or archive:
189
+ writer.write_event(WORKER, screen=screen, archive=archive)
@@ -16,6 +16,7 @@ from ..agent_invocation import (
16
16
  verify_agent_invocation,
17
17
  )
18
18
  from ..dispatch_state import DispatchError, record_verified_agent_dispatch
19
+ from ..json_boundary import JsonBoundaryError, load_owned_object
19
20
 
20
21
  from ..application.start_run import start_run
21
22
  from ..application.resume_run import resume_run
@@ -108,7 +109,13 @@ def _build_parser() -> argparse.ArgumentParser:
108
109
  resolve = commands.add_parser("resolve")
109
110
  resolve.add_argument("--requested", required=True)
110
111
  resolve.add_argument("--command", dest="runtime_command", required=True)
111
- resolve.add_argument("--context-json", required=True)
112
+ resolve.add_argument("--environment-host", default="")
113
+ resolve.add_argument(
114
+ "--capability",
115
+ action="append",
116
+ choices=("claude-skill-handoff", "tmux"),
117
+ default=[],
118
+ )
112
119
  launch = commands.add_parser("launch-plan")
113
120
  _add_session_plan_args(launch)
114
121
  launch.add_argument("--prepared-launch-json", default="")
@@ -152,7 +159,9 @@ def _command_payload(
152
159
  if args.command == "catalog":
153
160
  return catalog_payload(registry)
154
161
  if args.command == "resolve":
155
- context = _parse_resolution_context(args.context_json)
162
+ context = HostResolutionContext(
163
+ args.environment_host, frozenset(args.capability)
164
+ )
156
165
  try:
157
166
  return resolve_payload(registry, args.requested, context)
158
167
  except HostUnavailable as exc:
@@ -215,31 +224,6 @@ def _probe_paths(project_root: str, home_dir: str):
215
224
  os.environ[key] = value
216
225
 
217
226
 
218
- def _parse_resolution_context(raw_context: str) -> HostResolutionContext:
219
- try:
220
- raw = json.loads(raw_context)
221
- except json.JSONDecodeError as exc:
222
- raise ValueError("invalid host resolution context JSON") from exc
223
- if not isinstance(raw, dict):
224
- raise ValueError("invalid host resolution context JSON")
225
- env = raw.get("env", {})
226
- capabilities = raw.get("capabilities", {})
227
- if not isinstance(env, dict) or not isinstance(capabilities, dict):
228
- raise ValueError("invalid host resolution context JSON")
229
- explicit_env_host = env.get("OKSTRA_RUNTIME_HOST", "")
230
- if not isinstance(explicit_env_host, str):
231
- raise ValueError("invalid host resolution context JSON")
232
- signals = frozenset(
233
- signal
234
- for capability, signal in (
235
- ("claudeSkillHandoff", "claude-skill-handoff"),
236
- ("tmuxAvailable", "tmux"),
237
- )
238
- if capabilities.get(capability) is True
239
- )
240
- return HostResolutionContext(explicit_env_host, signals)
241
-
242
-
243
227
  def _unclaimed_host_message(registry: HostAdapterRegistry, command: str) -> str:
244
228
  runtime_flag = "--runtime" if command in {
245
229
  "install", "ensure-installed", "doctor"
@@ -336,17 +320,19 @@ def _launch_request(
336
320
  run_manifest_path = Path(run_manifest_value)
337
321
  metadata_path = Path(metadata_value)
338
322
  try:
339
- manifest = json.loads(run_manifest_path.read_text(encoding="utf-8"))
323
+ manifest = load_owned_object(run_manifest_path, artifact="run manifest")
340
324
  resources = manifest["resources"]
341
325
  assignment = agent_model_assignment_from_payload(
342
326
  manifest["invocationAssignments"]["lead"]
343
327
  )
344
328
  prompt_path = root / resources["leadExecutionPromptPath"]
345
329
  canonical_metadata = root / resources["leadPromptMetadataPath"]
346
- metadata = json.loads(canonical_metadata.read_text(encoding="utf-8"))
330
+ metadata = load_owned_object(
331
+ canonical_metadata, artifact="lead prompt metadata"
332
+ )
347
333
  if metadata["prompt"]["path"] != resources["leadExecutionPromptPath"]:
348
334
  raise KeyError("lead prompt resource does not match metadata")
349
- except (OSError, json.JSONDecodeError, KeyError, TypeError, AgentInvocationError) as exc:
335
+ except (OSError, JsonBoundaryError, KeyError, TypeError, AgentInvocationError) as exc:
350
336
  raise ValueError("lead invocation authority is incomplete") from exc
351
337
  if metadata_path.resolve(strict=False) != canonical_metadata.resolve(strict=False):
352
338
  raise ValueError("prepared lead metadata does not match run manifest")
@@ -15,6 +15,7 @@ import os
15
15
  from pathlib import Path
16
16
 
17
17
  from .models import provider_ids
18
+ from .json_boundary import external_error_sidecar_source, load_external_json
18
19
 
19
20
  STDERR_EXCERPT_MAX_BYTES = 2048
20
21
  TRUNCATION_SUFFIX = "...[truncated]"
@@ -240,6 +241,31 @@ def append_observed(
240
241
  return rec
241
242
 
242
243
 
244
+ def _worker_sidecar_record(entry, *, recorded_at, task_key, agent, agent_role, model, identity):
245
+ error_type = entry.get("errorType")
246
+ if error_type not in ALLOWED_ERROR_TYPES:
247
+ raise ValueError(f"invalid errorType in sidecar: {error_type!r}")
248
+ rec = {
249
+ "ts": entry.get("ts"), "recordedAt": recorded_at,
250
+ "taskKey": task_key,
251
+ "phase": str(entry.get("phase")) if entry.get("phase") is not None else None,
252
+ "agent": agent, "agentRole": agent_role, "model": model,
253
+ "source": "worker-reported", "errorType": error_type,
254
+ "command": entry.get("command"), "commandKind": entry.get("commandKind"),
255
+ "exitCode": entry.get("exitCode"), "durationMs": entry.get("durationMs"),
256
+ "message": entry.get("message"),
257
+ "stderrExcerpt": truncate_stderr(entry.get("stderrExcerpt")),
258
+ "context": normalize_cause_context(
259
+ entry.get("context"), message=entry.get("message")
260
+ ),
261
+ }
262
+ from okstra_ctl.execution_identity import stored_identity
263
+
264
+ rec.update(stored_identity(identity))
265
+ rec.update(stored_identity(entry))
266
+ return rec
267
+
268
+
243
269
  def dump_from_worker_sidecar(
244
270
  *,
245
271
  sidecar_path,
@@ -276,42 +302,21 @@ def dump_from_worker_sidecar(
276
302
  p = Path(sidecar_path)
277
303
  if not p.exists():
278
304
  return 0
279
- payload = json.loads(p.read_text())
305
+ # 외부 입력: 레거시 자동화가 제공하는 오류 sidecar 호환 경로다.
306
+ payload = load_external_json(
307
+ external_error_sidecar_source(p),
308
+ artifact="legacy error sidecar",
309
+ )
280
310
  schema = payload.get("schemaVersion")
281
311
  if schema not in SUPPORTED_SIDECAR_SCHEMA_VERSIONS:
282
312
  raise ValueError(f"unsupported sidecar schemaVersion: {schema!r}")
283
- entries = payload.get("errors") or []
284
313
  recorded_at = _iso(now or _now_utc())
285
314
  count = 0
286
- for e in entries:
287
- et = e.get("errorType")
288
- if et not in ALLOWED_ERROR_TYPES:
289
- raise ValueError(f"invalid errorType in sidecar: {et!r}")
290
- entry_context = normalize_cause_context(
291
- e.get("context"), message=e.get("message")
315
+ for entry in payload.get("errors") or []:
316
+ rec = _worker_sidecar_record(
317
+ entry, recorded_at=recorded_at, task_key=task_key, agent=agent,
318
+ agent_role=agent_role, model=model, identity=identity,
292
319
  )
293
- rec = {
294
- "ts": e.get("ts"),
295
- "recordedAt": recorded_at,
296
- "taskKey": task_key,
297
- "phase": str(e.get("phase")) if e.get("phase") is not None else None,
298
- "agent": agent,
299
- "agentRole": agent_role,
300
- "model": model,
301
- "source": "worker-reported",
302
- "errorType": et,
303
- "command": e.get("command"),
304
- "commandKind": e.get("commandKind"),
305
- "exitCode": e.get("exitCode"),
306
- "durationMs": e.get("durationMs"),
307
- "message": e.get("message"),
308
- "stderrExcerpt": truncate_stderr(e.get("stderrExcerpt")),
309
- "context": entry_context,
310
- }
311
- from okstra_ctl.execution_identity import stored_identity
312
-
313
- rec.update(stored_identity(identity))
314
- rec.update(stored_identity(e))
315
320
  append_jsonl_line(out_path, rec)
316
321
  count += 1
317
322
  return count
@@ -12,6 +12,8 @@ import json
12
12
  import sys
13
13
  from pathlib import Path
14
14
 
15
+ from .fixed_text import line
16
+
15
17
  from okstra_ctl.task_target import resolve_task_root, project_rel
16
18
  from okstra_project import read_task_key
17
19
  from okstra_ctl.error_log_core import (
@@ -187,6 +189,27 @@ def build_and_write(task_root: Path, project_root: Path, now: dt.datetime) -> di
187
189
  }
188
190
 
189
191
 
192
+ def render_result_text(result: dict) -> str:
193
+ totals = result.get("totals") if isinstance(result.get("totals"), dict) else {}
194
+ parts = ["# Okstra Error Report Input\n\n", line("Task key", result.get("taskKey")), line("Report path", result.get("reportPath")), line("Total errors", totals.get("errorCount")), line("Run count", totals.get("runCount")), line("Parse skipped", result.get("parseSkipped"))]
195
+ for key, label in (("byErrorType", "Error type"), ("bySource", "Source")):
196
+ values = totals.get(key)
197
+ if isinstance(values, dict):
198
+ for name, count in sorted(values.items()):
199
+ parts.append(line(f"{label} {name}", count))
200
+ for key, label in (("byPhase", "Phase"), ("byAgent", "Agent")):
201
+ values = result.get(key)
202
+ if isinstance(values, dict):
203
+ for name, count in sorted(values.items()):
204
+ parts.append(line(f"{label} {name}", count))
205
+ elif isinstance(values, list):
206
+ identity = "phase" if key == "byPhase" else "agent"
207
+ for row in values:
208
+ if isinstance(row, dict):
209
+ parts.append(line(f"{label} {row.get(identity)}", row.get("count")))
210
+ return "".join(parts)
211
+
212
+
190
213
  def main(argv: list[str] | None = None) -> int:
191
214
  parser = argparse.ArgumentParser(
192
215
  description="Aggregate okstra-run error logs into a task-level report."
@@ -194,11 +217,13 @@ def main(argv: list[str] | None = None) -> int:
194
217
  parser.add_argument("target", help="task root path or task-key")
195
218
  parser.add_argument("--project-root", default="", help="project root for task-key lookup")
196
219
  parser.add_argument("--cwd", default=".", help="cwd for project root resolution")
220
+ parser.add_argument("--text", action="store_true", help="emit fixed text fields")
197
221
  args = parser.parse_args(argv)
198
222
 
199
223
  task_root, project_root = resolve_task_root(args.target, args.project_root, args.cwd)
200
224
  result = build_and_write(task_root, project_root, dt.datetime.now(dt.timezone.utc))
201
- print(json.dumps(result, ensure_ascii=False, indent=2))
225
+ output = render_result_text(result) if args.text else json.dumps(result, ensure_ascii=False, indent=2)
226
+ print(output, end="" if args.text else "\n")
202
227
  return 0
203
228
 
204
229
 
@@ -13,10 +13,13 @@ import sys
13
13
  import zipfile
14
14
  from pathlib import Path
15
15
 
16
+ from .fixed_text import line
17
+
16
18
  from okstra_ctl.error_log_core import aggregate, parse_records
17
19
  from okstra_ctl.jsonl import read_jsonl
18
20
  from okstra_ctl.locks import central_lock
19
21
  from okstra_ctl.paths import okstra_home, resolve_under_root
22
+ from okstra_ctl.json_boundary import load_owned_object, write_owned_object_atomic
20
23
 
21
24
 
22
25
  def run_dirs(home: Path) -> list[tuple[str, Path, dict]]:
@@ -262,9 +265,10 @@ _CONFIG_NAME = "error-zip.json"
262
265
 
263
266
  def remember_output_path(home: Path, out_path: Path) -> None:
264
267
  cfg = home / _CONFIG_NAME
265
- cfg.write_text(
266
- json.dumps({"lastOutputPath": str(out_path)}, ensure_ascii=False, indent=2),
267
- encoding="utf-8",
268
+ write_owned_object_atomic(
269
+ cfg,
270
+ {"lastOutputPath": str(out_path)},
271
+ artifact="error zip configuration",
268
272
  )
269
273
 
270
274
 
@@ -273,7 +277,11 @@ def last_output_path(home: Path) -> str:
273
277
  if not cfg.is_file():
274
278
  return ""
275
279
  try:
276
- return str(json.loads(cfg.read_text(encoding="utf-8")).get("lastOutputPath", ""))
280
+ return str(
281
+ load_owned_object(cfg, artifact="error zip configuration").get(
282
+ "lastOutputPath", ""
283
+ )
284
+ )
277
285
  except Exception:
278
286
  return ""
279
287
 
@@ -315,16 +323,30 @@ def build_zip(home: Path, out_path: Path, now: dt.datetime) -> dict:
315
323
  }
316
324
 
317
325
 
326
+ def render_result_text(result: dict) -> str:
327
+ labels = (
328
+ ("outPath", "Output zip"), ("errorCount", "Total errors"),
329
+ ("runCount", "Run count"), ("unreachableRuns", "Unreachable runs"),
330
+ ("clusterCount", "Cluster count"), ("projectCount", "Project count"),
331
+ )
332
+ lines = ["# Okstra Error Zip Result\n\n"]
333
+ for key, label in labels:
334
+ lines.append(line(label, result.get(key)))
335
+ return "".join(lines)
336
+
337
+
318
338
  def main(argv: list[str] | None = None) -> int:
319
339
  parser = argparse.ArgumentParser(
320
340
  prog="okstra error-zip",
321
341
  description="cross-project okstra 에러를 수집·익명화해 zip 으로 묶는다.",
322
342
  )
323
343
  parser.add_argument("--out", required=True, help="출력 .zip 절대/상대 경로")
344
+ parser.add_argument("--text", action="store_true", help="emit fixed text fields")
324
345
  args = parser.parse_args(argv)
325
346
  home = okstra_home()
326
347
  result = build_zip(home, Path(args.out), dt.datetime.now(dt.timezone.utc))
327
- print(json.dumps(result, ensure_ascii=False, indent=2))
348
+ output = render_result_text(result) if args.text else json.dumps(result, ensure_ascii=False, indent=2)
349
+ print(output, end="" if args.text else "\n")
328
350
  return 0
329
351
 
330
352
 
@@ -10,6 +10,7 @@ from typing import Any
10
10
 
11
11
  from .domain.provider import HostModelBinding, ServedModelAttestation
12
12
  from .domain.worker_exec import WorkerWriteCapability
13
+ from .json_boundary import JsonBoundaryError, load_owned_object
13
14
 
14
15
 
15
16
  V2_SCHEMA_VERSION = "2.0"
@@ -479,8 +480,8 @@ def _capability_payload(
479
480
 
480
481
  def _read_json_object(path: Path) -> dict[str, Any]:
481
482
  try:
482
- payload = json.loads(path.read_text(encoding="utf-8"))
483
- except (OSError, json.JSONDecodeError) as exc:
483
+ payload = load_owned_object(path, artifact="execution identity")
484
+ except JsonBoundaryError as exc:
484
485
  raise ExecutionManifestError(f"cannot read execution identity: {path}") from exc
485
486
  if not isinstance(payload, dict):
486
487
  raise ExecutionManifestError("execution identity surface must be an object")
@@ -27,6 +27,7 @@ from .execution_identity import (
27
27
  validate_task_role_execution_index,
28
28
  )
29
29
  from .final_report_schema import SchemaError, load_schema, validate as validate_schema
30
+ from .json_boundary import JsonBoundaryError, load_owned_object, serialize_owned_object
30
31
  from .run_context import task_mutex
31
32
  from .write_policy import (
32
33
  WritePolicyError,
@@ -1257,8 +1258,8 @@ def _required_text(row: Mapping[str, Any], key: str) -> str:
1257
1258
 
1258
1259
  def _read_json_object(path: Path) -> dict[str, Any]:
1259
1260
  try:
1260
- payload = json.loads(path.read_text(encoding="utf-8"))
1261
- except (OSError, json.JSONDecodeError) as exc:
1261
+ payload = load_owned_object(path, artifact="execution manifest")
1262
+ except (OSError, JsonBoundaryError) as exc:
1262
1263
  raise ExecutionManifestError(f"cannot read JSON object: {path}") from exc
1263
1264
  if not isinstance(payload, dict):
1264
1265
  raise ExecutionManifestError(f"JSON surface must be an object: {path}")
@@ -1272,14 +1273,16 @@ def _write_json_atomic(
1272
1273
  create_only: bool = False,
1273
1274
  ) -> None:
1274
1275
  path.parent.mkdir(parents=True, exist_ok=True)
1276
+ serialized = serialize_owned_object(
1277
+ path, payload, artifact="execution manifest"
1278
+ )
1275
1279
  descriptor, temporary_name = tempfile.mkstemp(
1276
1280
  prefix=f".{path.name}.", suffix=".tmp", dir=path.parent
1277
1281
  )
1278
1282
  temporary = Path(temporary_name)
1279
1283
  try:
1280
1284
  with os.fdopen(descriptor, "w", encoding="utf-8") as stream:
1281
- json.dump(payload, stream, indent=2, ensure_ascii=False)
1282
- stream.write("\n")
1285
+ stream.write(serialized)
1283
1286
  stream.flush()
1284
1287
  os.fsync(stream.fileno())
1285
1288
  if create_only:
@@ -32,6 +32,10 @@ class MutationSnapshot:
32
32
  scratch_digests: Mapping[str, str]
33
33
  git_projection: Mapping[str, Any]
34
34
  digest: str
35
+ # 오케스트레이터가 쓰는 경로. 파일 하나를 싣거나 디렉터리를 실어 그 트리
36
+ # 전체를 덮을 수 있다 — 한 배치가 무엇을 쓸지 파일 단위로 미리 셀 수 없기
37
+ # 때문이다(다른 라운드의 재시도 프롬프트, 로그, 상태 사이드카가 그 창에
38
+ # 들어온다).
35
39
  orchestrator_paths: tuple[str, ...] = ()
36
40
 
37
41
  def to_payload(self) -> dict[str, Any]:
@@ -445,7 +449,7 @@ def _source_changes(
445
449
  snapshot.artifact_root == snapshot.root
446
450
  and any(_is_within(path, item) for item in artifacts)
447
451
  )
448
- and path not in orchestrator
452
+ and not any(_is_within(path, item) for item in orchestrator)
449
453
  and not _excluded(Path(path), generated)
450
454
  }
451
455
 
@@ -464,7 +468,7 @@ def _artifact_policy_failures(
464
468
  unauthorized = {
465
469
  path for path in changed
466
470
  if not any(_is_within(path, item) for item in allowed)
467
- and path not in orchestrator
471
+ and not any(_is_within(path, item) for item in orchestrator)
468
472
  }
469
473
  if snapshot.artifact_root == snapshot.root:
470
474
  return []
@@ -1,11 +1,11 @@
1
- """Mini JSON Schema validator for the final-report data.json.
1
+ """Mini JSON Schema validator for versioned final-report data.json records.
2
2
 
3
3
  This is a deliberately narrow JSON Schema implementation that supports
4
4
  exactly the keywords used by ``schemas/final-report-v2.0.schema.json``:
5
5
 
6
6
  type, required, properties, additionalProperties, enum, const, pattern,
7
7
  minLength, minItems, maxItems, uniqueItems, items, minimum, maximum, $ref ($defs),
8
- oneOf, allOf, if/then/else, contains, not
8
+ oneOf, allOf, if/then/else, contains, minContains, maxContains, not
9
9
 
10
10
  We do NOT depend on the ``jsonschema`` PyPI package because its
11
11
  dependency tree (``referencing``, ``rpds-py``) includes a Rust C
@@ -13,8 +13,8 @@ extension which we would have to vendor or ship pre-built per platform.
13
13
  The ~250 lines below cover everything the final-report schema needs;
14
14
  strict spec compliance is not a goal — strict validation of OUR schema is.
15
15
 
16
- Error messages include the JSON pointer of the failing field so the
17
- report-writer can fix the exact data.json cell.
16
+ Error messages include the JSON pointer of the failing field so report
17
+ assembly can return the defect to the input owner.
18
18
  """
19
19
  from __future__ import annotations
20
20
 
@@ -23,9 +23,6 @@ import re
23
23
  from pathlib import Path
24
24
  from typing import Any
25
25
 
26
- from okstra_ctl.report_contract import CURRENT_REPORT_SCHEMA_VERSION
27
-
28
-
29
26
  class SchemaError(ValueError):
30
27
  """Raised when a schema itself is malformed (e.g. a $ref points
31
28
  nowhere). Distinct from a data validation failure.
@@ -204,10 +201,20 @@ class _Validator:
204
201
  for i, value in enumerate(instance):
205
202
  self.validate(value, items, path + (i,))
206
203
  contains = schema.get("contains")
207
- if contains is not None and not any(
208
- self._matches(value, contains) for value in instance
209
- ):
210
- self._err(path, f"array does not contain any item matching {self._summarise(contains)}")
204
+ if contains is not None:
205
+ matches = sum(self._matches(value, contains) for value in instance)
206
+ minimum = schema.get("minContains", 1)
207
+ maximum = schema.get("maxContains")
208
+ if matches < minimum:
209
+ self._err(
210
+ path,
211
+ f"array contains {matches} matching item(s), below minContains {minimum}",
212
+ )
213
+ if maximum is not None and matches > maximum:
214
+ self._err(
215
+ path,
216
+ f"array contains {matches} matching item(s), above maxContains {maximum}",
217
+ )
211
218
 
212
219
  def _validate_conditional(self, instance: Any, schema: dict, path: tuple[str | int, ...]) -> None:
213
220
  if_schema = schema.get("if")
@@ -263,10 +270,11 @@ def validate(data: Any, schema: dict) -> list[str]:
263
270
 
264
271
  SCHEMA_FILENAMES = {
265
272
  "2.0": "final-report-v2.0.schema.json",
273
+ "3.0": "final-report-v3.0.schema.json",
266
274
  }
267
275
 
268
276
 
269
- def _locate_and_load_schema(filename: str, start: Path | None = None) -> dict:
277
+ def load_named_schema(filename: str, start: Path | None = None) -> dict:
270
278
  here = (start or Path(__file__)).resolve()
271
279
  if here.is_file():
272
280
  here = here.parent
@@ -283,7 +291,7 @@ def load_schema_version(version: str, start: Path | None = None) -> dict:
283
291
  filename = SCHEMA_FILENAMES[version]
284
292
  except KeyError as exc:
285
293
  raise SchemaError(f"unsupported final-report schemaVersion: {version}") from exc
286
- return _locate_and_load_schema(filename, start=start)
294
+ return load_named_schema(filename, start=start)
287
295
 
288
296
 
289
297
  def load_schema_for_data(data: dict, start: Path | None = None) -> dict:
@@ -295,10 +303,11 @@ def load_schema_for_data(data: dict, start: Path | None = None) -> dict:
295
303
 
296
304
 
297
305
  def load_schema(schema_path: Path | None = None) -> dict:
298
- """Load the final-report schema. If ``schema_path`` is None, locate
299
- ``schemas/final-report-v2.0.schema.json`` relative to this file's
300
- repo root.
306
+ """Load the compatibility schema used by historical direct callers.
307
+
308
+ New write paths select contract 3.0 explicitly. Readers select from the
309
+ record's ``schemaVersion`` through :func:`load_schema_for_data`.
301
310
  """
302
311
  if schema_path is not None:
303
312
  return json.loads(Path(schema_path).read_text(encoding="utf-8"))
304
- return load_schema_version(CURRENT_REPORT_SCHEMA_VERSION)
313
+ return load_schema_version("2.0")
@@ -20,6 +20,7 @@ from pathlib import Path
20
20
  from typing import Any, Dict, List, Optional
21
21
 
22
22
  from .run_context import dir_flock
23
+ from .jsonl import append_jsonl
23
24
 
24
25
  FIX_CYCLES_FILENAME = "fix-cycles.jsonl"
25
26
  _LOCK_FILENAME = ".fix-cycles.lock"
@@ -122,8 +123,7 @@ def append_closed(task_root: Path, *, cycle: str, closed_by: str,
122
123
  def _append_row(task_root: Path, record: Dict[str, Any]) -> None:
123
124
  p = fix_cycles_path(task_root)
124
125
  p.parent.mkdir(parents=True, exist_ok=True)
125
- with p.open("a", encoding="utf-8") as f:
126
- f.write(json.dumps(record, ensure_ascii=False) + "\n")
126
+ append_jsonl(p, record, ensure_ascii=False, compact=False)
127
127
 
128
128
 
129
129
  def summarize(rows: List[Dict[str, Any]]) -> Dict[str, Any]: