okstra 0.186.7 → 0.187.0

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 (541) hide show
  1. package/README.md +1 -1
  2. package/dist/cli-registry.d.mts +88 -1
  3. package/dist/cli-registry.mjs +68 -111
  4. package/dist/cli-registry.mjs.map +1 -1
  5. package/dist/commands/execute/render-bundle.mjs +0 -1
  6. package/dist/commands/execute/render-bundle.mjs.map +1 -1
  7. package/dist/commands/execute/run.mjs +8 -3
  8. package/dist/commands/execute/run.mjs.map +1 -1
  9. package/dist/commands/lifecycle/check-project.mjs +1 -14
  10. package/dist/commands/lifecycle/check-project.mjs.map +1 -1
  11. package/dist/commands/lifecycle/config.mjs +38 -40
  12. package/dist/commands/lifecycle/config.mjs.map +1 -1
  13. package/dist/commands/lifecycle/doctor.d.mts +22 -7
  14. package/dist/commands/lifecycle/doctor.mjs +77 -49
  15. package/dist/commands/lifecycle/doctor.mjs.map +1 -1
  16. package/dist/commands/lifecycle/install.d.mts +12 -10
  17. package/dist/commands/lifecycle/install.mjs +104 -39
  18. package/dist/commands/lifecycle/install.mjs.map +1 -1
  19. package/dist/commands/lifecycle/paths.mjs +8 -3
  20. package/dist/commands/lifecycle/paths.mjs.map +1 -1
  21. package/dist/commands/lifecycle/preflight.mjs +2 -1
  22. package/dist/commands/lifecycle/preflight.mjs.map +1 -1
  23. package/dist/commands/lifecycle/setup.mjs +22 -36
  24. package/dist/commands/lifecycle/setup.mjs.map +1 -1
  25. package/dist/commands/lifecycle/uninstall.d.mts +4 -2
  26. package/dist/commands/lifecycle/uninstall.mjs +59 -15
  27. package/dist/commands/lifecycle/uninstall.mjs.map +1 -1
  28. package/dist/commands/memory/memory.mjs +7 -1
  29. package/dist/commands/memory/memory.mjs.map +1 -1
  30. package/dist/lib/helper-scripts.d.mts +1 -1
  31. package/dist/lib/helper-scripts.mjs +10 -18
  32. package/dist/lib/helper-scripts.mjs.map +1 -1
  33. package/dist/lib/host-config.d.mts +72 -0
  34. package/dist/lib/host-config.mjs +404 -0
  35. package/dist/lib/host-config.mjs.map +1 -0
  36. package/dist/lib/host-registry-client.d.mts +2 -2
  37. package/dist/lib/host-registry-client.mjs +0 -3
  38. package/dist/lib/host-registry-client.mjs.map +1 -1
  39. package/dist/lib/install-assets.d.mts +1 -0
  40. package/dist/lib/install-assets.mjs +4 -0
  41. package/dist/lib/install-assets.mjs.map +1 -1
  42. package/dist/lib/proc.d.mts +2 -0
  43. package/dist/lib/proc.mjs +12 -0
  44. package/dist/lib/proc.mjs.map +1 -1
  45. package/dist/lib/python-command.d.mts +2 -0
  46. package/dist/lib/python-command.mjs +52 -0
  47. package/dist/lib/python-command.mjs.map +1 -0
  48. package/dist/lib/python-helper.d.mts +2 -5
  49. package/dist/lib/python-helper.mjs +3 -52
  50. package/dist/lib/python-helper.mjs.map +1 -1
  51. package/dist/lib/runtime-payload.d.mts +23 -0
  52. package/dist/lib/runtime-payload.mjs +57 -0
  53. package/dist/lib/runtime-payload.mjs.map +1 -0
  54. package/dist/lib/types.d.mts +24 -13
  55. package/docs/architecture/storage-model.md +21 -5
  56. package/docs/architecture.md +37 -36
  57. package/docs/cli.md +55 -76
  58. package/docs/coding-rules.md +295 -0
  59. package/docs/container.md +10 -36
  60. package/docs/contributor-change-matrix.md +1 -1
  61. package/docs/for-ai/skills/okstra-code-review.md +0 -1
  62. package/docs/for-ai/skills/okstra-container-build.md +8 -41
  63. package/docs/for-ai/skills/okstra-inspect.md +4 -4
  64. package/docs/for-ai/skills/okstra-manager.md +2 -2
  65. package/docs/for-ai/skills/okstra-rollup.md +0 -1
  66. package/docs/for-ai/skills/okstra-run.md +3 -3
  67. package/docs/for-ai/skills/okstra-user-response.md +0 -1
  68. package/docs/performance-improvement-plan-v2.md +1 -1
  69. package/docs/project-structure-overview.md +62 -62
  70. package/docs/task-process/README.md +3 -3
  71. package/docs/task-process/common-flow.md +1 -1
  72. package/docs/task-process/error-analysis.md +3 -3
  73. package/docs/task-process/implementation-planning.md +3 -1
  74. package/docs/task-process/release-handoff.md +1 -1
  75. package/package.json +3 -2
  76. package/runtime/BUILD.json +2 -2
  77. package/runtime/agents/workers/claude-worker.md +6 -11
  78. package/runtime/agents/workers/report-writer-worker.md +1 -1
  79. package/runtime/bin/lib/okstra/cli.sh +4 -0
  80. package/runtime/bin/lib/okstra/globals.sh +2 -0
  81. package/runtime/bin/lib/okstra/interactive.sh +26 -173
  82. package/runtime/bin/lib/okstra/project-resolver.sh +23 -61
  83. package/runtime/bin/lib/okstra/usage.sh +3 -3
  84. package/runtime/bin/okstra-compact-reminder.sh +1 -5
  85. package/runtime/bin/okstra-error-log.py +19 -2
  86. package/runtime/bin/okstra-import-check.py +26 -0
  87. package/runtime/bin/okstra-inject-report-index.py +5 -4
  88. package/runtime/bin/okstra-provider-exec.py +22 -17
  89. package/runtime/bin/okstra-render-final-report.py +16 -6
  90. package/runtime/bin/okstra-render-report-views.py +25 -18
  91. package/runtime/bin/okstra-report-translate.py +30 -18
  92. package/runtime/bin/okstra-spawn-followups.py +4 -0
  93. package/runtime/bin/okstra-token-usage.py +3 -0
  94. package/runtime/bin/okstra.sh +4 -2
  95. package/runtime/bin/okstra_bootstrap.py +58 -0
  96. package/runtime/prompts/coding-preflight/overview.md +1 -1
  97. package/runtime/prompts/duties/planning-worker.md +1 -1
  98. package/runtime/prompts/launch.template.md +35 -9
  99. package/runtime/prompts/lead/convergence.md +96 -87
  100. package/runtime/prompts/lead/okstra-lead-contract.md +63 -12
  101. package/runtime/prompts/lead/plan-body-verification.md +65 -46
  102. package/runtime/prompts/lead/report-writer.md +34 -4
  103. package/runtime/prompts/lead/team-contract.md +4 -1
  104. package/runtime/prompts/profiles/_clarification-recommendation.md +2 -1
  105. package/runtime/prompts/profiles/_coding-conventions-preflight.md +1 -1
  106. package/runtime/prompts/profiles/_common-contract.md +3 -3
  107. package/runtime/prompts/profiles/_coverage-critic.md +1 -1
  108. package/runtime/prompts/profiles/_implementation-deliverable.md +2 -2
  109. package/runtime/prompts/profiles/_implementation-executor.md +3 -1
  110. package/runtime/prompts/profiles/_implementation-verifier.md +16 -2
  111. package/runtime/prompts/profiles/error-analysis.md +4 -3
  112. package/runtime/prompts/profiles/final-verification.md +2 -2
  113. package/runtime/prompts/profiles/implementation-planning.md +22 -17
  114. package/runtime/prompts/profiles/implementation.md +3 -2
  115. package/runtime/prompts/profiles/requirements-discovery.md +2 -1
  116. package/runtime/prompts/wizard/prompts.ko.json +29 -8
  117. package/runtime/python/okstra_ctl/__init__.py +6 -27
  118. package/runtime/python/okstra_ctl/adapters/hosts/claude-code/relay.md +4 -5
  119. package/runtime/python/okstra_ctl/adapters/hosts/codex/adapter.py +21 -2
  120. package/runtime/python/okstra_ctl/adapters/hosts/external/adapter.py +3 -12
  121. package/runtime/python/okstra_ctl/adapters/providers/antigravity/adapter.py +40 -22
  122. package/runtime/python/okstra_ctl/adapters/providers/claude/adapter.py +14 -21
  123. package/runtime/python/okstra_ctl/adapters/providers/codex/adapter.py +37 -20
  124. package/runtime/python/okstra_ctl/adapters/providers/grok/adapter.py +86 -14
  125. package/runtime/python/okstra_ctl/adapters/providers/kimi/adapter.py +12 -12
  126. package/runtime/python/okstra_ctl/adapters/runtime/cmux.py +4 -7
  127. package/runtime/python/okstra_ctl/agent/__init__.py +1 -0
  128. package/runtime/python/okstra_ctl/{agent_activity.py → agent/activity.py} +12 -1
  129. package/runtime/python/okstra_ctl/{agent_invocation.py → agent/invocation.py} +34 -5
  130. package/runtime/python/okstra_ctl/agent/prompt_cli/__init__.py +19 -0
  131. package/runtime/python/okstra_ctl/agent/prompt_cli/__main__.py +11 -0
  132. package/runtime/python/okstra_ctl/agent/prompt_cli/cli.py +215 -0
  133. package/runtime/python/okstra_ctl/agent/prompt_cli/dynamic_verifier.py +155 -0
  134. package/runtime/python/okstra_ctl/agent/prompt_cli/emit.py +66 -0
  135. package/runtime/python/okstra_ctl/agent/prompt_cli/inputs.py +138 -0
  136. package/runtime/python/okstra_ctl/agent/prompt_cli/materialize.py +408 -0
  137. package/runtime/python/okstra_ctl/agent/prompt_cli/results.py +197 -0
  138. package/runtime/python/okstra_ctl/agent/prompt_cli/run_identity.py +107 -0
  139. package/runtime/python/okstra_ctl/analysis_packet.py +47 -4
  140. package/runtime/python/okstra_ctl/approval_decisions.py +260 -8
  141. package/runtime/python/okstra_ctl/assignment_resolver.py +0 -12
  142. package/runtime/python/okstra_ctl/attempt_evidence.py +12 -24
  143. package/runtime/python/okstra_ctl/blocking_checks.py +251 -0
  144. package/runtime/python/okstra_ctl/brief_frontmatter.py +0 -7
  145. package/runtime/python/okstra_ctl/clarification_items/__init__.py +102 -0
  146. package/runtime/python/okstra_ctl/clarification_items/carry.py +224 -0
  147. package/runtime/python/okstra_ctl/clarification_items/dispositions.py +135 -0
  148. package/runtime/python/okstra_ctl/clarification_items/parsing.py +253 -0
  149. package/runtime/python/okstra_ctl/clarification_items/rows.py +115 -0
  150. package/runtime/python/okstra_ctl/clarification_items/scan.py +215 -0
  151. package/runtime/python/okstra_ctl/clarification_items/sidecars.py +211 -0
  152. package/runtime/python/okstra_ctl/cmux.py +38 -31
  153. package/runtime/python/okstra_ctl/code_review_target.py +163 -1
  154. package/runtime/python/okstra_ctl/conformance.py +23 -0
  155. package/runtime/python/okstra_ctl/container.py +38 -421
  156. package/runtime/python/okstra_ctl/context_cost.py +16 -2
  157. package/runtime/python/okstra_ctl/contract_graph_cli.py +7 -0
  158. package/runtime/python/okstra_ctl/convergence.py +101 -15
  159. package/runtime/python/okstra_ctl/convergence_critic_prompt.py +345 -0
  160. package/runtime/python/okstra_ctl/convergence_engine.py +382 -70
  161. package/runtime/python/okstra_ctl/convergence_store.py +5 -29
  162. package/runtime/python/okstra_ctl/design_prep.py +108 -12
  163. package/runtime/python/okstra_ctl/design_snapshot.py +11 -1
  164. package/runtime/python/okstra_ctl/design_surfaces.py +29 -2
  165. package/runtime/python/okstra_ctl/dispatch_core.py +102 -25
  166. package/runtime/python/okstra_ctl/dispatch_state.py +268 -39
  167. package/runtime/python/okstra_ctl/doctor_cli.py +48 -0
  168. package/runtime/python/okstra_ctl/domain/worker_exec.py +10 -5
  169. package/runtime/python/okstra_ctl/domain/worker_presentation.py +21 -1
  170. package/runtime/python/okstra_ctl/domain/worker_stream.py +52 -21
  171. package/runtime/python/okstra_ctl/domain/write_policy.py +219 -0
  172. package/runtime/python/okstra_ctl/entrypoints/hosts.py +9 -2
  173. package/runtime/python/okstra_ctl/error_log_core.py +1 -1
  174. package/runtime/python/okstra_ctl/error_report.py +14 -0
  175. package/runtime/python/okstra_ctl/error_zip.py +18 -2
  176. package/runtime/python/okstra_ctl/execution_identity.py +100 -7
  177. package/runtime/python/okstra_ctl/execution_manifest.py +67 -20
  178. package/runtime/python/okstra_ctl/execution_mutation_audit.py +90 -8
  179. package/runtime/python/okstra_ctl/final_report_paths.py +17 -0
  180. package/runtime/python/okstra_ctl/final_report_schema.py +90 -16
  181. package/runtime/python/okstra_ctl/git_reconcile.py +22 -2
  182. package/runtime/python/okstra_ctl/handoff.py +24 -1
  183. package/runtime/python/okstra_ctl/ids.py +6 -16
  184. package/runtime/python/okstra_ctl/implementation_direction.py +142 -33
  185. package/runtime/python/okstra_ctl/implementation_options.py +25 -11
  186. package/runtime/python/okstra_ctl/implementation_outcome.py +5 -2
  187. package/runtime/python/okstra_ctl/improvement_lenses.py +0 -14
  188. package/runtime/python/okstra_ctl/incremental_carry.py +69 -8
  189. package/runtime/python/okstra_ctl/incremental_scope.py +173 -12
  190. package/runtime/python/okstra_ctl/index.py +2 -2
  191. package/runtime/python/okstra_ctl/initial_prompt_materialization.py +48 -8
  192. package/runtime/python/okstra_ctl/interactive_cli.py +223 -0
  193. package/runtime/python/okstra_ctl/json_boundary.py +10 -0
  194. package/runtime/python/okstra_ctl/listing.py +0 -91
  195. package/runtime/python/okstra_ctl/locks.py +5 -19
  196. package/runtime/python/okstra_ctl/log_report.py +14 -0
  197. package/runtime/python/okstra_ctl/manager_cli.py +22 -3
  198. package/runtime/python/okstra_ctl/manager_launch.py +4 -8
  199. package/runtime/python/okstra_ctl/material.py +2 -2
  200. package/runtime/python/okstra_ctl/migrate.py +23 -1
  201. package/runtime/python/okstra_ctl/model_cli.py +19 -5
  202. package/runtime/python/okstra_ctl/model_discovery.py +46 -47
  203. package/runtime/python/okstra_ctl/model_io/__init__.py +1 -0
  204. package/runtime/python/okstra_ctl/model_io/lines.py +163 -0
  205. package/runtime/python/okstra_ctl/model_io/references.py +370 -0
  206. package/runtime/python/okstra_ctl/model_io/renderers.py +498 -0
  207. package/runtime/python/okstra_ctl/model_io_cli.py +30 -945
  208. package/runtime/python/okstra_ctl/model_pool.py +10 -6
  209. package/runtime/python/okstra_ctl/models.py +8 -8
  210. package/runtime/python/okstra_ctl/mutation_recovery.py +5 -63
  211. package/runtime/python/okstra_ctl/next_phase.py +121 -69
  212. package/runtime/python/okstra_ctl/pane_reclaim.py +23 -0
  213. package/runtime/python/okstra_ctl/pane_title.py +11 -8
  214. package/runtime/python/okstra_ctl/path_hints.py +36 -11
  215. package/runtime/python/okstra_ctl/paths.py +76 -8
  216. package/runtime/python/okstra_ctl/plan_items.py +132 -16
  217. package/runtime/python/okstra_ctl/plan_items_cli.py +486 -36
  218. package/runtime/python/okstra_ctl/plan_run_root.py +1 -1
  219. package/runtime/python/okstra_ctl/plan_validate_cli.py +51 -0
  220. package/runtime/python/okstra_ctl/plan_verify_cli.py +80 -0
  221. package/runtime/python/okstra_ctl/prepare_error.py +20 -0
  222. package/runtime/python/okstra_ctl/prior_planning.py +157 -0
  223. package/runtime/python/okstra_ctl/profile_show.py +18 -1
  224. package/runtime/python/okstra_ctl/project_setup_cli.py +140 -0
  225. package/runtime/python/okstra_ctl/recap.py +22 -1
  226. package/runtime/python/okstra_ctl/reconcile.py +84 -0
  227. package/runtime/python/okstra_ctl/registry/host_registry.py +18 -1
  228. package/runtime/python/okstra_ctl/render.py +98 -32
  229. package/runtime/python/okstra_ctl/render_final_report.py +3 -7
  230. package/runtime/python/okstra_ctl/report_assembly.py +297 -7
  231. package/runtime/python/okstra_ctl/report_contract.py +30 -7
  232. package/runtime/python/okstra_ctl/report_finalize.py +206 -15
  233. package/runtime/python/okstra_ctl/report_html/common.py +2 -2
  234. package/runtime/python/okstra_ctl/report_html/render.py +8 -12
  235. package/runtime/python/okstra_ctl/report_language.py +8 -4
  236. package/runtime/python/okstra_ctl/report_narrative.py +90 -3
  237. package/runtime/python/okstra_ctl/report_projections.py +8 -2
  238. package/runtime/python/okstra_ctl/report_synthesis_packet.py +258 -1
  239. package/runtime/python/okstra_ctl/report_translation.py +2 -35
  240. package/runtime/python/okstra_ctl/report_views.py +4 -22
  241. package/runtime/python/okstra_ctl/resolve_task_key.py +13 -0
  242. package/runtime/python/okstra_ctl/rollup.py +13 -0
  243. package/runtime/python/okstra_ctl/run.py +639 -138
  244. package/runtime/python/okstra_ctl/run_audit.py +13 -0
  245. package/runtime/python/okstra_ctl/run_index_row.py +0 -12
  246. package/runtime/python/okstra_ctl/seeding.py +21 -11
  247. package/runtime/python/okstra_ctl/sequence.py +1 -1
  248. package/runtime/python/okstra_ctl/session.py +64 -14
  249. package/runtime/python/okstra_ctl/set_work_status.py +18 -0
  250. package/runtime/python/okstra_ctl/stage_integrate.py +15 -1
  251. package/runtime/python/okstra_ctl/stage_ledger.py +1 -1
  252. package/runtime/python/okstra_ctl/stage_map.py +57 -11
  253. package/runtime/python/okstra_ctl/stage_map_cli.py +83 -0
  254. package/runtime/python/okstra_ctl/stage_map_view.py +65 -0
  255. package/runtime/python/okstra_ctl/stage_targets.py +3 -12
  256. package/runtime/python/okstra_ctl/task_list_cli.py +138 -0
  257. package/runtime/python/okstra_ctl/task_show_cli.py +66 -0
  258. package/runtime/python/okstra_ctl/task_target.py +4 -16
  259. package/runtime/python/okstra_ctl/team.py +49 -2
  260. package/runtime/python/okstra_ctl/time_report.py +17 -2
  261. package/runtime/python/okstra_ctl/usage_report.py +11 -0
  262. package/runtime/python/okstra_ctl/user_response.py +55 -218
  263. package/runtime/python/okstra_ctl/user_response_values.py +242 -0
  264. package/runtime/python/okstra_ctl/validation_contract.py +3 -0
  265. package/runtime/python/okstra_ctl/verification_target.py +68 -0
  266. package/runtime/python/okstra_ctl/wizard.py +457 -92
  267. package/runtime/python/okstra_ctl/worker_artifacts.py +75 -16
  268. package/runtime/python/okstra_ctl/worker_audit_check.py +22 -0
  269. package/runtime/python/okstra_ctl/worker_dispatch.py +21 -0
  270. package/runtime/python/okstra_ctl/worker_liveness.py +33 -0
  271. package/runtime/python/okstra_ctl/worker_prompt_body.py +15 -2
  272. package/runtime/python/okstra_ctl/worker_prompt_contract.py +98 -7
  273. package/runtime/python/okstra_ctl/worker_prompt_headers.py +24 -2
  274. package/runtime/python/okstra_ctl/worker_prompt_policy.py +18 -3
  275. package/runtime/python/okstra_ctl/worker_request.py +2 -1
  276. package/runtime/python/okstra_ctl/worker_runner.py +16 -9
  277. package/runtime/python/okstra_ctl/worker_state.py +16 -1
  278. package/runtime/python/okstra_ctl/workflow.py +18 -1
  279. package/runtime/python/okstra_ctl/worktree/__init__.py +92 -0
  280. package/runtime/python/okstra_ctl/worktree/cleanliness.py +89 -0
  281. package/runtime/python/okstra_ctl/worktree/decisions.py +127 -0
  282. package/runtime/python/okstra_ctl/worktree/git_ops.py +165 -0
  283. package/runtime/python/okstra_ctl/worktree/linking.py +249 -0
  284. package/runtime/python/okstra_ctl/worktree/naming.py +91 -0
  285. package/runtime/python/okstra_ctl/worktree/provision.py +385 -0
  286. package/runtime/python/okstra_ctl/worktree/sync_config.py +181 -0
  287. package/runtime/python/okstra_ctl/worktree_cli.py +75 -0
  288. package/runtime/python/okstra_ctl/worktree_lookup_cli.py +39 -0
  289. package/runtime/python/okstra_ctl/worktree_registry.py +7 -0
  290. package/runtime/python/okstra_ctl/worktree_status_cli.py +53 -0
  291. package/runtime/python/okstra_ctl/write_policy.py +90 -213
  292. package/runtime/python/okstra_project/__init__.py +0 -4
  293. package/runtime/python/okstra_project/dirs.py +2 -2
  294. package/runtime/python/okstra_project/phase_pointer.py +84 -0
  295. package/runtime/python/okstra_project/slug.py +24 -0
  296. package/runtime/python/okstra_project/state.py +22 -230
  297. package/runtime/python/okstra_token_usage/claude.py +9 -1
  298. package/runtime/python/okstra_token_usage/cli.py +3 -1
  299. package/runtime/python/okstra_token_usage/collect.py +71 -10
  300. package/runtime/python/okstra_token_usage/cursor.py +2 -0
  301. package/runtime/python/okstra_token_usage/grok.py +1 -5
  302. package/runtime/python/okstra_token_usage/paths.py +26 -0
  303. package/runtime/python/okstra_token_usage/pricing.py +19 -7
  304. package/runtime/schemas/convergence-critic-results-v1.0.schema.json +5 -0
  305. package/runtime/schemas/execution-manifest-v2.schema.json +10 -10
  306. package/runtime/schemas/final-report-v2.0.schema.json +9 -26
  307. package/runtime/schemas/final-report-v3.0.schema.json +173 -41
  308. package/runtime/schemas/report-narrative-v3.0.schema.json +3 -2
  309. package/runtime/skills/okstra-brief-gen/SKILL.md +35 -2
  310. package/runtime/skills/okstra-container-build/SKILL.md +16 -47
  311. package/runtime/skills/okstra-inspect/facets/history.md +1 -1
  312. package/runtime/skills/okstra-inspect/facets/status.md +7 -6
  313. package/runtime/skills/okstra-pr-gen/SKILL.md +1 -1
  314. package/runtime/skills/okstra-run/SKILL.md +7 -5
  315. package/runtime/templates/report-writer-prompt-preamble.md +1 -1
  316. package/runtime/templates/reports/brief.template.md +6 -2
  317. package/runtime/templates/reports/error-analysis-input.template.md +2 -0
  318. package/runtime/templates/reports/final-verification-input.template.md +2 -0
  319. package/runtime/templates/reports/implementation-input.template.md +7 -1
  320. package/runtime/templates/reports/implementation-planning-input.template.md +4 -0
  321. package/runtime/templates/reports/quick-input.template.md +2 -0
  322. package/runtime/templates/reports/release-handoff-input.template.md +6 -1
  323. package/runtime/templates/reports/report.js +20 -3
  324. package/runtime/templates/reports/schedule.template.md +2 -0
  325. package/runtime/templates/reports/settings.template.json +0 -10
  326. package/runtime/templates/reports/task-brief.template.md +3 -0
  327. package/runtime/templates/reports/user-response.template.md +7 -3
  328. package/runtime/validators/checks/fixtures-01.py +57 -0
  329. package/runtime/validators/checks/fixtures-02.py +565 -0
  330. package/runtime/validators/checks/runners-01.py +108 -0
  331. package/runtime/validators/checks/validate-assets-01.py +60 -0
  332. package/runtime/validators/checks/validate-prompt-metadata-01.py +261 -0
  333. package/runtime/validators/checks/validate-tasks-01.py +46 -0
  334. package/runtime/validators/checks/validate-tasks-02.py +85 -0
  335. package/runtime/validators/checks/validate-tasks-03.py +61 -0
  336. package/runtime/validators/checks/validate-tasks-04.py +118 -0
  337. package/runtime/validators/forbidden_actions.py +73 -3
  338. package/runtime/validators/lib/common.sh +5 -0
  339. package/runtime/validators/lib/fixtures.sh +7 -591
  340. package/runtime/validators/lib/paths.sh +13 -0
  341. package/runtime/validators/lib/runners.sh +6 -104
  342. package/runtime/validators/lib/summary.sh +1 -1
  343. package/runtime/validators/lib/validate-assets.sh +6 -56
  344. package/runtime/validators/lib/validate-prompt-metadata.sh +6 -257
  345. package/runtime/validators/lib/validate-tasks.sh +9 -294
  346. package/runtime/validators/validate-implementation-plan-stages.py +4 -4
  347. package/runtime/validators/validate-run.py +1369 -2654
  348. package/runtime/validators/validate-workflow.sh +56 -16
  349. package/runtime/validators/validate_improvement_report.py +2 -1
  350. package/runtime/validators/validate_session_conformance.py +295 -49
  351. package/dist/commands/execute/agent-prompt.d.mts +0 -1
  352. package/dist/commands/execute/agent-prompt.mjs +0 -24
  353. package/dist/commands/execute/agent-prompt.mjs.map +0 -1
  354. package/dist/commands/execute/codex-dispatch.d.mts +0 -3
  355. package/dist/commands/execute/codex-dispatch.mjs +0 -6
  356. package/dist/commands/execute/codex-dispatch.mjs.map +0 -1
  357. package/dist/commands/execute/codex-run.d.mts +0 -3
  358. package/dist/commands/execute/codex-run.mjs +0 -62
  359. package/dist/commands/execute/codex-run.mjs.map +0 -1
  360. package/dist/commands/execute/convergence.d.mts +0 -1
  361. package/dist/commands/execute/convergence.mjs +0 -38
  362. package/dist/commands/execute/convergence.mjs.map +0 -1
  363. package/dist/commands/execute/error-log.d.mts +0 -1
  364. package/dist/commands/execute/error-log.mjs +0 -18
  365. package/dist/commands/execute/error-log.mjs.map +0 -1
  366. package/dist/commands/execute/git-reconcile.d.mts +0 -1
  367. package/dist/commands/execute/git-reconcile.mjs +0 -30
  368. package/dist/commands/execute/git-reconcile.mjs.map +0 -1
  369. package/dist/commands/execute/handoff.d.mts +0 -1
  370. package/dist/commands/execute/handoff.mjs +0 -31
  371. package/dist/commands/execute/handoff.mjs.map +0 -1
  372. package/dist/commands/execute/incremental-carry.d.mts +0 -1
  373. package/dist/commands/execute/incremental-carry.mjs +0 -20
  374. package/dist/commands/execute/incremental-carry.mjs.map +0 -1
  375. package/dist/commands/execute/incremental-scope.d.mts +0 -1
  376. package/dist/commands/execute/incremental-scope.mjs +0 -29
  377. package/dist/commands/execute/incremental-scope.mjs.map +0 -1
  378. package/dist/commands/execute/integrate-stages.d.mts +0 -1
  379. package/dist/commands/execute/integrate-stages.mjs +0 -24
  380. package/dist/commands/execute/integrate-stages.mjs.map +0 -1
  381. package/dist/commands/execute/pane-title.d.mts +0 -1
  382. package/dist/commands/execute/pane-title.mjs +0 -20
  383. package/dist/commands/execute/pane-title.mjs.map +0 -1
  384. package/dist/commands/execute/plan-items.d.mts +0 -1
  385. package/dist/commands/execute/plan-items.mjs +0 -9
  386. package/dist/commands/execute/plan-items.mjs.map +0 -1
  387. package/dist/commands/execute/plan-validate.d.mts +0 -1
  388. package/dist/commands/execute/plan-validate.mjs +0 -68
  389. package/dist/commands/execute/plan-validate.mjs.map +0 -1
  390. package/dist/commands/execute/plan-verify.d.mts +0 -1
  391. package/dist/commands/execute/plan-verify.mjs +0 -43
  392. package/dist/commands/execute/plan-verify.mjs.map +0 -1
  393. package/dist/commands/execute/spawn-followups.d.mts +0 -1
  394. package/dist/commands/execute/spawn-followups.mjs +0 -22
  395. package/dist/commands/execute/spawn-followups.mjs.map +0 -1
  396. package/dist/commands/execute/team.d.mts +0 -3
  397. package/dist/commands/execute/team.mjs +0 -66
  398. package/dist/commands/execute/team.mjs.map +0 -1
  399. package/dist/commands/execute/token-usage.d.mts +0 -1
  400. package/dist/commands/execute/token-usage.mjs +0 -19
  401. package/dist/commands/execute/token-usage.mjs.map +0 -1
  402. package/dist/commands/execute/worker-audit-check.d.mts +0 -1
  403. package/dist/commands/execute/worker-audit-check.mjs +0 -34
  404. package/dist/commands/execute/worker-audit-check.mjs.map +0 -1
  405. package/dist/commands/execute/worker-dispatch.d.mts +0 -7
  406. package/dist/commands/execute/worker-dispatch.mjs +0 -64
  407. package/dist/commands/execute/worker-dispatch.mjs.map +0 -1
  408. package/dist/commands/execute/worker-state.d.mts +0 -1
  409. package/dist/commands/execute/worker-state.mjs +0 -28
  410. package/dist/commands/execute/worker-state.mjs.map +0 -1
  411. package/dist/commands/execute/worktree-lookup.d.mts +0 -1
  412. package/dist/commands/execute/worktree-lookup.mjs +0 -92
  413. package/dist/commands/execute/worktree-lookup.mjs.map +0 -1
  414. package/dist/commands/execute/worktree-status.d.mts +0 -1
  415. package/dist/commands/execute/worktree-status.mjs +0 -121
  416. package/dist/commands/execute/worktree-status.mjs.map +0 -1
  417. package/dist/commands/inspect/code-review.d.mts +0 -1
  418. package/dist/commands/inspect/code-review.mjs +0 -32
  419. package/dist/commands/inspect/code-review.mjs.map +0 -1
  420. package/dist/commands/inspect/container.d.mts +0 -1
  421. package/dist/commands/inspect/container.mjs +0 -25
  422. package/dist/commands/inspect/container.mjs.map +0 -1
  423. package/dist/commands/inspect/context-cost.d.mts +0 -1
  424. package/dist/commands/inspect/context-cost.mjs +0 -25
  425. package/dist/commands/inspect/context-cost.mjs.map +0 -1
  426. package/dist/commands/inspect/design-prep.d.mts +0 -1
  427. package/dist/commands/inspect/design-prep.mjs +0 -22
  428. package/dist/commands/inspect/design-prep.mjs.map +0 -1
  429. package/dist/commands/inspect/error-report.d.mts +0 -1
  430. package/dist/commands/inspect/error-report.mjs +0 -25
  431. package/dist/commands/inspect/error-report.mjs.map +0 -1
  432. package/dist/commands/inspect/error-zip.d.mts +0 -1
  433. package/dist/commands/inspect/error-zip.mjs +0 -24
  434. package/dist/commands/inspect/error-zip.mjs.map +0 -1
  435. package/dist/commands/inspect/log-report.d.mts +0 -1
  436. package/dist/commands/inspect/log-report.mjs +0 -26
  437. package/dist/commands/inspect/log-report.mjs.map +0 -1
  438. package/dist/commands/inspect/model-io.d.mts +0 -1
  439. package/dist/commands/inspect/model-io.mjs +0 -25
  440. package/dist/commands/inspect/model-io.mjs.map +0 -1
  441. package/dist/commands/inspect/profile-show.d.mts +0 -1
  442. package/dist/commands/inspect/profile-show.mjs +0 -28
  443. package/dist/commands/inspect/profile-show.mjs.map +0 -1
  444. package/dist/commands/inspect/recap.d.mts +0 -1
  445. package/dist/commands/inspect/recap.mjs +0 -30
  446. package/dist/commands/inspect/recap.mjs.map +0 -1
  447. package/dist/commands/inspect/resolve-task-key.d.mts +0 -1
  448. package/dist/commands/inspect/resolve-task-key.mjs +0 -24
  449. package/dist/commands/inspect/resolve-task-key.mjs.map +0 -1
  450. package/dist/commands/inspect/rollup.d.mts +0 -1
  451. package/dist/commands/inspect/rollup.mjs +0 -25
  452. package/dist/commands/inspect/rollup.mjs.map +0 -1
  453. package/dist/commands/inspect/run-audit.d.mts +0 -1
  454. package/dist/commands/inspect/run-audit.mjs +0 -25
  455. package/dist/commands/inspect/run-audit.mjs.map +0 -1
  456. package/dist/commands/inspect/set-work-status.d.mts +0 -1
  457. package/dist/commands/inspect/set-work-status.mjs +0 -29
  458. package/dist/commands/inspect/set-work-status.mjs.map +0 -1
  459. package/dist/commands/inspect/stage-map.d.mts +0 -1
  460. package/dist/commands/inspect/stage-map.mjs +0 -131
  461. package/dist/commands/inspect/stage-map.mjs.map +0 -1
  462. package/dist/commands/inspect/task-list.d.mts +0 -1
  463. package/dist/commands/inspect/task-list.mjs +0 -149
  464. package/dist/commands/inspect/task-list.mjs.map +0 -1
  465. package/dist/commands/inspect/task-show.d.mts +0 -1
  466. package/dist/commands/inspect/task-show.mjs +0 -108
  467. package/dist/commands/inspect/task-show.mjs.map +0 -1
  468. package/dist/commands/inspect/time-report.d.mts +0 -1
  469. package/dist/commands/inspect/time-report.mjs +0 -24
  470. package/dist/commands/inspect/time-report.mjs.map +0 -1
  471. package/dist/commands/inspect/usage-report.d.mts +0 -1
  472. package/dist/commands/inspect/usage-report.mjs +0 -23
  473. package/dist/commands/inspect/usage-report.mjs.map +0 -1
  474. package/dist/commands/inspect/user-response.d.mts +0 -1
  475. package/dist/commands/inspect/user-response.mjs +0 -35
  476. package/dist/commands/inspect/user-response.mjs.map +0 -1
  477. package/dist/commands/inspect/worker-liveness.d.mts +0 -1
  478. package/dist/commands/inspect/worker-liveness.mjs +0 -45
  479. package/dist/commands/inspect/worker-liveness.mjs.map +0 -1
  480. package/dist/commands/lifecycle/contract-check.d.mts +0 -1
  481. package/dist/commands/lifecycle/contract-check.mjs +0 -18
  482. package/dist/commands/lifecycle/contract-check.mjs.map +0 -1
  483. package/dist/commands/lifecycle/migrate.d.mts +0 -1
  484. package/dist/commands/lifecycle/migrate.mjs +0 -30
  485. package/dist/commands/lifecycle/migrate.mjs.map +0 -1
  486. package/dist/commands/lifecycle/model.d.mts +0 -1
  487. package/dist/commands/lifecycle/model.mjs +0 -22
  488. package/dist/commands/lifecycle/model.mjs.map +0 -1
  489. package/dist/commands/manager.d.mts +0 -3
  490. package/dist/commands/manager.mjs +0 -50
  491. package/dist/commands/manager.mjs.map +0 -1
  492. package/dist/commands/report/agent-activity.d.mts +0 -1
  493. package/dist/commands/report/agent-activity.mjs +0 -20
  494. package/dist/commands/report/agent-activity.mjs.map +0 -1
  495. package/dist/commands/report/approval-decision.d.mts +0 -1
  496. package/dist/commands/report/approval-decision.mjs +0 -21
  497. package/dist/commands/report/approval-decision.mjs.map +0 -1
  498. package/dist/commands/report/design-snapshot.d.mts +0 -1
  499. package/dist/commands/report/design-snapshot.mjs +0 -19
  500. package/dist/commands/report/design-snapshot.mjs.map +0 -1
  501. package/dist/commands/report/finalize.d.mts +0 -4
  502. package/dist/commands/report/finalize.mjs +0 -64
  503. package/dist/commands/report/finalize.mjs.map +0 -1
  504. package/dist/commands/report/inject-report-index.d.mts +0 -1
  505. package/dist/commands/report/inject-report-index.mjs +0 -21
  506. package/dist/commands/report/inject-report-index.mjs.map +0 -1
  507. package/dist/commands/report/render-final-report.d.mts +0 -1
  508. package/dist/commands/report/render-final-report.mjs +0 -23
  509. package/dist/commands/report/render-final-report.mjs.map +0 -1
  510. package/dist/commands/report/render-views.d.mts +0 -1
  511. package/dist/commands/report/render-views.mjs +0 -27
  512. package/dist/commands/report/render-views.mjs.map +0 -1
  513. package/dist/commands/report/translate.d.mts +0 -1
  514. package/dist/commands/report/translate.mjs +0 -33
  515. package/dist/commands/report/translate.mjs.map +0 -1
  516. package/runtime/bin/lib/okstra/tmux-pane.sh +0 -40
  517. package/runtime/bin/lib/okstra-ctl/cmd-batch.sh +0 -59
  518. package/runtime/bin/lib/okstra-ctl/cmd-list.sh +0 -35
  519. package/runtime/bin/lib/okstra-ctl/cmd-open.sh +0 -36
  520. package/runtime/bin/lib/okstra-ctl/cmd-projects.sh +0 -26
  521. package/runtime/bin/lib/okstra-ctl/cmd-reconcile.sh +0 -29
  522. package/runtime/bin/lib/okstra-ctl/cmd-reindex.sh +0 -38
  523. package/runtime/bin/lib/okstra-ctl/cmd-rerun.sh +0 -345
  524. package/runtime/bin/lib/okstra-ctl/cmd-show.sh +0 -27
  525. package/runtime/bin/lib/okstra-ctl/cmd-tail.sh +0 -92
  526. package/runtime/bin/lib/okstra-ctl/main.sh +0 -41
  527. package/runtime/bin/lib/okstra-ctl/prepare.sh +0 -31
  528. package/runtime/bin/lib/okstra-ctl/usage.sh +0 -23
  529. package/runtime/bin/okstra-central.sh +0 -152
  530. package/runtime/bin/okstra-incremental-carry.py +0 -10
  531. package/runtime/bin/okstra-incremental-scope.py +0 -10
  532. package/runtime/bin/okstra-team-reconcile.sh +0 -36
  533. package/runtime/python/okstra_ctl/agent_prompt_cli.py +0 -1156
  534. package/runtime/python/okstra_ctl/batch.py +0 -60
  535. package/runtime/python/okstra_ctl/clarification_items.py +0 -1050
  536. package/runtime/python/okstra_ctl/container_registry.py +0 -72
  537. package/runtime/python/okstra_ctl/improvement_assignment.py +0 -61
  538. package/runtime/python/okstra_ctl/resolver.py +0 -54
  539. package/runtime/python/okstra_ctl/team_reconcile.py +0 -275
  540. package/runtime/python/okstra_ctl/tmux.py +0 -134
  541. package/runtime/python/okstra_ctl/worktree.py +0 -1099
@@ -1,1050 +0,0 @@
1
- """Read a final-report's clarification rows, whichever schema wrote them.
2
-
3
- A clarification is what a run owes the user — a decision, a file attachment,
4
- a single data point. Each row carries a ``Blocks`` value out of
5
- ``{approval, next-phase, none}``. Rows with ``Blocks=approval`` are the
6
- approval gate: they MUST resolve before the user flips the frontmatter
7
- ``approved`` field to ``true`` and starts the next ``implementation`` run.
8
- A ``request-revision`` / ``reject`` answer still blocks the report that has
9
- not yet incorporated it. The report that recorded the same id in
10
- ``supersessionLedger`` has already absorbed that return and does not block.
11
-
12
- The two schemas store those rows in different places, and that is why the
13
- read functions take a report **path**, not its text:
14
-
15
- * schema-v1 — the ``## 1. Clarification Items`` markdown table (introduced
16
- when §4.5.9 / §5.1 / §5.2 collapsed into a single section).
17
- * schema-v2 — ``clarificationItems[]`` in the ``.data.json`` sibling. Its AI
18
- handoff markdown renders them as one section per row under
19
- ``## Clarification and User Decisions``, not as a §1 table, so the §1 table
20
- walk finds nothing there by construction.
21
-
22
- Every gate goes through ``scan_approval_gate`` / ``scan_open_user_input`` so
23
- run-prep (``_validate_approved_plan``), the wizard, and the user-response CLI
24
- cannot disagree about what is still open.
25
-
26
- Gate semantics are fail-closed: when the rows cannot be read with confidence
27
- (§1 heading missing/drifted, table header unrecognized, a body row whose
28
- metadata cell fails to parse, or a v2 row missing id/blocks/status), the scan
29
- reports an ``unreadable_reason`` and callers must refuse approval instead of
30
- soft-passing. ``parse_clarification_items`` keeps the lenient
31
- None-on-absence contract for the schema-v1 HTML-view renderers, which only
32
- need best-effort row extraction.
33
- """
34
- from __future__ import annotations
35
-
36
- import json
37
- import re
38
- from collections.abc import Mapping
39
- from dataclasses import dataclass
40
- from pathlib import Path
41
- from typing import Optional
42
-
43
- from okstra_ctl.final_report_paths import final_report_data_path
44
- from okstra_ctl.json_boundary import load_owned_object
45
- from okstra_ctl.md_table import is_separator_row, split_pipe_row, to_cell_text
46
-
47
-
48
- # The final-report renderer (render_final_report.py:_inject_anchors) appends a
49
- # ` <a id="slug"></a>` scroll anchor to every heading. The §1 slice MUST tolerate
50
- # that trailing anchor — otherwise `_section_1_slice` fails on every *rendered*
51
- # report, parse returns None, and the Blocks=approval approval gate is silently
52
- # bypassed (run.py:_validate_approved_plan soft-passes None).
53
- SECTION_HEADING_PATTERN = re.compile(
54
- r'^##\s+1\.\s+Clarification Items\s*(?:<a id="[^"]+"></a>\s*)?$',
55
- re.MULTILINE,
56
- )
57
- NEXT_TOP_LEVEL_HEADING_PATTERN = re.compile(r"^##\s+(?!1\.)", re.MULTILINE)
58
-
59
-
60
- @dataclass(frozen=True)
61
- class ClarificationItem:
62
- """One row of the §1 table.
63
-
64
- ``raw_*`` fields preserve the exact cell text (after backtick stripping)
65
- for diagnostics; canonical lowercased versions live in ``blocks`` /
66
- ``status`` for predicate use.
67
- """
68
- row_id: str
69
- kind: str # "material" | "decision" | "data-point" | other
70
- blocks: str # canonical lowercase: "approval" | "next-phase" | "none" | other
71
- status: str # canonical lowercase: "open" | "answered" | "resolved" | "obsolete" | other
72
- raw_blocks: str
73
- raw_status: str
74
-
75
-
76
- # The final-report renderer injects a scroll anchor into ID-defining first
77
- # cells — either leading (`<a id="c-001"></a>C-001`) or inside the bold marker
78
- # (`**<a id="e-001"></a>E-001**`). Strip every such empty anchor during cell
79
- # normalization so the ID parses as a bare token for clarification parsing AND
80
- # the HTML view's `C-\d+` form detection, and so the anchor never leaks into
81
- # the HTML view as html-escaped literal text.
82
- _CELL_ANCHOR_RE = re.compile(r'<a id="([^"]*)"></a>')
83
-
84
-
85
- def _strip_backticks(cell: str) -> str:
86
- s = _CELL_ANCHOR_RE.sub("", cell.strip()).strip()
87
- if s.startswith("`") and s.endswith("`") and len(s) >= 2:
88
- s = s[1:-1].strip()
89
- return s
90
-
91
-
92
- def _split_pipe_row(line: str) -> list[str]:
93
- """``md_table.split_pipe_row`` + §1-specific cell normalization
94
- (scroll-anchor removal, outer-backtick unwrap)."""
95
- return [_strip_backticks(c) for c in split_pipe_row(line)]
96
-
97
-
98
- def _section_1_slice(report_text: str) -> Optional[str]:
99
- """Return the substring spanning the §1 section (heading exclusive of the
100
- next ``##`` heading), or None if §1 is absent."""
101
- start_match = SECTION_HEADING_PATTERN.search(report_text)
102
- if not start_match:
103
- return None
104
- rest = report_text[start_match.end():]
105
- end_match = NEXT_TOP_LEVEL_HEADING_PATTERN.search(rest)
106
- return rest[: end_match.start()] if end_match else rest
107
-
108
-
109
- _META_ID_RE = re.compile(r"([A-Za-z][A-Za-z0-9]*-\d+)")
110
-
111
-
112
- def _meta_field(cell: str, key: str) -> str:
113
- """Extract a ``<key>: value`` field from a stacked §1 meta cell.
114
-
115
- The §1 table collapses the short columns into one ``<br>``-delimited
116
- metadata cell (``**C-101**<br>Ticket: `DEV-1`<br>Kind: `decision`<br>
117
- Blocks: `approval`<br>Status: open``). Values may be backtick-wrapped.
118
- """
119
- m = re.search(
120
- rf"{re.escape(key)}:\s*`?\s*([^`<|]+?)\s*`?\s*(?:<br\s*/?>|$)",
121
- cell,
122
- re.IGNORECASE,
123
- )
124
- return m.group(1).strip() if m else ""
125
-
126
-
127
- def _meta_id(cell: str) -> str:
128
- """The bold headline ID of a §1 meta cell (the part before the first <br>)."""
129
- headline = re.split(r"<br\s*/?>", cell, maxsplit=1)[0]
130
- m = _META_ID_RE.search(headline)
131
- return m.group(1) if m else ""
132
-
133
-
134
- def parse_meta_cell(cell: str) -> Optional[ClarificationItem]:
135
- """Parse one §1 stacked meta cell into a ``ClarificationItem``, or ``None``
136
- when the cell is not a §1 meta cell (no ``Blocks:``/``Status:`` markers —
137
- e.g. a header or an unrelated table). Shared by the approval-gate parser
138
- and the HTML view's form-attach pass so both read the cell identically.
139
- """
140
- raw_blocks = _meta_field(cell, "Blocks")
141
- raw_status = _meta_field(cell, "Status")
142
- if not (raw_blocks and raw_status):
143
- return None
144
- return ClarificationItem(
145
- row_id=_meta_id(cell),
146
- kind=_meta_field(cell, "Kind").lower(),
147
- blocks=raw_blocks.lower(),
148
- status=raw_status.lower(),
149
- raw_blocks=raw_blocks,
150
- raw_status=raw_status,
151
- )
152
-
153
-
154
- @dataclass(frozen=True)
155
- class _Section1Table:
156
- """Outcome of walking the §1 slice for its data table.
157
-
158
- ``items`` is ``None`` when no recognizable table header exists among the
159
- pipe lines. ``unparsed_row_count`` counts body rows whose metadata cell
160
- failed ``parse_meta_cell`` (all-empty filler rows excluded).
161
- ``has_pipe_lines`` distinguishes the renderer's legitimate table-less
162
- placeholder (emptyState bullet) from a table whose header drifted.
163
- """
164
- items: Optional[list[ClarificationItem]]
165
- unparsed_row_count: int
166
- has_pipe_lines: bool
167
-
168
-
169
- def _walk_section_1_table(section: str) -> _Section1Table:
170
- lines = section.splitlines()
171
- has_pipe_lines = any(line.lstrip().startswith("|") for line in lines)
172
- # Locate the §1 data table by its header. The merged-meta layout collapses
173
- # ID/Ticket/Kind/Blocks/Status into one metadata cell and keeps the
174
- # English `Statement` + `User input` columns; detect on those two (any
175
- # other table — intro, legacy 5.1/5.2 — is rejected).
176
- header_idx = -1
177
- for idx, line in enumerate(lines):
178
- if not line.lstrip().startswith("|"):
179
- continue
180
- cells = [c.lower() for c in _split_pipe_row(line)]
181
- if "user input" in cells and any(c.startswith("statement") for c in cells):
182
- header_idx = idx
183
- break
184
- if header_idx < 0:
185
- return _Section1Table(None, 0, has_pipe_lines)
186
-
187
- items: list[ClarificationItem] = []
188
- unparsed = 0
189
- body_started = False
190
- for line in lines[header_idx + 1:]:
191
- if not line.lstrip().startswith("|"):
192
- if body_started:
193
- break
194
- continue
195
- if is_separator_row(line):
196
- body_started = True
197
- continue
198
- if not body_started:
199
- continue
200
- cells = _split_pipe_row(line)
201
- if not any(cells):
202
- continue
203
- item = parse_meta_cell(cells[0])
204
- if item is None:
205
- unparsed += 1
206
- continue
207
- items.append(item)
208
- return _Section1Table(items, unparsed, True)
209
-
210
-
211
- def parse_clarification_items(report_text: str) -> Optional[list[ClarificationItem]]:
212
- """Return the list of §1 rows. ``None`` means "no §1 meta table detected"
213
- (missing section or unrecognized table header) — caller must NOT treat
214
- that as "table is empty".
215
-
216
- Lenient view-renderer contract: rows whose metadata cell fails to parse
217
- are skipped, not surfaced. The approval gate must use
218
- ``scan_approval_gate`` instead, which fail-closes on those rows.
219
- """
220
- section = _section_1_slice(report_text)
221
- if section is None:
222
- return None
223
- return _walk_section_1_table(section).items
224
-
225
-
226
- # schema-v2 는 clarification 을 §1 마크다운 테이블이 아니라 data.json 의
227
- # `clarificationItems[]` 로 들고, AI 핸드오프 Markdown 은 그것을
228
- # `## Clarification and User Decisions` 아래 JSON 블록으로 직렬화한다. §1 테이블
229
- # 워크는 거기서 아무것도 못 찾으므로, 게이트가 v1 파서만 쓰면 열린 항목이 있는
230
- # v2 리포트를 "읽을 수 없음"으로 떨어뜨린다. data.json 은 두 렌더러가 이미 읽는
231
- # SSOT 다 — clarification 상태도 여기서 읽는다.
232
- # 리포트 정본의 구조화 판본. 2.0 과 3.0 은 `clarificationItems[]` 를 같은 자리에
233
- # 같은 모양으로 들고 있으므로 이 좁히기에는 차이가 없다. ADR-0019 가 2.0 을 계속
234
- # 판독하라고 정했으므로 둘 다 받는다 — `stage_map.structured_report` 와 같은 규칙.
235
- STRUCTURED_REPORT_VERSIONS = frozenset({"2.0", "3.0"})
236
-
237
-
238
- def _read_report_text(report_path: Path) -> str:
239
- return report_path.read_text(encoding="utf-8", errors="replace")
240
-
241
-
242
- def _structured_report_data(report_path: Path) -> Optional[dict]:
243
- """``report_path`` 의 구조화 리포트 레코드, 아니면 ``None``.
244
-
245
- 파일이 없거나 JSON 이 깨졌거나 구조화 판본이 아니면 ``None`` — 호출자는 v1
246
- 마크다운 경로로 폴백하고, 진짜 구조화 리포트인데 레코드가 깨진 경우는 그
247
- 폴백이 "§1 없음" fail-closed 로 잡는다.
248
-
249
- 판본을 `2.0` 하나로 못박아 두던 동안 3.0 리포트가 여기서 ``None`` 이 됐고,
250
- 그러면 호출자가 JSON 본문에서 `## 1.` 헤딩을 찾다 실패해 **리포트 전문을
251
- 그대로 복사**했다 — 이 좁히기가 막으려던 바로 그 중복이다. 실측: 66K 짜리
252
- 추출본이 나와야 할 자리에 693K data.json 이 스테이징됐고, 패킷의
253
- `## Clarification Carry-In Extract` 가 0행이 되어 워커가 사용자 답변을
254
- 전혀 받지 못했다.
255
- """
256
- data_path = final_report_data_path(report_path)
257
- if not data_path.is_file():
258
- return None
259
- try:
260
- data = load_owned_object(data_path, artifact="final report record")
261
- except (OSError, ValueError):
262
- return None
263
- if (
264
- not isinstance(data, dict)
265
- or data.get("schemaVersion") not in STRUCTURED_REPORT_VERSIONS
266
- ):
267
- return None
268
- return data
269
-
270
-
271
- def _v2_row(entry: dict) -> Optional[dict]:
272
- """``clarificationItems[]`` 한 행을 §1 행과 같은 shape 으로. 필수 필드가
273
- 빠졌으면 ``None`` — 호출자가 fail-closed 로 셀 수 있게 한다."""
274
- row_id = entry.get("id")
275
- raw_blocks = entry.get("blocks")
276
- raw_status = entry.get("status")
277
- if not (isinstance(row_id, str) and row_id):
278
- return None
279
- if not (isinstance(raw_blocks, str) and raw_blocks):
280
- return None
281
- if not (isinstance(raw_status, str) and raw_status):
282
- return None
283
- kind = entry.get("kind")
284
- item = ClarificationItem(
285
- row_id=row_id,
286
- kind=kind.lower() if isinstance(kind, str) else "",
287
- blocks=raw_blocks.lower(),
288
- status=raw_status.lower(),
289
- raw_blocks=raw_blocks,
290
- raw_status=raw_status,
291
- )
292
- options = entry.get("options")
293
- return {
294
- "item": item,
295
- "statement": str(entry.get("statement") or ""),
296
- "expected_form": str(entry.get("expectedForm") or ""),
297
- "options": (
298
- [option for option in options if isinstance(option, dict)]
299
- if isinstance(options, list)
300
- else []
301
- ),
302
- "disposition": clarification_disposition(entry),
303
- }
304
-
305
-
306
- def _v2_clarification_rows(report_path: Path) -> Optional[list[dict]]:
307
- """schema-v2 clarification 행들, 이 리포트가 v2 가 아니면 ``None``.
308
- 필수 필드가 빠진 행은 건너뛰는 lenient 계약(§1 파서와 동일)."""
309
- data = _structured_report_data(report_path)
310
- if data is None:
311
- return None
312
- entries = data.get("clarificationItems")
313
- if not isinstance(entries, list):
314
- return []
315
- rows = [_v2_row(e) for e in entries if isinstance(e, dict)]
316
- return [row for row in rows if row is not None]
317
-
318
-
319
- def read_clarification_rows(report_path: Path) -> list[dict]:
320
- """리포트 한 건의 clarification 행 — schema-v2 는 data.json 사이드카에서,
321
- schema-v1 은 §1 테이블에서. 스키마 버전을 아는 유일한 읽기 지점이다.
322
-
323
- ``parse_section_1_rows`` 와 같은 lenient 계약: 어느 쪽에서도 행을 못 찾으면
324
- ``[]``.
325
- """
326
- v2_rows = _v2_clarification_rows(report_path)
327
- if v2_rows is not None:
328
- return v2_rows
329
- return parse_section_1_rows(_read_report_text(report_path))
330
-
331
-
332
- def parse_section_1_rows(report_text: str) -> list[dict]:
333
- """§1 테이블 한 행마다 메타(ClarificationItem) + 원문 Statement / Expected
334
- form 셀.
335
-
336
- 입력은 §1 을 담은 마크다운 본문이다 — 리포트 전문일 수도 있고, 다음 run 에
337
- 첨부되는 carry-in 본문(``clarification_response_with_sidecars``)일 수도
338
- 있다. §1 셀 추출의 단일 참조점 — 다른 모듈이 §1 레이아웃을 다시 훑지 않도록
339
- 이 함수로 통일한다.
340
- """
341
- section = _section_1_slice(report_text)
342
- if section is None:
343
- return []
344
- lines = section.splitlines()
345
- header_idx = -1
346
- for idx, line in enumerate(lines):
347
- if not line.lstrip().startswith("|"):
348
- continue
349
- cells = [c.lower() for c in _split_pipe_row(line)]
350
- if "user input" in cells and any(c.startswith("statement") for c in cells):
351
- header_idx = idx
352
- break
353
- if header_idx < 0:
354
- return []
355
- header = [c.lower() for c in _split_pipe_row(lines[header_idx])]
356
- s_col = next((j for j, h in enumerate(header) if h.startswith("statement")), -1)
357
- e_col = next((j for j, h in enumerate(header) if h.startswith("expected form")), -1)
358
- rows: list[dict] = []
359
- body = False
360
- for line in lines[header_idx + 1:]:
361
- if not line.lstrip().startswith("|"):
362
- if body:
363
- break
364
- continue
365
- if is_separator_row(line):
366
- body = True
367
- continue
368
- if not body:
369
- continue
370
- cells = _split_pipe_row(line)
371
- item = parse_meta_cell(cells[0]) if cells else None
372
- if item is None:
373
- continue
374
- rows.append({
375
- "item": item,
376
- "statement": cells[s_col] if 0 <= s_col < len(cells) else "",
377
- "expected_form": cells[e_col] if 0 <= e_col < len(cells) else "",
378
- # v1 cells are strings; the structured options live only in v2's
379
- # data sibling. The key stays so callers never branch on schema.
380
- "options": [],
381
- })
382
- return rows
383
-
384
-
385
- # 아직 사용자 판단이 없는 상태. `answered` 는 판단 기록이며 다시 묻지 않는다.
386
- UNRESOLVED_STATUSES = {"open"}
387
-
388
- # `Blocks` values that gate the user's `approved: true` flip.
389
- APPROVAL_BLOCKS = frozenset({"approval"})
390
- # `Blocks` values that owe the user an answer at all. `next-phase` rows never
391
- # gate approval, but the next phase cannot start with them open either, so the
392
- # user-response skill must list them — counting only `approval` made a report
393
- # whose open items were all `next-phase` look like it had none.
394
- USER_INPUT_BLOCKS = frozenset({"approval", "next-phase"})
395
- ANSWER_DISPOSITIONS = frozenset({
396
- "answer",
397
- "select",
398
- "accept-risk",
399
- "request-revision",
400
- "reject",
401
- })
402
- # 사용자가 진행을 고른 처분. DISAGREE 표와 행은 증거로 남고 게이트는 내리다.
403
- PROCEEDING_DISPOSITIONS = frozenset({"answer", "select", "accept-risk"})
404
- # 사용자가 이 계획으로 진행하지 않겠다고 고른 처분.
405
- RETURN_DISPOSITIONS = frozenset({"request-revision", "reject"})
406
- # 이 런이 그 답을 본문에 반영했다고 원장이 적은 처분.
407
- INCORPORATED_LEDGER_DISPOSITIONS = frozenset(
408
- {"superseded", "no-dependent-statement"}
409
- )
410
-
411
-
412
- def clarification_disposition(row: Mapping[str, object]) -> str:
413
- """행에 기록된 사용자 처분. 없으면 빈 문자열."""
414
- resolution = row.get("resolution")
415
- if not isinstance(resolution, Mapping):
416
- context = row.get("approvalContext")
417
- if isinstance(context, Mapping):
418
- resolution = context.get("resolution")
419
- if isinstance(resolution, Mapping):
420
- disposition = str(resolution.get("disposition") or "").strip()
421
- if disposition:
422
- return disposition
423
- user_input = str(row.get("userInput") or "").strip()
424
- options = row.get("options")
425
- if user_input and isinstance(options, list):
426
- for option in options:
427
- if (
428
- isinstance(option, Mapping)
429
- and str(option.get("answer") or "").strip() == user_input
430
- ):
431
- return str(option.get("disposition") or "").strip()
432
- return ""
433
-
434
-
435
- def incorporated_clarification_ids(
436
- report_data: Mapping[str, object] | None,
437
- ) -> frozenset[str]:
438
- """이 런이 답을 본문에 반영했다고 원장에 적은 C-id.
439
-
440
- `superseded` 와 `no-dependent-statement` 만 센다. 원장에 없는
441
- `request-revision` / `reject` 는 아직 다음 계획을 막는 되돌림이다.
442
- """
443
- if not isinstance(report_data, Mapping):
444
- return frozenset()
445
- planning = report_data.get("implementationPlanning")
446
- if not isinstance(planning, Mapping):
447
- return frozenset()
448
- ledger = planning.get("supersessionLedger")
449
- if not isinstance(ledger, list):
450
- return frozenset()
451
- ids: set[str] = set()
452
- for entry in ledger:
453
- if not isinstance(entry, Mapping):
454
- continue
455
- row_id = str(entry.get("clarificationId") or "").strip()
456
- disposition = str(entry.get("disposition") or "").strip().lower()
457
- if row_id and disposition in INCORPORATED_LEDGER_DISPOSITIONS:
458
- ids.add(row_id)
459
- return frozenset(ids)
460
-
461
-
462
- def row_blocks_progress(
463
- status: str,
464
- disposition: str = "",
465
- *,
466
- incorporated: bool = False,
467
- ) -> bool:
468
- """이 행이 승인·다음 단계 진입을 막는가.
469
-
470
- 진행 처분(`accept-risk` / `select` / `answer`)은 고치지 않은 DISAGREE 를
471
- 행과 투표에 남긴 채로 게이트만 내린다. `request-revision` / `reject` 는
472
- 사용자가 진행을 거절한 것이므로 막는다. 다만 이 보고서 원장이 그 답을
473
- 이미 반영했으면(`incorporated`) 같은 되돌림이 다음 계획 런을 강제하지
474
- 않는다. 처분이 없는 `answered` 도 판단 기록이므로 막지 않는다.
475
- """
476
- normalized_status = status.strip().lower()
477
- normalized_disposition = disposition.strip().lower()
478
- if normalized_status == "obsolete":
479
- return False
480
- if normalized_disposition in PROCEEDING_DISPOSITIONS:
481
- return False
482
- if normalized_disposition in RETURN_DISPOSITIONS:
483
- return not incorporated
484
- return normalized_status not in {"answered", "resolved"}
485
-
486
-
487
- def progress_blocking_ids(
488
- rows: object,
489
- blocking_values: frozenset[str] = APPROVAL_BLOCKS,
490
- *,
491
- report_data: Mapping[str, object] | None = None,
492
- ) -> list[str]:
493
- """게이트를 아직 막는 행 id. 사용자 진행 처분이 있는 행은 빠진다.
494
-
495
- ``report_data`` 가 있으면 원장에 반영된 되돌림 행도 빠진다.
496
- """
497
- if not isinstance(rows, list):
498
- return []
499
- incorporated = incorporated_clarification_ids(report_data)
500
- ids: list[str] = []
501
- for row in rows:
502
- if not isinstance(row, Mapping):
503
- continue
504
- blocks = str(row.get("blocks") or "").strip().lower()
505
- status = str(row.get("status") or "").strip()
506
- row_id = row.get("id")
507
- if (
508
- blocks in blocking_values
509
- and row_blocks_progress(
510
- status,
511
- clarification_disposition(row),
512
- incorporated=isinstance(row_id, str) and row_id in incorporated,
513
- )
514
- and isinstance(row_id, str)
515
- and row_id
516
- ):
517
- ids.append(row_id)
518
- return ids
519
-
520
-
521
- @dataclass(frozen=True)
522
- class ClarificationScan:
523
- """Fail-closed read of the clarification rows blocking on one `Blocks`
524
- value set.
525
-
526
- ``unreadable_reason`` is ``None`` only when the scan is confident: the
527
- rows parsed cleanly (or the report is the legitimate table-less
528
- placeholder) and ``blockers`` is therefore authoritative. A non-None
529
- reason means the caller must refuse to act — never soft-pass.
530
- """
531
- blockers: list[ClarificationItem]
532
- unreadable_reason: Optional[str]
533
-
534
-
535
- def scan_approval_gate(report_path: Path) -> ClarificationScan:
536
- """Scan for ``Blocks=approval`` rows that still block progress.
537
-
538
- A recorded user proceeding disposition (`accept-risk` / `select` /
539
- `answer`), including one that lives only in the sidecar, does not block.
540
- ``request-revision`` / ``reject`` still block unless this report's
541
- ``supersessionLedger`` already incorporated that id. The scan refuses to
542
- guess whenever the schema drifted.
543
- """
544
- return scan_clarification_blockers(
545
- report_path,
546
- APPROVAL_BLOCKS,
547
- honor_sidecar_answers=True,
548
- sidecar_unblocks_proceeding_only=True,
549
- )
550
-
551
-
552
- def scan_open_user_input(report_path: Path) -> ClarificationScan:
553
- """Scan for every unresolved row that still owes the user an answer
554
- (``Blocks`` in ``{approval, next-phase}``)."""
555
- return scan_clarification_blockers(
556
- report_path, USER_INPUT_BLOCKS, honor_sidecar_answers=True
557
- )
558
-
559
-
560
- def scan_clarification_blockers(
561
- report_path: Path,
562
- blocking_values: frozenset[str],
563
- *,
564
- honor_sidecar_answers: bool,
565
- sidecar_unblocks_proceeding_only: bool = False,
566
- ) -> ClarificationScan:
567
- """Shared fail-closed clarification walk for both gates above — schema-v2
568
- reads its rows from the data sibling and schema-v1 from the §1 table.
569
- ``honor_sidecar_answers`` hides rows the sidecar already answered.
570
- ``sidecar_unblocks_proceeding_only`` keeps an unincorporated
571
- ``request-revision`` / ``reject`` as a blocker so a return choice cannot
572
- start the next phase. A ledger entry for that id means this report already
573
- absorbed the return.
574
- """
575
- v2_scan = _scan_v2_blockers(report_path, blocking_values)
576
- scan = (
577
- v2_scan if v2_scan is not None
578
- else scan_section_1_blockers(_read_report_text(report_path), blocking_values)
579
- )
580
- return (
581
- _resolve_blockers_answered_by_user(
582
- report_path,
583
- scan,
584
- proceeding_only=sidecar_unblocks_proceeding_only,
585
- )
586
- if honor_sidecar_answers
587
- else scan
588
- )
589
-
590
-
591
- def _resolve_blockers_answered_by_user(
592
- report_path: Path,
593
- scan: ClarificationScan,
594
- *,
595
- proceeding_only: bool = False,
596
- ) -> ClarificationScan:
597
- """사용자가 사이드카로 답한 행을 blocker 에서 뺀 스캔.
598
-
599
- 답의 정본은 사용자의 `user-responses/` 사이드카다. 리포트의 `Status` 는 그
600
- run 이 스스로 적어둔 값이고, 답이 사이드카로만 들어오는 경로(HTML 뷰의
601
- `Export user response`, `okstra user-response write`)에서는 갱신되지 않는다.
602
- 사용자 입력 목록은 사이드카 답변이 있는 항목을 다시 묻지 않는다. 승인
603
- 게이트는 진행 처분만 차단에서 뺀다.
604
-
605
- fail-closed 는 그대로다: 행 자체를 못 읽은 스캔(`unreadable_reason`)은
606
- 어떤 id 가 blocker 인지 모르는 상태이므로 사이드카로 덮지 않는다.
607
- """
608
- if scan.unreadable_reason is not None or not scan.blockers:
609
- return scan
610
- dispositions = sidecar_dispositions(report_path)
611
- if not dispositions:
612
- return scan
613
- answered_ids = {
614
- row_id
615
- for row_id, disposition in dispositions.items()
616
- if (not proceeding_only) or disposition in PROCEEDING_DISPOSITIONS
617
- }
618
- if not answered_ids:
619
- return scan
620
- return ClarificationScan(
621
- [b for b in scan.blockers if b.row_id not in answered_ids], None
622
- )
623
-
624
-
625
- def scan_section_1_blockers(
626
- report_text: str, blocking_values: frozenset[str]
627
- ) -> ClarificationScan:
628
- """Fail-closed §1 walk over any markdown carrying the table — a schema-v1
629
- report, or the carry-in body a reconciliation just produced."""
630
- section = _section_1_slice(report_text)
631
- if section is None:
632
- if _LOOSE_SECTION_1_RE.search(report_text):
633
- reason = (
634
- "`## 1. Clarification Items` heading exists but does not match "
635
- "the schema heading format (anchor/format drift)"
636
- )
637
- else:
638
- expected = "/".join(sorted(blocking_values))
639
- reason = (
640
- "report has no `## 1. Clarification Items` section — the gate "
641
- f"cannot confirm there are no unresolved `Blocks={expected}` rows"
642
- )
643
- return ClarificationScan([], reason)
644
- table = _walk_section_1_table(section)
645
- if table.items is None:
646
- if table.has_pipe_lines:
647
- return ClarificationScan([], (
648
- "§1 contains a table but its header row is not the schema "
649
- "header (`| ... | Statement | Expected form | User input |`)"
650
- ))
651
- # Renderer's emptyState placeholder: heading is intact and no table
652
- # was emitted — confidently "no blocking items".
653
- return ClarificationScan([], None)
654
- if table.unparsed_row_count:
655
- return ClarificationScan([], (
656
- f"§1 table has {table.unparsed_row_count} row(s) whose metadata "
657
- "cell could not be parsed (Blocks/Status markers missing or "
658
- "malformed)"
659
- ))
660
- blockers = [
661
- it for it in table.items
662
- if it.blocks in blocking_values and row_blocks_progress(it.status)
663
- ]
664
- return ClarificationScan(blockers, None)
665
-
666
-
667
- def _scan_v2_blockers(
668
- report_path: Path, blocking_values: frozenset[str]
669
- ) -> Optional[ClarificationScan]:
670
- """schema-v2 data.json 기준 스캔, 이 리포트가 v2 가 아니면 ``None``.
671
- 필수 필드가 빠진 행은 §1 의 unparsed row 와 같이 fail-closed 로 다룬다."""
672
- data = _structured_report_data(report_path)
673
- if data is None:
674
- return None
675
- entries = data.get("clarificationItems")
676
- if entries is None:
677
- return ClarificationScan([], None)
678
- if not isinstance(entries, list):
679
- return ClarificationScan([], (
680
- "schema-v2 data.json `clarificationItems` is not an array — the "
681
- "gate cannot read the clarification rows"
682
- ))
683
- rows = [_v2_row(e) if isinstance(e, dict) else None for e in entries]
684
- unparsed = sum(1 for row in rows if row is None)
685
- if unparsed:
686
- return ClarificationScan([], (
687
- f"schema-v2 data.json has {unparsed} `clarificationItems` row(s) "
688
- "missing id/blocks/status"
689
- ))
690
- incorporated = incorporated_clarification_ids(data)
691
- blockers = [
692
- row["item"] for row in rows
693
- if row["item"].blocks in blocking_values
694
- and row_blocks_progress(
695
- row["item"].status,
696
- str(row.get("disposition") or ""),
697
- incorporated=row["item"].row_id in incorporated,
698
- )
699
- ]
700
- return ClarificationScan(blockers, None)
701
-
702
-
703
- # 느슨한 §1 헤딩 탐지: 엄격한 SECTION_HEADING_PATTERN 이 실패해도 이게 매칭되면
704
- # "§1 헤딩은 있는데 형태가 어긋나 파싱에 실패" 한 상태다. trailing 부분을 보지
705
- # 않으므로 앵커 변형·수동 편집·미래 렌더 변경 어디서든 헤딩의 존재만 잡는다.
706
- _LOOSE_SECTION_1_RE = re.compile(r"^##\s+1\.\s+Clarification Items\b", re.MULTILINE)
707
-
708
-
709
- def section_1_present_but_unparsed(report_text: str) -> bool:
710
- """§1 헤딩이 느슨 탐지엔 잡히지만 엄격 SECTION_HEADING_PATTERN 에는 매칭하지
711
- 못하는 경우 True — 헤딩 형태가 어긋나(앵커·포맷 drift) §1 슬라이스 자체가
712
- 실패하는 상태다.
713
-
714
- 이때 ``_section_1_slice`` 가 None 을 반환해 parse 가 통째로 None 이 되고 승인
715
- 게이트가 "schema 없음 → soft-pass" 로 조용히 열린다. §1 앵커 버그가 정확히 이
716
- 메커니즘으로 터졌다. 헤딩 자체가 없는 legacy 리포트(둘 다 불매칭)와, 엄격
717
- 매칭에 성공하는 정상 헤딩(테이블이 없는 emptyState placeholder 포함)은 False —
718
- placeholder 는 헤딩이 멀쩡하므로 fail-closed 로 오인하지 않는다. 정규식만 넓혀 온
719
- 과거 수정과 달리, 이 판별은 "헤딩 형태 drift" 자체를 차단해 재발 클래스를 닫는다."""
720
- if SECTION_HEADING_PATTERN.search(report_text):
721
- return False
722
- return bool(_LOOSE_SECTION_1_RE.search(report_text))
723
-
724
-
725
- def user_response_sidecars(source: Path) -> list[Path]:
726
- """``source`` 형제 ``user-responses/`` 의 ``user-response-*.md`` 목록(이름 순).
727
-
728
- ``source`` 가 ``runs/<task-type>/reports/final-report-*.md`` 레이아웃일 때만
729
- 형제 ``user-responses/`` 디렉토리를 찾는다(HTML 뷰의 `Export user response`
730
- 가 내려준 파일을 사용자가 거기 저장). 그 외 경로·디렉토리 부재 시 빈 목록.
731
- carry-in 첨부 본문(``clarification_response_with_sidecars``)과 위저드 안내
732
- 문구의 사이드카 카운트가 같은 한 곳을 보도록 하는 단일 참조점이다.
733
- """
734
- responses_dir = source.parent.parent / "user-responses"
735
- if source.parent.name != "reports" or not responses_dir.is_dir():
736
- return []
737
- return sorted(
738
- p for p in responses_dir.glob("user-response-*.md") if p.is_file()
739
- )
740
-
741
-
742
- _ANALYSIS_REPORT_NAME_RE = re.compile(
743
- r"^final-report-(?P<task_type>project-analysis|feature-analysis|"
744
- r"change-impact-analysis)-(?P<seq>\d{3})\.md$"
745
- )
746
- _SIDECAR_FRONTMATTER_RE = re.compile(
747
- r"\A---[ \t]*\r?\n(?P<body>.*?)(?:\r?\n)---[ \t]*(?:\r?\n|\Z)",
748
- re.DOTALL,
749
- )
750
- _ANALYSIS_REVIEW_HEADING_RE = re.compile(
751
- r"^## ANALYSIS REVIEW\s*$", re.MULTILINE
752
- )
753
-
754
-
755
- def _sidecar_frontmatter_value(text: str, key: str) -> str:
756
- match = _SIDECAR_FRONTMATTER_RE.match(text)
757
- if match is None:
758
- return ""
759
- value = re.search(
760
- rf"^{re.escape(key)}:\s*(\S.*?)\s*$",
761
- match.group("body"),
762
- re.MULTILINE,
763
- )
764
- return value.group(1) if value else ""
765
-
766
-
767
- def _sidecars_for_attachment(source: Path) -> list[Path]:
768
- sidecars = user_response_sidecars(source)
769
- report_match = _ANALYSIS_REPORT_NAME_RE.fullmatch(source.name)
770
- if report_match is None:
771
- return sidecars
772
- expected_source = (
773
- f"runs/{report_match.group('task_type')}/reports/{source.name}"
774
- )
775
- ordinary: list[Path] = []
776
- candidates: list[tuple[Path, str]] = []
777
- for sidecar in sidecars:
778
- try:
779
- text = sidecar.read_text(encoding="utf-8")
780
- except OSError:
781
- ordinary.append(sidecar)
782
- continue
783
- if _ANALYSIS_REVIEW_HEADING_RE.search(text) is None:
784
- ordinary.append(sidecar)
785
- continue
786
- source_report = _sidecar_frontmatter_value(text, "source-report")
787
- seq = _sidecar_frontmatter_value(text, "seq")
788
- if source_report and source_report != expected_source:
789
- continue
790
- if seq and seq != report_match.group("seq"):
791
- continue
792
- candidates.append((sidecar, text))
793
-
794
- from okstra_ctl.user_response import UserResponseError, parse_analysis_review
795
-
796
- valid: list[Path] = []
797
- malformed: list[Path] = []
798
- for sidecar, text in candidates:
799
- try:
800
- review = parse_analysis_review(text)
801
- except UserResponseError:
802
- malformed.append(sidecar)
803
- continue
804
- if review is not None and (
805
- review.source_report == expected_source
806
- and review.seq == report_match.group("seq")
807
- ):
808
- valid.append(sidecar)
809
- else:
810
- malformed.append(sidecar)
811
- selected = valid if valid else malformed
812
- return sorted([*ordinary, *selected])
813
-
814
-
815
- def _sidecar_answer_records(source: Path) -> dict[str, tuple[str, str]]:
816
- """사이드카 답을 `{id: (value, disposition)}` 로 모은다.
817
-
818
- `disposition` 이 `ANSWER_DISPOSITIONS` 에 속하는 항목만 답으로 센다.
819
- `reframe` 은 답이 아니므로 집합에서 빠진다. 같은 id 는 이름순 마지막이
820
- 이긴다 — 최신이 reframe 이거나 값이 비면 앞선 답을 지운다.
821
- """
822
- from okstra_ctl.user_response import parse_user_response_entries
823
-
824
- answers: dict[str, tuple[str, str]] = {}
825
- for sidecar in user_response_sidecars(source):
826
- for entry in parse_user_response_entries(
827
- sidecar.read_text(encoding="utf-8")
828
- ):
829
- if entry.value and entry.disposition in ANSWER_DISPOSITIONS:
830
- answers[entry.response_id] = (entry.value, entry.disposition)
831
- else:
832
- answers.pop(entry.response_id, None)
833
- return answers
834
-
835
-
836
- def sidecar_answers(source: Path) -> dict[str, str]:
837
- """`user-responses/` 사이드카들의 답변을 `{clarification-id: value}` 로 모은다.
838
-
839
- 사용자가 답한 항목이 무엇인지 아는 단일 참조점 — carry-in 병합도, 승인
840
- 게이트도, 스킬의 열린 항목 목록도 전부 이 한 곳을 본다.
841
- """
842
- return {
843
- row_id: value
844
- for row_id, (value, _disposition) in _sidecar_answer_records(source).items()
845
- }
846
-
847
-
848
- def sidecar_dispositions(source: Path) -> dict[str, str]:
849
- """사이드카 답의 처분을 `{clarification-id: disposition}` 로 모은다."""
850
- return {
851
- row_id: disposition
852
- for row_id, (_value, disposition) in _sidecar_answer_records(source).items()
853
- }
854
-
855
-
856
- def attached_user_responses_section(source: Path) -> str:
857
- """`source` 형제 `user-responses/` 사이드카만 모은 `# Attached User Responses`
858
- 섹션 본문. 사이드카 부재 시 빈 문자열.
859
-
860
- plan 본문이 이미 별도 경로(`--approved-plan`)로 참조되는 implementation
861
- carry-in 에서, 원문을 중복 복사하지 않고 사용자 답변만 instruction-set 에
862
- 첨부할 때 쓴다. `clarification_response_with_sidecars` 와 같은 직렬화 포맷을
863
- 한 곳에서 만들어 두 carry-in 경로가 갈라지지 않게 한다.
864
- """
865
- sidecars = _sidecars_for_attachment(source)
866
- if not sidecars:
867
- return ""
868
- parts = ["# Attached User Responses\n"]
869
- for sidecar in sidecars:
870
- parts.append(
871
- f"\n## {sidecar.name}\n\n"
872
- f"{sidecar.read_text(encoding='utf-8').strip()}\n"
873
- )
874
- return "".join(parts)
875
-
876
-
877
- def clarification_response_with_sidecars(source: Path) -> str:
878
- """clarification-response 본문에 `user-responses/` 사이드카를 덧붙인 본문.
879
-
880
- `resume-clarification` 의 설계된 입력은 사용자가 §1 의 `User input` 열을
881
- 채운 **직전 final-report 자체**다. 그 전문을 그대로 복사하면 리포트가
882
- instruction-set 안에 두 번째로 존재하게 되고, report-writer 는 같은 내용을
883
- `clarification-response.md` 로 한 번, 직전 리포트 경로로 다시 한 번 읽는다.
884
- 리포트는 run 이 누적될수록 커지므로 이 중복은 스스로 악화된다(실측: 283K
885
- 리포트가 892K 의 중복 읽기를 만들어 report-writer 를 timeout 시켰다).
886
-
887
- 그래서 소스가 final-report 일 때는 답변이 실린 clarification 행만 잘라내고
888
- 원문은 경로로 가리킨다 — `attached_user_responses_section` 이 implementation
889
- carry-in 에서 이미 쓰는 "원문은 경로로, 답변만 첨부" 규칙과 같다. 행을 어디서
890
- 읽는지는 스키마가 정한다(v1 은 §1 표, v2 는 data.json). clarification 을
891
- 아예 담지 않은 소스(사용자가 직접 쓴 답변 파일)만 원문 그대로 복사한다.
892
- """
893
- text = source.read_text(encoding="utf-8")
894
- section = attached_user_responses_section(source)
895
- answers = sidecar_answers(source)
896
- body = _clarification_carry_body(source, text, answers)
897
- if not section:
898
- return body
899
- return body.rstrip("\n") + "\n\n---\n\n" + section
900
-
901
-
902
- SECTION_1_HEADING = "## 1. Clarification Items"
903
- _SECTION_1_TABLE_HEADER = (
904
- "| Record | Statement | Expected form | User input |\n"
905
- "|---|---|---|---|"
906
- )
907
- _SECTION_1_EMPTY_STATE = "- The source report recorded no clarification items."
908
-
909
-
910
- def _clarification_carry_body(
911
- source: Path, text: str, answers: dict[str, str]
912
- ) -> str:
913
- """final-report 소스는 §1 + 원문 포인터로 좁히고, 그 외는 원문 그대로.
914
-
915
- §1 이 있으면 사이드카 답변을 그 표의 `User input` 열에 병합해, 답이 표 안에
916
- 자리하도록 한다(파일 헤더가 선언하는 "답은 User input 열에" 계약을 실제로
917
- 참으로 만든다)."""
918
- carried = _carry_section_1(source, text)
919
- if carried is None:
920
- return text
921
- heading, section_body = carried
922
- if answers:
923
- section_body = _reconcile_user_input(section_body, answers)
924
- return (
925
- "# Clarification Response (carry-in)\n\n"
926
- f"- Source report: `{source}`\n"
927
- "- This file carries **only** the source report's Clarification Items "
928
- "section; the report itself is read from the path above when a phase "
929
- "needs it. Do not re-read the source report to find the answers — they "
930
- "are in the `User input` column below.\n\n"
931
- f"{heading}\n{section_body}\n"
932
- )
933
-
934
-
935
- def _carry_section_1(source: Path, text: str) -> Optional[tuple[str, str]]:
936
- """carry-in 본문에 실을 (헤딩, §1 본문). clarification 을 담지 않은 소스면
937
- ``None``.
938
-
939
- schema-v2 는 행을 data.json 에 들고 AI 마크다운에는 §1 표가 없다. §1 슬라이스
940
- 만 보던 동안 v2 소스는 "좁힐 것이 없다" 로 판정돼 **리포트 전문이 그대로
941
- 복사**됐다 — 이 좁히기가 막으려던 바로 그 중복이다. carry-in 은 파생 문서이고
942
- 다운스트림(승인 게이트·프롬프트 빌더·검증 워커)이 §1 표 하나만 읽으므로, v2
943
- 행도 같은 표로 렌더한다."""
944
- data = _structured_report_data(source)
945
- if data is not None:
946
- entries = data.get("clarificationItems")
947
- return SECTION_1_HEADING, _structured_section_1_body(
948
- entries if isinstance(entries, list) else []
949
- )
950
- slice_ = _section_1_slice(text)
951
- if slice_ is None:
952
- return None
953
- heading = SECTION_HEADING_PATTERN.search(text)
954
- assert heading is not None # _section_1_slice returned a slice
955
- return heading.group(0), slice_.rstrip()
956
-
957
-
958
- def _structured_section_1_body(entries: list) -> str:
959
- """schema-v2 `clarificationItems[]` 를 §1 표 본문으로.
960
-
961
- 메타 셀은 렌더러가 쓰는 모양 그대로다 — `Status:` 는 따옴표 없이 써야
962
- `_reconcile_user_input` 이 답을 병합하면서 상태를 answered 로 넘길 수 있다."""
963
- rows = [e for e in entries if isinstance(e, dict) and e.get("id")]
964
- if not rows:
965
- return f"\n{_SECTION_1_EMPTY_STATE}"
966
- lines = ["", _SECTION_1_TABLE_HEADER]
967
- for entry in rows:
968
- meta = (
969
- f"**{entry['id']}**"
970
- f"<br>Ticket: `{to_cell_text(entry.get('ticketId'))}`"
971
- f"<br>Kind: `{to_cell_text(entry.get('kind'))}`"
972
- f"<br>Blocks: `{to_cell_text(entry.get('blocks'))}`"
973
- f"<br>Status: {to_cell_text(entry.get('status'))}"
974
- )
975
- lines.append(
976
- f"| {meta} | {to_cell_text(entry.get('statement'))} "
977
- f"| {to_cell_text(entry.get('expectedForm'))} "
978
- f"| {to_cell_text(entry.get('userInput'))} |"
979
- )
980
- return "\n".join(lines)
981
-
982
-
983
- # The final-report renderer writes `Status: open` / `Status: answered` unquoted
984
- # in the stacked meta cell; only those two are unresolved. Resolve in place so
985
- # the meta cell's other fields (ID, Ticket, Kind, Blocks) are left untouched.
986
- _STATUS_ANSWER_RE = re.compile(r"(Status:\s*)(?:open|answered)\b", re.IGNORECASE)
987
-
988
-
989
- def _locate_user_input_column(lines: list[str]) -> tuple[int, int]:
990
- """§1 데이터 표의 헤더 줄 인덱스와 `User input` 열 인덱스. 표가 없으면 (-1, -1)."""
991
- for idx, line in enumerate(lines):
992
- if not line.lstrip().startswith("|"):
993
- continue
994
- cells = [c.lower() for c in _split_pipe_row(line)]
995
- if "user input" in cells and any(c.startswith("statement") for c in cells):
996
- return idx, cells.index("user input")
997
- return -1, -1
998
-
999
-
1000
- def _reconcile_row(line: str, ui_col: int, answers: dict[str, str]) -> str:
1001
- """답이 있고 open/answered 인 행이면 `User input` 칸을 그 답으로 채우고 Status 를
1002
- answered 로 바꾼 줄을, 그 외에는 원본 줄을 그대로 돌려준다.
1003
-
1004
- 칸에 이미 값이 있어도 사용자의 사이드카 답이 이긴다. 그 칸을 채우는 것은
1005
- run 자신(직전 렌더가 옮겨 적은 값)이고, 사용자가 나중에 답을 바꾸면 둘이
1006
- 갈라진다 — 사용자가 쓴 쪽을 정본으로 삼지 않으면 run 이 자기가 적어둔 값으로
1007
- 계속 되돌아간다.
1008
-
1009
- 판정은 앵커/백틱을 벗긴 셀(`_split_pipe_row`)로 — 그래야 `_meta_id` 가 스크롤
1010
- 앵커의 소문자 slug 대신 진짜 대문자 ID 를 읽는다. 재조립은 원본 셀
1011
- (`split_pipe_row`)로 해서 앵커를 보존한다."""
1012
- norm = _split_pipe_row(line)
1013
- item = parse_meta_cell(norm[0]) if norm else None
1014
- if item is None or item.row_id not in answers:
1015
- return line
1016
- if item.status not in UNRESOLVED_STATUSES:
1017
- return line
1018
- raw = split_pipe_row(line)
1019
- if not 0 <= ui_col < len(raw):
1020
- return line
1021
- raw[ui_col] = answers[item.row_id]
1022
- raw[0] = _STATUS_ANSWER_RE.sub(r"\1answered", raw[0])
1023
- return "| " + " | ".join(to_cell_text(c) for c in raw) + " |"
1024
-
1025
-
1026
- def _reconcile_user_input(section: str, answers: dict[str, str]) -> str:
1027
- """§1 표에서 사이드카 답이 있는 미해결 행의 `User input` 칸을 답으로 채우고
1028
- Status 를 answered 로 바꾼 §1 본문을 돌려준다.
1029
-
1030
- 답의 정본 위치를 §1 표 안으로 옮긴다 — 표만 읽는 승인 게이트·프롬프트
1031
- 빌더·검증 워커가 모두 답을 보게 하려는 것. 사이드카는 §1 표 밖 별도 섹션에만
1032
- 있어서 표만 신뢰하는 소비자는 그 답을 놓쳤다."""
1033
- lines = section.splitlines()
1034
- header_idx, ui_col = _locate_user_input_column(lines)
1035
- if header_idx < 0:
1036
- return section
1037
- out = list(lines)
1038
- body = False
1039
- for i in range(header_idx + 1, len(lines)):
1040
- line = lines[i]
1041
- if not line.lstrip().startswith("|"):
1042
- if body:
1043
- break
1044
- continue
1045
- if is_separator_row(line):
1046
- body = True
1047
- continue
1048
- if body:
1049
- out[i] = _reconcile_row(line, ui_col, answers)
1050
- return "\n".join(out)