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
@@ -101,6 +101,7 @@ def build_analysis_packet(
101
101
  instruction_set_relative_path: str,
102
102
  fix_history_text: str = "",
103
103
  stage_ledger_json: str = "",
104
+ stage_ledger_notice: str = "",
104
105
  ) -> str:
105
106
  """Return the primary compact input for Claude/Codex/Antigravity analysers."""
106
107
  brief_text = task_brief_path.read_text(encoding="utf-8")
@@ -124,9 +125,90 @@ def build_analysis_packet(
124
125
  parts.extend(_reference_block(reference_text))
125
126
  parts.extend(_fix_history_block(fix_history_text))
126
127
  parts.extend(_stage_ledger_block(stage_ledger_json))
128
+ parts.extend(_stage_ledger_unavailable_block(stage_ledger_notice))
127
129
  parts.extend(_clarification_block(clarification_text))
128
130
  parts.extend(_directive_block(directive))
129
- return "\n".join(part.rstrip() for part in parts).rstrip() + "\n"
131
+ body = "\n".join(part.rstrip() for part in parts).rstrip() + "\n"
132
+ return _with_section_index(body)
133
+
134
+
135
+ _HEADING_RE = re.compile(r"\A#{1,2} \S")
136
+ _FENCE_RE = re.compile(r"\A\s*(```|~~~)")
137
+ # 목차 자신이 차지하는 줄 수 중 표제 행을 뺀 나머지. 행 수는 표제 개수로
138
+ # 정해지므로 삽입 전에 전체 이동량을 계산할 수 있다.
139
+ _INDEX_PREAMBLE_LINES = 5
140
+
141
+
142
+ def _with_section_index(body: str) -> str:
143
+ """Prefix the rendered packet with every section's line range.
144
+
145
+ The packet's own structure is the only thing a worker can navigate it by,
146
+ and a worker cannot navigate what it cannot see. Measured 2026-08-21: the
147
+ antigravity CLI's `view_file` truncates at 46,080 bytes per call, carries
148
+ no line-range parameter, and reports only `<n> lines, <m> bytes` — the same
149
+ string whether it returned the file or 40% of it. On a 114KB packet that
150
+ first call ended at line 510, so `## Stage Ledger` (572) and
151
+ `## Clarification Carry-In Extract` (680) never arrived and the worker
152
+ planned from the pre-migration brief that fills the first 42KB. Every
153
+ evidence line it cited was under 400. With the ranges here a truncated
154
+ reader can fetch the block it is missing by range instead of re-reading
155
+ the head; the lead had to write those ranges by hand to get a usable
156
+ retry.
157
+ """
158
+ lines = body.splitlines()
159
+ anchor = next(
160
+ (i for i, line in enumerate(lines)
161
+ if line.startswith("# OKSTRA Analysis Packet")),
162
+ None,
163
+ )
164
+ if anchor is None:
165
+ return body
166
+ headings = _heading_lines(lines)
167
+ if not headings:
168
+ return body
169
+ shift = _INDEX_PREAMBLE_LINES + len(headings)
170
+ total = len(lines) + shift
171
+ starts = [number + shift for number, _ in headings]
172
+ rows = [
173
+ f"- {start}-{end} `{text}`"
174
+ for start, end, (_, text) in zip(
175
+ starts, [nxt - 1 for nxt in starts[1:]] + [total], headings
176
+ )
177
+ ]
178
+ block = [
179
+ "",
180
+ "## Section Index",
181
+ "",
182
+ f"This packet is {total} lines. Read a section by line range with"
183
+ " `sed -n '<start>,<end>p'` rather than opening the whole file: a"
184
+ " whole-file read is truncated by some worker CLIs without reporting"
185
+ " it, and the sections that describe current state are last.",
186
+ "",
187
+ *rows,
188
+ ]
189
+ assert len(block) == shift, (len(block), shift)
190
+ return "\n".join(lines[:anchor + 1] + block + lines[anchor + 1:]) + "\n"
191
+
192
+
193
+ def _heading_lines(lines: list[str]) -> list[tuple[int, str]]:
194
+ """Every `#`/`##` heading outside a fence, as (1-based line, text).
195
+
196
+ Fences are tracked because the Stage Ledger and the carried-in
197
+ clarification rows embed JSON and shell text where a `#` starts a comment,
198
+ not a section.
199
+ """
200
+ out: list[tuple[int, str]] = []
201
+ fenced = False
202
+ for number, line in enumerate(lines, start=1):
203
+ if _FENCE_RE.match(line):
204
+ fenced = not fenced
205
+ continue
206
+ if fenced or not _HEADING_RE.match(line):
207
+ continue
208
+ if line.startswith("# OKSTRA Analysis Packet"):
209
+ continue
210
+ out.append((number, line.strip()))
211
+ return out
130
212
 
131
213
 
132
214
  def _packet_frontmatter(brief_text: str, task_key: str) -> str:
@@ -240,8 +322,20 @@ def _stage_ledger_block(stage_ledger_json: str) -> list[str]:
240
322
  "",
241
323
  "Facts about this task's stages as they stand on disk — not a plan.",
242
324
  "A stage whose `status` is `done` is already implemented and will not",
243
- "be executed again, so do not rewrite its plan body. Stage numbers",
244
- "already listed here are taken; never reuse or renumber them.",
325
+ "be executed again, so do not rewrite its plan body.",
326
+ "",
327
+ "`stages` lists every stage the latest plan declares, so every number",
328
+ "in it is taken. Never reuse or renumber one: a new stage takes the",
329
+ "next number after the highest listed here, and reworking a completed",
330
+ "stage means cancelling it and adding a new number, never editing it",
331
+ "in place. `sourcePlan` is the plan the completed stages were built",
332
+ "against; `latestPlan` is the plan this list came from. When the two",
333
+ "differ, the completed work followed the former and the numbering",
334
+ "authority is the latter.",
335
+ "",
336
+ "A `planDivergence` entry means the two plans disagree about a",
337
+ "completed stage. Treat it as a blocker for any new stage number and",
338
+ "report it; do not resolve it by choosing one of the two yourself.",
245
339
  "",
246
340
  "```json",
247
341
  stage_ledger_json.strip(),
@@ -250,6 +344,33 @@ def _stage_ledger_block(stage_ledger_json: str) -> list[str]:
250
344
  ]
251
345
 
252
346
 
347
+ def _stage_ledger_unavailable_block(notice: str) -> list[str]:
348
+ """원장을 못 읽었다는 사실을 packet 에 남긴다.
349
+
350
+ 블록을 생략하면 저작 쪽은 "이전 계획이 없다" 로 읽는다 — 프로파일이 그렇게
351
+ 지시한다. 계획이 있는데 못 읽은 경우에 그 침묵은 거짓이고, 이미 점유된
352
+ 번호를 새 stage 에 다시 내주는 경로가 된다.
353
+ """
354
+ if not notice.strip():
355
+ return []
356
+ return [
357
+ "",
358
+ "## Stage Ledger",
359
+ "",
360
+ "This task's stage facts could NOT be read, so no ledger is included.",
361
+ "Do not read this as 'the task has no prior plan' — a plan exists and",
362
+ "this run could not parse it.",
363
+ "",
364
+ f"- Reason: {notice.strip()}",
365
+ "",
366
+ "Do not assign a number to any new stage in this state. A number taken",
367
+ "by a plan this run could not read would collide with the completed",
368
+ "work under it, and that collision stays silent until integration.",
369
+ "Report this as a blocker instead.",
370
+ "",
371
+ ]
372
+
373
+
253
374
  def _clarification_block(clarification_text: str) -> list[str]:
254
375
  if not clarification_text.strip():
255
376
  return []
@@ -0,0 +1,328 @@
1
+ """선임 에이전트가 소유하는 승인 결정 입력 원장."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import json
6
+ import re
7
+ import sys
8
+ from copy import deepcopy
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+ from typing import Any, Mapping, Sequence
12
+
13
+ from .convergence_store import write_json_atomic
14
+ from .json_boundary import JsonBoundaryError, load_owned_object
15
+
16
+
17
+ DISPOSITIONS = frozenset({"select", "accept-risk", "request-revision", "reject"})
18
+ REACHES = frozenset({"in-repo", "cross-repo"})
19
+ SCOPE_EFFECTS = frozenset({"new-schema", "deferrable"})
20
+ CLASSIFICATIONS = frozenset(
21
+ {"user-decision", "noncritical-dissent", "correctness-critical"}
22
+ )
23
+ _FORBIDDEN_BY_CLASSIFICATION = {
24
+ "correctness-critical": frozenset({"select", "accept-risk"}),
25
+ "noncritical-dissent": frozenset({"select"}),
26
+ "user-decision": frozenset(),
27
+ }
28
+
29
+
30
+ class ApprovalDecisionError(ValueError):
31
+ """승인 결정 입력이 역할 계약이나 선택지 불변식을 위반했다."""
32
+
33
+
34
+ def _required(value: str, field: str) -> str:
35
+ if not isinstance(value, str) or not value.strip():
36
+ raise ApprovalDecisionError(f"{field} must be a non-empty string")
37
+ return value.strip()
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class DecisionOption:
42
+ role: str
43
+ answer: str
44
+ rationale: str
45
+ disposition: str
46
+ reach: str
47
+ scope_effects: tuple[str, ...]
48
+ added_work: str
49
+ direction_change: str
50
+
51
+ def __post_init__(self) -> None:
52
+ if self.role not in {"recommended", "alternative"}:
53
+ raise ApprovalDecisionError(f"invalid option role: {self.role}")
54
+ for field in ("answer", "rationale", "added_work", "direction_change"):
55
+ _required(getattr(self, field), field)
56
+ if self.disposition not in DISPOSITIONS:
57
+ raise ApprovalDecisionError(f"invalid disposition: {self.disposition}")
58
+ if self.reach not in REACHES:
59
+ raise ApprovalDecisionError(f"invalid reach: {self.reach}")
60
+ unknown = sorted(set(self.scope_effects) - SCOPE_EFFECTS)
61
+ if unknown or len(set(self.scope_effects)) != len(self.scope_effects):
62
+ raise ApprovalDecisionError(f"invalid scope_effects: {unknown}")
63
+
64
+ def to_payload(self) -> dict[str, Any]:
65
+ return {
66
+ "role": self.role,
67
+ "answer": self.answer,
68
+ "rationale": self.rationale,
69
+ "disposition": self.disposition,
70
+ "reach": self.reach,
71
+ "scopeEffects": list(self.scope_effects),
72
+ "addedWork": self.added_work,
73
+ "directionChange": self.direction_change,
74
+ }
75
+
76
+
77
+ def _new_ledger(task_key: str, task_type: str, run_seq: str) -> dict[str, Any]:
78
+ return {
79
+ "schemaVersion": "1.0",
80
+ "owner": "lead",
81
+ "taskKey": _required(task_key, "task_key"),
82
+ "taskType": _required(task_type, "task_type"),
83
+ "runSeq": _required(run_seq, "run_seq"),
84
+ "activeClarifications": [],
85
+ "carriedDecisions": [],
86
+ }
87
+
88
+
89
+ def _read_ledger(path: Path) -> dict[str, Any]:
90
+ try:
91
+ value = load_owned_object(path, artifact="approval decision ledger")
92
+ except JsonBoundaryError as exc:
93
+ raise ApprovalDecisionError(f"cannot read approval ledger {path}: {exc}") from exc
94
+ if not isinstance(value, dict) or value.get("owner") != "lead":
95
+ raise ApprovalDecisionError(f"approval ledger owner must be lead: {path}")
96
+ return value
97
+
98
+
99
+ def _ledger(path: Path, task_key: str, task_type: str, run_seq: str) -> dict[str, Any]:
100
+ if not path.is_file():
101
+ return _new_ledger(task_key, task_type, run_seq)
102
+ ledger = _read_ledger(path)
103
+ expected = (task_key, task_type, run_seq)
104
+ actual = (ledger.get("taskKey"), ledger.get("taskType"), ledger.get("runSeq"))
105
+ if actual != expected:
106
+ raise ApprovalDecisionError("approval ledger identity does not match this run")
107
+ return ledger
108
+
109
+
110
+ def _validate_option_set(
111
+ options: Sequence[DecisionOption], classification: str,
112
+ recommended_disposition: str,
113
+ ) -> None:
114
+ if len(options) < 2:
115
+ raise ApprovalDecisionError("a decision requires at least two options")
116
+ if sum(option.role == "recommended" for option in options) != 1:
117
+ raise ApprovalDecisionError("a decision requires exactly one recommended option")
118
+ forbidden = _FORBIDDEN_BY_CLASSIFICATION[classification]
119
+ used = {recommended_disposition, *(option.disposition for option in options)}
120
+ invalid = sorted(used & forbidden)
121
+ if invalid:
122
+ raise ApprovalDecisionError(f"{classification} forbids dispositions: {invalid}")
123
+ recommended = next(option for option in options if option.role == "recommended")
124
+ if recommended.disposition != recommended_disposition:
125
+ raise ApprovalDecisionError(
126
+ "recommended_disposition must match the recommended option"
127
+ )
128
+
129
+
130
+ def _decision_row(
131
+ *, clarification_id: str, ticket_id: str, statement: str,
132
+ expected_form: str, classification: str, origin: str,
133
+ user_confirmation: str, unblock_condition: str,
134
+ recommended_disposition: str, options: Sequence[DecisionOption],
135
+ ) -> dict[str, Any]:
136
+ if classification not in CLASSIFICATIONS:
137
+ raise ApprovalDecisionError(f"invalid classification: {classification}")
138
+ if recommended_disposition not in DISPOSITIONS:
139
+ raise ApprovalDecisionError(
140
+ f"invalid recommended_disposition: {recommended_disposition}"
141
+ )
142
+ _validate_option_set(options, classification, recommended_disposition)
143
+ return {
144
+ "id": _required(clarification_id, "clarification_id"),
145
+ "ticketId": _required(ticket_id, "ticket_id"),
146
+ "kind": "decision",
147
+ "statement": _required(statement, "statement"),
148
+ "expectedForm": _required(expected_form, "expected_form"),
149
+ "blocks": "approval",
150
+ "origin": _required(origin, "origin"),
151
+ "userConfirmation": _required(user_confirmation, "user_confirmation"),
152
+ "approval": {
153
+ "classification": classification,
154
+ "unblockCondition": _required(unblock_condition, "unblock_condition"),
155
+ "recommendedDisposition": recommended_disposition,
156
+ },
157
+ "options": [option.to_payload() for option in options],
158
+ }
159
+
160
+
161
+ def open_decision(
162
+ *, ledger_path: Path, task_key: str, task_type: str, run_seq: str,
163
+ clarification_id: str, ticket_id: str, statement: str, expected_form: str,
164
+ classification: str, origin: str, user_confirmation: str,
165
+ unblock_condition: str, recommended_disposition: str,
166
+ options: Sequence[DecisionOption],
167
+ ) -> None:
168
+ row = _decision_row(
169
+ clarification_id=clarification_id, ticket_id=ticket_id,
170
+ statement=statement, expected_form=expected_form,
171
+ classification=classification, origin=origin,
172
+ user_confirmation=user_confirmation, unblock_condition=unblock_condition,
173
+ recommended_disposition=recommended_disposition, options=options,
174
+ )
175
+ ledger = _ledger(ledger_path, task_key, task_type, run_seq)
176
+ active = ledger.get("activeClarifications")
177
+ if not isinstance(active, list):
178
+ raise ApprovalDecisionError("activeClarifications must be an array")
179
+ if any(item.get("id") == clarification_id for item in active if isinstance(item, Mapping)):
180
+ raise ApprovalDecisionError(f"duplicate active clarification: {clarification_id}")
181
+ active.append(row)
182
+ write_json_atomic(ledger_path, ledger)
183
+
184
+
185
+ def resolve_decision(
186
+ ledger_path: Path, clarification_id: str, *, disposition: str,
187
+ user_text: str, user_response_ref: str, check_refs: Sequence[str],
188
+ ) -> None:
189
+ if disposition not in DISPOSITIONS:
190
+ raise ApprovalDecisionError(f"invalid disposition: {disposition}")
191
+ resolution = {
192
+ "disposition": disposition,
193
+ "userText": _required(user_text, "user_text"),
194
+ "userResponseRef": _required(user_response_ref, "user_response_ref"),
195
+ "checkRefs": [_required(ref, "check_refs") for ref in check_refs],
196
+ }
197
+ if not resolution["checkRefs"]:
198
+ raise ApprovalDecisionError("check_refs requires at least one value")
199
+ invalid_refs = [
200
+ ref for ref in resolution["checkRefs"]
201
+ if re.fullmatch(r"A-\d{3,}", ref) is None
202
+ ]
203
+ if invalid_refs:
204
+ raise ApprovalDecisionError(f"check_refs must be activity IDs: {invalid_refs}")
205
+ ledger = _read_ledger(ledger_path)
206
+ active = ledger.get("activeClarifications")
207
+ rows = active if isinstance(active, list) else []
208
+ matches = [row for row in rows if isinstance(row, dict) and row.get("id") == clarification_id]
209
+ if len(matches) != 1:
210
+ raise ApprovalDecisionError(f"active clarification not found: {clarification_id}")
211
+ matches[0]["resolutionInput"] = resolution
212
+ write_json_atomic(ledger_path, ledger)
213
+
214
+
215
+ def carry_decision(
216
+ ledger_path: Path, *, source_run_ref: str, decision: Mapping[str, Any],
217
+ ) -> None:
218
+ ledger = _read_ledger(ledger_path)
219
+ carried = ledger.get("carriedDecisions")
220
+ if not isinstance(carried, list):
221
+ raise ApprovalDecisionError("carriedDecisions must be an array")
222
+ row = {
223
+ "sourceRunRef": _required(source_run_ref, "source_run_ref"),
224
+ "decision": deepcopy(dict(decision)),
225
+ }
226
+ if row not in carried:
227
+ carried.append(row)
228
+ write_json_atomic(ledger_path, ledger)
229
+
230
+
231
+ def _options_from_args(args: argparse.Namespace) -> tuple[DecisionOption, ...]:
232
+ fields = (
233
+ args.option_role, args.option_answer, args.option_rationale,
234
+ args.option_disposition, args.option_reach, args.option_scope_effect,
235
+ args.option_added_work, args.option_direction_change,
236
+ )
237
+ if len({len(values) for values in fields}) != 1:
238
+ raise ApprovalDecisionError("every repeated option field needs the same count")
239
+ return tuple(
240
+ DecisionOption(role, answer, rationale, disposition, reach,
241
+ tuple(effect.split(",")) if effect else (), added, direction)
242
+ for role, answer, rationale, disposition, reach, effect, added, direction
243
+ in zip(*fields, strict=True)
244
+ )
245
+
246
+
247
+ def _add_open_arguments(parser: argparse.ArgumentParser) -> None:
248
+ required = (
249
+ "task-key", "task-type", "run-seq", "clarification-id", "ticket-id",
250
+ "statement", "expected-form", "classification", "origin",
251
+ "user-confirmation", "unblock-condition", "recommended-disposition",
252
+ )
253
+ for flag in required:
254
+ parser.add_argument(f"--{flag}", required=True)
255
+ repeated = (
256
+ "role", "answer", "rationale", "disposition", "reach",
257
+ "scope-effect", "added-work", "direction-change",
258
+ )
259
+ for flag in repeated:
260
+ parser.add_argument(f"--option-{flag}", action="append", default=[])
261
+
262
+
263
+ def _parser() -> argparse.ArgumentParser:
264
+ parser = argparse.ArgumentParser(prog="okstra approval-decision")
265
+ commands = parser.add_subparsers(dest="command", required=True)
266
+ opened = commands.add_parser("open")
267
+ opened.add_argument("--ledger", type=Path, required=True)
268
+ _add_open_arguments(opened)
269
+ resolved = commands.add_parser("resolve")
270
+ resolved.add_argument("--ledger", type=Path, required=True)
271
+ resolved.add_argument("--clarification-id", required=True)
272
+ resolved.add_argument("--disposition", required=True)
273
+ resolved.add_argument("--user-text", required=True)
274
+ resolved.add_argument("--user-response-ref", required=True)
275
+ resolved.add_argument("--check-ref", action="append", default=[])
276
+ carried = commands.add_parser("carry")
277
+ carried.add_argument("--ledger", type=Path, required=True)
278
+ carried.add_argument("--source-ledger", type=Path, required=True)
279
+ carried.add_argument("--source-run-ref", required=True)
280
+ carried.add_argument("--clarification-id", required=True)
281
+ return parser
282
+
283
+
284
+ def _open_from_args(args: argparse.Namespace) -> None:
285
+ values = vars(args).copy()
286
+ values.pop("command")
287
+ values["ledger_path"] = values.pop("ledger")
288
+ for key in tuple(values):
289
+ if key.startswith("option_"):
290
+ values.pop(key)
291
+ open_decision(**values, options=_options_from_args(args))
292
+
293
+
294
+ def _carry_from_args(args: argparse.Namespace) -> None:
295
+ source = _read_ledger(args.source_ledger)
296
+ rows = source.get("activeClarifications") or []
297
+ matches = [row for row in rows if isinstance(row, dict) and row.get("id") == args.clarification_id]
298
+ if len(matches) != 1:
299
+ raise ApprovalDecisionError(f"source clarification not found: {args.clarification_id}")
300
+ carry_decision(args.ledger, source_run_ref=args.source_run_ref, decision=matches[0])
301
+
302
+
303
+ def _run(args: argparse.Namespace) -> None:
304
+ if args.command == "open":
305
+ _open_from_args(args)
306
+ elif args.command == "resolve":
307
+ resolve_decision(
308
+ args.ledger, args.clarification_id, disposition=args.disposition,
309
+ user_text=args.user_text, user_response_ref=args.user_response_ref,
310
+ check_refs=args.check_ref,
311
+ )
312
+ else:
313
+ _carry_from_args(args)
314
+
315
+
316
+ def main(argv: list[str] | None = None) -> int:
317
+ try:
318
+ args = _parser().parse_args(argv)
319
+ _run(args)
320
+ print(json.dumps({"ok": True, "ledger": str(args.ledger)}, ensure_ascii=False))
321
+ return 0
322
+ except (ApprovalDecisionError, OSError) as exc:
323
+ print(f"approval-decision: {exc}", file=sys.stderr)
324
+ return 1
325
+
326
+
327
+ if __name__ == "__main__":
328
+ raise SystemExit(main())
@@ -12,6 +12,7 @@ from .domain.worker_stream import host_event_row
12
12
  from .execution_identity import Attempt, ExecutionManifestError
13
13
  from .execution_manifest import read_execution_manifest
14
14
  from .execution_manifest import _replace_manifest, _write_manifest_over_existing
15
+ from .jsonl import append_jsonl
15
16
  from .run_context import task_mutex
16
17
 
17
18
 
@@ -104,8 +105,7 @@ class HostEventStreamWriter:
104
105
  payload=payload,
105
106
  timestamp=_utc_now(),
106
107
  )
107
- with self.path.open("a", encoding="utf-8") as handle:
108
- handle.write(json.dumps(row, ensure_ascii=False) + "\n")
108
+ append_jsonl(self.path, row, ensure_ascii=False, compact=False)
109
109
  return row
110
110
 
111
111
 
@@ -13,6 +13,7 @@ from .ids import build_run_id
13
13
  from .paths import runs_dir_of
14
14
  from .invocation import save_invocation
15
15
  from .jsonl import append_jsonl, read_jsonl, rotate_recent_if_needed
16
+ from .json_boundary import JsonBoundaryError, load_owned_object, write_owned_object_atomic
16
17
  from .project_meta import _project_meta_path
17
18
  from .reconcile import _now_iso, normalize_central_status
18
19
  from .run_index_row import build_run_index_row
@@ -64,8 +65,8 @@ def discover_project_roots(home: Path) -> List[tuple]:
64
65
  if not meta_file.is_file():
65
66
  continue
66
67
  try:
67
- meta = json.loads(meta_file.read_text())
68
- except (OSError, json.JSONDecodeError):
68
+ meta = load_owned_object(meta_file, artifact="project metadata")
69
+ except JsonBoundaryError:
69
70
  continue
70
71
  pid = str(meta.get("projectId") or "")
71
72
  root = str(meta.get("projectRoot") or "")
@@ -89,7 +90,7 @@ def _apply_backfill_meta(home: Path, project_id: str, project_root: Path, *,
89
90
  target = _project_meta_path(home, project_id)
90
91
  target.parent.mkdir(parents=True, exist_ok=True)
91
92
  if target.is_file():
92
- meta = json.loads(target.read_text())
93
+ meta = load_owned_object(target, artifact="project metadata")
93
94
  else:
94
95
  meta = {"projectId": project_id, "projectRoot": str(project_root),
95
96
  "firstRunAt": "", "lastRunAt": "",
@@ -108,9 +109,7 @@ def _apply_backfill_meta(home: Path, project_id: str, project_root: Path, *,
108
109
  meta["runCount"] = int(meta.get("runCount", 0)) + run_count_inc
109
110
  meta["activeCount"] = max(
110
111
  0, int(meta.get("activeCount", 0)) + active_count_inc)
111
- tmp = target.with_suffix(".json.tmp")
112
- tmp.write_text(json.dumps(meta, indent=2) + "\n")
113
- os.replace(tmp, target)
112
+ write_owned_object_atomic(target, meta, artifact="project metadata")
114
113
 
115
114
 
116
115
  def backfill_project(home: Path, project_id: str, project_root: Path) -> int:
@@ -142,8 +141,8 @@ def backfill_project(home: Path, project_id: str, project_root: Path) -> int:
142
141
  task_type = m.group("tt")
143
142
  seq = int(m.group("seq"))
144
143
  try:
145
- manifest = json.loads(mf.read_text())
146
- except (OSError, json.JSONDecodeError):
144
+ manifest = load_owned_object(mf, artifact="run manifest")
145
+ except JsonBoundaryError:
147
146
  # 부분 기록 / 손상된 매니페스트를 빈 dict 로 강등해 row 를
148
147
  # 만들면, 빈 status 가 'completed' 로 normalize 되어
149
148
  # recent 에 박히고, 같은 runId 가 existing_ids 로 인덱싱
@@ -268,6 +267,10 @@ def backfill_project(home: Path, project_id: str, project_root: Path) -> int:
268
267
 
269
268
  def mark_backfilled(home: Path) -> None:
270
269
  state_file = home / "state.json"
271
- state = json.loads(state_file.read_text()) if state_file.is_file() else {}
270
+ state = (
271
+ load_owned_object(state_file, artifact="okstra home state")
272
+ if state_file.is_file()
273
+ else {}
274
+ )
272
275
  state["backfilledAt"] = _now_iso()
273
- state_file.write_text(json.dumps(state, indent=2) + "\n")
276
+ write_owned_object_atomic(state_file, state, artifact="okstra home state")
@@ -9,6 +9,7 @@ from pathlib import Path
9
9
  from typing import List, Optional
10
10
 
11
11
  from .listing import list_runs
12
+ from .json_boundary import write_owned_object_atomic
12
13
  from .resolver import resolve_last, resolve_run_id
13
14
 
14
15
 
@@ -20,10 +21,7 @@ def make_batch_id() -> str:
20
21
 
21
22
  def write_batch_meta(home: Path, batch_id: str, payload: dict) -> Path:
22
23
  target = home / "batches" / f"{batch_id}.json"
23
- target.parent.mkdir(parents=True, exist_ok=True)
24
- tmp = target.with_suffix(".json.tmp")
25
- tmp.write_text(json.dumps(payload, indent=2) + "\n")
26
- os.replace(tmp, target)
24
+ write_owned_object_atomic(target, payload, artifact="batch metadata")
27
25
  return target
28
26
 
29
27
 
@@ -16,6 +16,8 @@ import json
16
16
  import shlex
17
17
  from pathlib import Path
18
18
 
19
+ from .json_boundary import JsonBoundaryError, load_owned_object
20
+
19
21
  DEFAULT_BUILD_TOOL_TOKENS: tuple[str, ...] = (
20
22
  "npm",
21
23
  "yarn",
@@ -49,10 +51,11 @@ def resolve_build_tool_tokens(project_root: Path) -> tuple[str, ...]:
49
51
  uses, so one rule covers both.
50
52
  """
51
53
  try:
52
- payload = json.loads(
53
- (Path(project_root) / ".okstra" / "project.json").read_text(encoding="utf-8")
54
+ payload = load_owned_object(
55
+ Path(project_root) / ".okstra" / "project.json",
56
+ artifact="project configuration",
54
57
  )
55
- except (OSError, json.JSONDecodeError):
58
+ except JsonBoundaryError:
56
59
  return DEFAULT_BUILD_TOOL_TOKENS
57
60
  configured = payload.get("buildToolTokens") if isinstance(payload, dict) else None
58
61
  if not isinstance(configured, list):