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,1099 +0,0 @@
1
- """Per-task git worktree provisioning.
2
-
3
- Every okstra task — regardless of task-type/phase — runs inside an
4
- isolated git worktree rooted at
5
- `~/.okstra/worktrees/<project_id>/<task_group>/<task_id>/`. The same
6
- worktree is reused across all phases of one task-key (requirements-
7
- discovery → error-analysis → implementation-option-selection →
8
- implementation-planning → implementation), so phase N picks up exactly
9
- the working-tree state phase N-1 left behind.
10
-
11
- A global registry (`worktree_registry.py`) maps task-keys to the
12
- on-disk path + branch and serialises reservations, so two concurrent
13
- okstra runs cannot collide on the same path or branch name.
14
-
15
- Pre-conditions handled here:
16
- - Skip when `project_root` is not a git repo (degrade gracefully).
17
- - Skip when `project_root` itself is already a non-main worktree
18
- (caller's tree is already an isolated workspace; reuse it).
19
- - Re-entry of the same task-key returns the existing worktree.
20
- - Branch / path collisions across task-keys raise PrepareError-like
21
- RuntimeError.
22
-
23
- Side effects:
24
- - `git worktree add -b <branch> <path> <base_ref>` invoked in the
25
- main worktree of `project_root` (NOT `project_root` itself when it
26
- is also a worktree — base must be the main checkout).
27
- - Per-task sync dirs (.project-docs, .scratch, graphify-out by
28
- default) symlinked from the **main worktree** into the new
29
- worktree so every task sees the same shared state, irrespective of
30
- which worktree the caller invoked okstra from.
31
- - The function does NOT chdir.
32
- """
33
- from __future__ import annotations
34
-
35
- import json
36
- import os
37
- import shutil
38
- import stat
39
- import subprocess
40
- from dataclasses import dataclass
41
- from pathlib import Path
42
- from typing import Optional
43
-
44
- from okstra_project.dirs import okstra_home, project_json_path
45
-
46
- from .ids import _safe_fs_segment
47
- from .json_boundary import JsonBoundaryError, load_owned_object
48
- from . import worktree_registry
49
- from .seeding import (
50
- SettingsLinkError,
51
- ensure_project_settings_symlink,
52
- )
53
-
54
-
55
- # Project-root directories that hold okstra task state, ignored by git, or
56
- # otherwise required for the executor to operate but NOT carried across by
57
- # `git worktree add`. Each is symlinked from the MAIN worktree into the new
58
- # worktree at provision time. Symlinks (not copies) so every task sees the
59
- # live shared state and disk/CPU cost stays near zero; the trade-off is
60
- # that any write through the link reaches the main worktree, which is
61
- # acceptable because okstra writes only to paths it owns: its task-scoped
62
- # subdirectory (e.g. `.okstra/tasks/<task-id>/runs/...`) and, for a
63
- # branch-mode code review, `.project-docs/code-reviews/<branch>/`.
64
- #
65
- # Override precedence (most-specific first):
66
- # 1. `OKSTRA_WORKTREE_SYNC_DIRS` env var — colon-separated list, REPLACES
67
- # defaults. Empty string disables the feature entirely. One-off
68
- # operator override.
69
- # 2. `worktreeSyncDirs` array in `.okstra/project.json` —
70
- # project-level config, persists across runs. Same semantics: array
71
- # REPLACES defaults, empty array disables.
72
- # 3. The built-in `DEFAULT_WORKTREE_SYNC_DIRS` below.
73
- DEFAULT_WORKTREE_SYNC_DIRS: tuple[str, ...] = (
74
- ".project-docs",
75
- ".scratch",
76
- "graphify-out",
77
- ".claude",
78
- )
79
-
80
-
81
- # Sync dirs materialised as a REAL directory whose children are symlinked one
82
- # by one, instead of a single symlink standing in for the whole directory.
83
- #
84
- # Why the split exists: git does not follow a symlink, so a symlinked directory
85
- # is one *file* to it. A project that ignores host config by its contents
86
- # (`.claude/*`) matches every child but never the bare `.claude` path, so the
87
- # symlink lands in `git status` as
88
- # `?? .claude` while the same directory is invisible in the main checkout. Any
89
- # plan step asserting a clean worktree then fails on okstra's own provisioning.
90
- # Linking the children instead reproduces the main checkout's shape, so
91
- # whatever the project's ignore rules do there, they do here too.
92
- #
93
- # Only `.claude` qualifies. The other sync dirs are shared okstra state that
94
- # okstra WRITES into, and a directory symlink is what makes a newly created
95
- # top-level entry land in the main checkout rather than diverging inside the
96
- # worktree. `.claude` is host configuration okstra reads; its one okstra write
97
- # is the fixed `settings.local.json` child seeded by
98
- # `_seed_worktree_settings_symlink`.
99
- CHILD_LINKED_SYNC_DIRS: tuple[str, ...] = (".claude",)
100
-
101
-
102
- # Project-root-relative FILES (not dirs) symlinked from MAIN → task worktree
103
- # at provision time. Same symlink semantics as `DEFAULT_WORKTREE_SYNC_DIRS`:
104
- # every task sees the live shared file. The split exists because the original
105
- # `_link_sync_dirs` helper only walked directories — a `.env` (or any
106
- # top-level file outside `.git`'s tracking) would otherwise be lost on every
107
- # new worktree, blocking verifier dispatches that depend on environment-
108
- # resolved secrets.
109
- #
110
- # Override precedence mirrors `DEFAULT_WORKTREE_SYNC_DIRS`:
111
- # 1. `OKSTRA_WORKTREE_SYNC_FILES` env var (colon-separated, REPLACES).
112
- # 2. `worktreeSyncFiles` array in `.okstra/project.json`.
113
- # 3. The built-in `DEFAULT_WORKTREE_SYNC_FILES` below.
114
- DEFAULT_WORKTREE_SYNC_FILES: tuple[str, ...] = (
115
- ".env",
116
- )
117
-
118
-
119
- # Project-root-relative files COPIED (not symlinked) from MAIN → task worktree
120
- # at provision time, then `chmod 0o444` so the task cannot mutate the
121
- # snapshot. Used for live-mutating fixtures (e.g. `classifications.db`)
122
- # where the verifier needs to reproduce accuracy SQL against the same rows
123
- # the executor saw, without sharing the writable handle that would corrupt
124
- # the main worktree's copy. Default is empty — okstra has no opinion about
125
- # which fixtures any given project relies on; opt in per-project via
126
- # `worktreeSnapshotFiles` in `project.json` (or `OKSTRA_WORKTREE_SNAPSHOT_FILES`
127
- # for one-off operator override).
128
- DEFAULT_WORKTREE_SNAPSHOT_FILES: tuple[str, ...] = ()
129
-
130
-
131
- # Work-category → branch namespace (slash-prefixed). Mirrors the values
132
- # accepted by `--work-category` (bugfix / feature / refactor / ops /
133
- # improvement); `feature` and `improvement` share the `feature/` namespace,
134
- # and any unset/unrecognised category falls back to `task/`.
135
- _WORK_CATEGORY_NAMESPACE = {
136
- "feature": "feature",
137
- "improvement": "feature",
138
- "bugfix": "fix",
139
- "refactor": "refactor",
140
- "ops": "ops",
141
- }
142
-
143
-
144
- @dataclass
145
- class WorktreeProvision:
146
- """Result of `provision_task_worktree`.
147
-
148
- status:
149
- - "created": fresh worktree at `path` on `branch`
150
- - "reused": registry already had this task-key; same path/branch
151
- returned and no new `git worktree add` was executed
152
- - "skipped-in-worktree": project_root is itself a non-main
153
- worktree; the run reuses `project_root` and no new worktree is
154
- materialised (registry NOT updated — that caller is already
155
- isolated by virtue of its own worktree)
156
- - "skipped-not-git": project_root has no `.git` (worktree path
157
- cannot be provisioned; degrade gracefully)
158
- """
159
- status: str
160
- path: str = "" # absolute path of the task worktree (or project_root when reused)
161
- branch: str = "" # branch checked out in the worktree (empty when reused / not-git)
162
- base_ref: str = "" # commit SHA the worktree was branched from (empty when not created)
163
- note: str = "" # human-readable explanation, surfaced in team-state / manifests
164
-
165
-
166
- @dataclass
167
- class WorktreeDecision:
168
- """Side-effect-free preview of what `provision_task_worktree` would do.
169
-
170
- status:
171
- - "new": no active registry entry; a fresh worktree would be created
172
- - "reused": registry already has this task-key; existing path/branch returned
173
- - "skipped-in-worktree": project_root is itself a non-main worktree
174
- - "skipped-not-git": project_root has no .git
175
- """
176
- status: str
177
- path: str # worktree path (new: prospective; reuse: existing; skip: project_root)
178
- branch: str = "" # new: prospective branch; reused: existing branch
179
- base_ref: str = "" # new: requested base_ref; reused: existing base
180
-
181
-
182
- def preview_worktree_decision(
183
- *,
184
- project_root,
185
- project_id: str,
186
- task_group_segment: str,
187
- task_id_segment: str,
188
- work_category: str,
189
- base_ref: str = "",
190
- ) -> "WorktreeDecision":
191
- """Side-effect-free: what provision_task_worktree WOULD do, without touching disk.
192
-
193
- Mirrors provision's decision branches exactly; reuses the same read-only
194
- helpers so preview never diverges from the actual provisioning result.
195
- """
196
- project_root = Path(project_root)
197
- if not is_git_work_tree(project_root):
198
- return WorktreeDecision(status="skipped-not-git", path=str(project_root))
199
- if _is_inside_non_main_worktree(project_root):
200
- return WorktreeDecision(status="skipped-in-worktree", path=str(project_root))
201
- safe_project = _safe_segment(project_id)
202
- safe_group = _safe_segment(task_group_segment)
203
- safe_task = _safe_segment(task_id_segment)
204
- existing = worktree_registry.lookup(safe_project, safe_group, safe_task)
205
- if existing is not None and existing.status == "active":
206
- return WorktreeDecision(
207
- status="reused", path=existing.worktree_path,
208
- branch=existing.branch, base_ref=existing.base_ref,
209
- )
210
- return WorktreeDecision(
211
- status="new",
212
- path=str(compute_worktree_path(
213
- project_id=safe_project, task_group_segment=safe_group,
214
- task_id_segment=safe_task)),
215
- branch=compute_branch_name(work_category=work_category, task_id_segment=safe_task),
216
- base_ref=base_ref,
217
- )
218
-
219
-
220
- @dataclass
221
- class StageWorktreeDecision:
222
- """Side-effect-free decision for one concrete implementation stage."""
223
-
224
- status: str
225
- path: str
226
- branch: str = ""
227
- base_ref: str = ""
228
-
229
-
230
- def resolve_stage_worktree_decision(
231
- *,
232
- project_id: str,
233
- task_group_segment: str,
234
- task_id_segment: str,
235
- work_category: str,
236
- stage_number: int,
237
- ) -> StageWorktreeDecision:
238
- """Resolve whether one concrete stage worktree is new or reusable."""
239
- safe_project = _safe_segment(project_id)
240
- safe_group = _safe_segment(task_group_segment)
241
- safe_task = _safe_segment(task_id_segment)
242
- existing = worktree_registry.lookup(
243
- safe_project, safe_group, safe_task, stage_number=stage_number)
244
- if existing is not None and _stage_entry_is_reusable(existing):
245
- return StageWorktreeDecision(
246
- status="reused",
247
- path=existing.worktree_path,
248
- branch=existing.branch,
249
- base_ref=existing.base_ref,
250
- )
251
- return StageWorktreeDecision(
252
- status="new",
253
- path=str(compute_worktree_path(
254
- project_id=safe_project, task_group_segment=safe_group,
255
- task_id_segment=safe_task, stage_number=stage_number)),
256
- branch=compute_branch_name(
257
- work_category=work_category, task_id_segment=safe_task,
258
- stage_number=stage_number),
259
- )
260
-
261
-
262
- def _stage_entry_is_reusable(entry: worktree_registry.WorktreeEntry) -> bool:
263
- """Whether a registered stage worktree can be entered by this run.
264
-
265
- `active` is the live-run case. `released` with the directory still on disk is
266
- the fix-run case: a stage whose verifier returned FAIL records a `failed`
267
- consumers row, which frees the occupancy but deliberately keeps the worktree
268
- and its branch as the reviewable stack. Re-entry MUST reuse that tree —
269
- provisioning anew refuses on the existing path and branch. After whole-task
270
- final-verification removes the directory the entry stops being reusable, so
271
- the stage provisions from scratch.
272
- """
273
- if entry.status == "active":
274
- return True
275
- return entry.status == "released" and Path(entry.worktree_path).is_dir()
276
-
277
-
278
- def _safe_segment(value: str) -> str:
279
- """Sanitise a single path/branch segment.
280
-
281
- Forbidden chars (`/`, `:`, spaces, anything outside `[a-z0-9-]`)
282
- are collapsed to `-`. Empty result becomes `_` so we never create
283
- an empty path component. Delegates to the canonical slugifier in
284
- `ids.py` to stay in lock-step with run-id / manifest segmentation.
285
- """
286
- return _safe_fs_segment(value)
287
-
288
-
289
- def _work_category_namespace(work_category: str) -> str:
290
- key = (work_category or "").strip().lower()
291
- return _WORK_CATEGORY_NAMESPACE.get(key, "task")
292
-
293
-
294
- def _git(cwd: Path, *args: str) -> subprocess.CompletedProcess:
295
- return subprocess.run(
296
- ["git", "-C", str(cwd), *args],
297
- capture_output=True, text=True, check=False,
298
- )
299
-
300
-
301
- def _is_inside_non_main_worktree(project_root: Path) -> bool:
302
- """True iff project_root is inside a git worktree that is NOT the
303
- repository's main checkout. Detection rule: `--git-dir` (per-worktree
304
- .git pointer) differs from `--git-common-dir` (shared object store).
305
- """
306
- common = _git(project_root, "rev-parse", "--git-common-dir")
307
- per_tree = _git(project_root, "rev-parse", "--git-dir")
308
- if common.returncode != 0 or per_tree.returncode != 0:
309
- return False
310
- common_abs = (project_root / common.stdout.strip()).resolve()
311
- per_tree_abs = (project_root / per_tree.stdout.strip()).resolve()
312
- return common_abs != per_tree_abs
313
-
314
-
315
- def is_git_work_tree(project_root: Path) -> bool:
316
- """project_root 가 git work tree 내부인지 판정하는 public git-introspection seam.
317
-
318
- git 미설치(FileNotFoundError) 를 포함한 모든 실패는 False — 호출자(migrate
319
- 등)가 non-git 레이아웃으로 안전하게 폴백할 수 있도록 한다. 과거 migrate.py
320
- 가 같은 판정을 자체 구현(`--show-toplevel`)했는데 이 seam 으로 통합한다."""
321
- try:
322
- res = _git(project_root, "rev-parse", "--is-inside-work-tree")
323
- except (OSError, FileNotFoundError):
324
- return False
325
- return res.returncode == 0 and res.stdout.strip() == "true"
326
-
327
-
328
- def _branch_exists(project_root: Path, branch: str) -> bool:
329
- res = _git(project_root, "rev-parse", "--verify", "--quiet", f"refs/heads/{branch}")
330
- return res.returncode == 0
331
-
332
-
333
- def _branch_checkout_path(project_root: Path, branch: str) -> str:
334
- """The worktree that currently has *branch* checked out, or "" when none does.
335
-
336
- `git branch -D` refuses to delete a checked-out branch, so an existing-branch
337
- error that only recommends `-D` is unfollowable whenever the branch is the
338
- main checkout's HEAD. Callers use this to name the extra step instead.
339
- """
340
- res = _git(project_root, "worktree", "list", "--porcelain")
341
- if res.returncode != 0:
342
- return ""
343
- current = ""
344
- for line in res.stdout.splitlines():
345
- if line.startswith("worktree "):
346
- current = line[len("worktree "):].strip()
347
- elif line.strip() == f"branch refs/heads/{branch}":
348
- return current
349
- return ""
350
-
351
-
352
- def _branch_exists_message(project_root: Path, branch: str, kind: str) -> str:
353
- """Existing-branch error text whose remedy is actually runnable."""
354
- checkout = _branch_checkout_path(project_root, branch)
355
- if checkout:
356
- return (
357
- f"{kind} worktree branch already exists: {branch}, and it is checked "
358
- f"out at {checkout}. `git branch -D {branch}` will refuse while it is "
359
- f"checked out — switch that checkout to another branch first "
360
- f"(`git -C {checkout} switch <other-branch>`), then delete it, or "
361
- "choose a different work-category before retrying."
362
- )
363
- return (
364
- f"{kind} worktree branch already exists: {branch}. "
365
- f"Delete it (`git branch -D {branch}`) or choose a different "
366
- "work-category before retrying."
367
- )
368
-
369
-
370
- def _head_sha(cwd: Path) -> str:
371
- res = _git(cwd, "rev-parse", "HEAD")
372
- if res.returncode != 0:
373
- return ""
374
- return res.stdout.strip()
375
-
376
-
377
- def _resolve_commit_sha(cwd: Path, ref: str) -> str:
378
- """Resolve a user-supplied ref (branch, tag, short/long SHA) to a full
379
- commit SHA in `cwd`. Returns empty string when the ref is not resolvable
380
- so the caller can raise a contextual error.
381
- """
382
- res = _git(cwd, "rev-parse", "--verify", "--quiet", f"{ref}^{{commit}}")
383
- if res.returncode != 0:
384
- return ""
385
- return res.stdout.strip()
386
-
387
-
388
- def main_worktree_path(project_root: Path) -> Path:
389
- """Locate the repository's MAIN worktree (the original checkout).
390
-
391
- `git worktree list --porcelain` lists worktrees in a stable order
392
- where the first `worktree <path>` block is the main checkout.
393
- Falls back to `project_root` if parsing fails — caller still gets
394
- a working path, sync-dir links just point at the caller's tree
395
- (the prior behaviour).
396
- """
397
- res = _git(project_root, "worktree", "list", "--porcelain")
398
- if res.returncode != 0:
399
- return project_root
400
- for line in res.stdout.splitlines():
401
- if line.startswith("worktree "):
402
- return Path(line[len("worktree "):].strip())
403
- return project_root
404
-
405
-
406
- def _read_project_json_field(project_root: Path, field: str) -> Optional[tuple[str, ...]]:
407
- """Read a string-array field from the project's okstra project.json.
408
-
409
- Returns None if the field is absent or the file cannot be parsed (so
410
- the caller falls back to defaults). Returns an empty tuple if the
411
- field is explicitly an empty array (caller treats this as "disable").
412
- A non-list value is treated as missing — we do not raise here because
413
- field resolution must never block worktree provisioning.
414
- """
415
- target = project_json_path(project_root)
416
- if not target.is_file():
417
- return None
418
- try:
419
- data = load_owned_object(target, artifact="project config")
420
- except (OSError, JsonBoundaryError):
421
- return None
422
- if not isinstance(data, dict):
423
- return None
424
- value = data.get(field)
425
- if not isinstance(value, list):
426
- return None
427
- cleaned = tuple(
428
- item.strip() for item in value
429
- if isinstance(item, str) and item.strip()
430
- )
431
- return cleaned
432
-
433
-
434
- def _resolve_entries(
435
- *,
436
- env_var: str,
437
- project_field: str,
438
- default: tuple[str, ...],
439
- project_root: Optional[Path],
440
- ) -> tuple[str, ...]:
441
- """Generic resolver shared by sync-dirs / sync-files / snapshot-files.
442
-
443
- Precedence: env var (colon-separated, REPLACES) → project.json field →
444
- built-in default. An empty env value or empty JSON array disables the
445
- feature (returns `()`).
446
- """
447
- raw = os.environ.get(env_var)
448
- if raw is not None:
449
- raw = raw.strip()
450
- if not raw:
451
- return ()
452
- return tuple(part for part in (p.strip() for p in raw.split(":")) if part)
453
- if project_root is not None:
454
- from_project = _read_project_json_field(project_root, project_field)
455
- if from_project is not None:
456
- return from_project
457
- return default
458
-
459
-
460
- def _resolve_sync_dirs(project_root: Optional[Path] = None) -> tuple[str, ...]:
461
- """Return the list of project-root-relative dirs to symlink into the
462
- new worktree. Precedence: env var → project.json → built-in default.
463
- See the comment above `DEFAULT_WORKTREE_SYNC_DIRS` for full semantics.
464
- """
465
- return _resolve_entries(
466
- env_var="OKSTRA_WORKTREE_SYNC_DIRS",
467
- project_field="worktreeSyncDirs",
468
- default=DEFAULT_WORKTREE_SYNC_DIRS,
469
- project_root=project_root,
470
- )
471
-
472
-
473
- def _resolve_sync_files(project_root: Optional[Path] = None) -> tuple[str, ...]:
474
- """File-level counterpart to `_resolve_sync_dirs` (FU-V2)."""
475
- return _resolve_entries(
476
- env_var="OKSTRA_WORKTREE_SYNC_FILES",
477
- project_field="worktreeSyncFiles",
478
- default=DEFAULT_WORKTREE_SYNC_FILES,
479
- project_root=project_root,
480
- )
481
-
482
-
483
- def _resolve_snapshot_files(project_root: Optional[Path] = None) -> tuple[str, ...]:
484
- """Read-only snapshot file list (FU-V3)."""
485
- return _resolve_entries(
486
- env_var="OKSTRA_WORKTREE_SNAPSHOT_FILES",
487
- project_field="worktreeSnapshotFiles",
488
- default=DEFAULT_WORKTREE_SNAPSHOT_FILES,
489
- project_root=project_root,
490
- )
491
-
492
-
493
- def okstra_clean_gate_excludes(project_root: Optional[Path] = None) -> tuple[str, ...]:
494
- """Project-relative paths okstra owns and source clean gates should ignore."""
495
- out: list[str] = []
496
- seen: set[str] = set()
497
- for rel in (
498
- ".okstra",
499
- *_resolve_sync_dirs(project_root),
500
- *_resolve_sync_files(project_root),
501
- *_resolve_snapshot_files(project_root),
502
- ):
503
- cleaned = rel.strip().removeprefix("./").rstrip("/")
504
- if not cleaned or cleaned == "." or cleaned in seen:
505
- continue
506
- seen.add(cleaned)
507
- out.append(cleaned)
508
- return tuple(out)
509
-
510
-
511
- def nested_worktree_excludes(worktree_path) -> tuple[str, ...]:
512
- """Relative paths of git worktrees nested under `worktree_path`.
513
-
514
- implementation stage worktrees live inside the task worktree
515
- (`<task-id>/stage-<N>/`), so the parent's `git status --short` reports
516
- each as an untracked `?? stage-N/`. They are git-registered worktrees,
517
- not user source changes, so clean gates must ignore them. Derived from
518
- `git worktree list` rather than a hardcoded pattern so it tracks exactly
519
- what git registered.
520
- """
521
- root = Path(worktree_path).resolve()
522
- r = _git(root, "worktree", "list", "--porcelain")
523
- if r.returncode != 0:
524
- return ()
525
- out: list[str] = []
526
- for line in r.stdout.splitlines():
527
- if not line.startswith("worktree "):
528
- continue
529
- wt = Path(line[len("worktree "):].strip()).resolve()
530
- if wt == root:
531
- continue
532
- try:
533
- rel = wt.relative_to(root)
534
- except ValueError:
535
- continue
536
- out.append(str(rel))
537
- return tuple(out)
538
-
539
-
540
- def is_ancestor(cwd, commit: str, head: str) -> bool:
541
- """True iff `commit` is an ancestor of `head` (both non-empty)."""
542
- if not commit or not head:
543
- return False
544
- return _git(Path(cwd), "merge-base", "--is-ancestor", commit, head).returncode == 0
545
-
546
-
547
- def dirty_entries_excluding_okstra(cwd) -> list[str]:
548
- """`git status --short` rows for changes outside okstra-owned paths.
549
-
550
- okstra-owned = `okstra_clean_gate_excludes` (e.g. `.okstra`, synced dirs)
551
- plus `nested_worktree_excludes` (stage worktrees nested under `cwd`).
552
-
553
- This is the clean-worktree question every okstra gate asks, and the one a
554
- plan step must ask through `okstra worktree-status` rather than through a
555
- bare `git status --porcelain`: okstra provisions `.okstra` plus the synced
556
- entries into every task worktree, so a bare status is never empty there and
557
- an assertion built on it fails on okstra's own scaffolding.
558
- """
559
- owned = (*okstra_clean_gate_excludes(Path(cwd)), *nested_worktree_excludes(cwd))
560
- excludes = [f":(exclude){p}" for p in owned]
561
- out = _git(Path(cwd), "status", "--short", "--", ".", *excludes).stdout
562
- return [line for line in out.splitlines() if line.strip()]
563
-
564
-
565
- def is_dirty_excluding_okstra(cwd) -> bool:
566
- """True iff the worktree has changes outside okstra-owned paths."""
567
- return bool(dirty_entries_excluding_okstra(cwd))
568
-
569
-
570
- class MergeError(RuntimeError):
571
- """`git merge` 가 내용 충돌이 아닌 사유로 실패했을 때(추적되지 않은 파일
572
- 덮어쓰기, 진행 중인 머지 잔존, 없는 브랜치 등) 던진다. 충돌 파일이 0개인
573
- 실패를 '충돌'로 둔갑시키지 않고 git stderr 를 그대로 노출해 호출자가 올바른
574
- 복구 경로로 안내하도록 한다."""
575
-
576
-
577
- def merge_branch(cwd, branch: str, *, no_ff: bool) -> list[str] | None:
578
- """`branch` 를 cwd 의 현재 HEAD 에 머지한다. 성공이면 None, 내용 충돌이면
579
- abort 후 충돌 파일 목록을 반환한다(최소 1개). 충돌이 아닌 머지 실패는
580
- MergeError 로 던진다 — 충돌 0개를 충돌로 보고하지 않기 위함. 호출자가 충돌
581
- 목록을 HandoffConflict/IntegrateError 등 적절한 예외로 변환한다 — 머지·
582
- 충돌감지·abort 시퀀스의 단일 지점."""
583
- args = ["merge", "--no-ff", "--no-edit", branch] if no_ff \
584
- else ["merge", "--no-edit", branch]
585
- res = _git(Path(cwd), *args)
586
- if res.returncode == 0:
587
- return None
588
- conflicts = _git(Path(cwd), "diff", "--name-only",
589
- "--diff-filter=U").stdout.split()
590
- _git(Path(cwd), "merge", "--abort")
591
- if not conflicts:
592
- raise MergeError((res.stderr or res.stdout).strip()
593
- or f"git merge {branch} failed (exit={res.returncode})")
594
- return conflicts
595
-
596
-
597
- def remove_worktree_force(cwd, worktree_path) -> subprocess.CompletedProcess:
598
- """워크트리를 강제 제거한다. provision_*_worktree 가 워크트리에 .okstra·
599
- synced 디렉터리/파일 symlink(untracked) 를 깔기 때문에 plain remove 는 그
600
- 잔여물 때문에 git 이 거부한다 — dirty 게이트로 실사용자 변경을 막은 뒤
601
- --force 로 okstra 잔여물만 정리하는 단일 지점."""
602
- return _git(Path(cwd), "worktree", "remove", "--force", str(worktree_path))
603
-
604
-
605
- def _link_sync_dirs(source_root: Path, worktree_path: Path) -> list[str]:
606
- """Symlink each configured dir from `source_root` (the MAIN
607
- worktree) into the new worktree.
608
-
609
- Skip rules:
610
- - Source missing in `source_root` → silently skipped.
611
- - Target path already exists in worktree (e.g. tracked content
612
- checked out by `git worktree add`) → skipped to avoid clobbering
613
- version-controlled files.
614
- - Parent directories are created as needed for nested entries.
615
-
616
- Entries listed in `CHILD_LINKED_SYNC_DIRS` are materialised as a real
617
- directory of per-child symlinks instead (see that constant).
618
-
619
- Returns a list of human-readable notes (one per linked entry) so the
620
- caller can include them in the provisioning note.
621
- """
622
- notes: list[str] = []
623
- for rel in _resolve_sync_dirs(source_root):
624
- src = (source_root / rel).resolve()
625
- if not src.exists():
626
- continue
627
- dst = worktree_path / rel
628
- if dst.exists() or dst.is_symlink():
629
- continue
630
- dst.parent.mkdir(parents=True, exist_ok=True)
631
- if rel in CHILD_LINKED_SYNC_DIRS and src.is_dir():
632
- _link_dir_children(src, dst)
633
- notes.append(rel)
634
- continue
635
- try:
636
- os.symlink(src, dst)
637
- except FileExistsError:
638
- continue
639
- notes.append(rel)
640
- return notes
641
-
642
-
643
- def _link_dir_children(src: Path, dst: Path) -> None:
644
- """Create `dst` as a real directory whose entries symlink to `src`'s
645
- children, so git sees the same directory shape it sees in the main
646
- checkout (`CHILD_LINKED_SYNC_DIRS`).
647
-
648
- Each link points at the MAIN checkout's child rather than its resolved
649
- target, so a child that is itself a symlink (e.g. `.claude/settings.local.json`
650
- → `~/.okstra/templates/settings.local.json`) keeps following whatever the
651
- main checkout currently points at. An unreadable source or a child that
652
- cannot be linked degrades to "that child is absent in this worktree" —
653
- provisioning is not worth failing over host config.
654
- """
655
- dst.mkdir(parents=True, exist_ok=True)
656
- try:
657
- children = sorted(src.iterdir())
658
- except OSError:
659
- return
660
- for child in children:
661
- link = dst / child.name
662
- if link.exists() or link.is_symlink():
663
- continue
664
- try:
665
- os.symlink(child, link)
666
- except OSError:
667
- continue
668
-
669
-
670
- def _link_sync_files(source_root: Path, worktree_path: Path) -> list[str]:
671
- """File-level counterpart to `_link_sync_dirs` (FU-V2).
672
-
673
- Same skip rules: missing source → skipped silently; pre-existing dst
674
- (tracked content checked out by `git worktree add`) → skipped to avoid
675
- clobbering. Symlinks (not copies) keep secrets out of duplicate files —
676
- the link points back to the MAIN worktree's `.env` so rotating the file
677
- there is reflected in every active task without re-provisioning.
678
- """
679
- notes: list[str] = []
680
- for rel in _resolve_sync_files(source_root):
681
- src = (source_root / rel).resolve()
682
- if not src.exists() or not src.is_file():
683
- continue
684
- dst = worktree_path / rel
685
- if dst.exists() or dst.is_symlink():
686
- continue
687
- dst.parent.mkdir(parents=True, exist_ok=True)
688
- try:
689
- os.symlink(src, dst)
690
- except FileExistsError:
691
- continue
692
- notes.append(rel)
693
- return notes
694
-
695
-
696
- def _seed_worktree_settings_symlink(worktree_path: Path) -> None:
697
- """Seed `.claude/settings.local.json` in the worker worktree so dispatched
698
- Claude / codex / antigravity sessions inherit the okstra read-only / write
699
- allowlist. Mirrors the main-project seeding done in `run.py` — needed
700
- because `_link_sync_dirs` skips `.claude/` whenever `git worktree add`
701
- already materialised the directory (e.g. tracked `.claude/handoff-*.md`).
702
- Failures degrade to stderr warning so worktree provisioning still
703
- succeeds.
704
- """
705
- try:
706
- link = ensure_project_settings_symlink(project_root=worktree_path)
707
- except SettingsLinkError as exc:
708
- print(
709
- f"okstra-settings: failed to seed worker worktree symlink at "
710
- f"{worktree_path / '.claude/settings.local.json'} — worker dispatch "
711
- f"may be blocked by Claude Code permissions. ({exc})",
712
- file=__import__("sys").stderr,
713
- )
714
- return
715
- if link is None:
716
- print(
717
- "okstra-settings: ~/.okstra/templates/settings.local.json missing — "
718
- "re-run 'npx okstra@latest install' (0.14.0+) to provision the "
719
- "symlink target.",
720
- file=__import__("sys").stderr,
721
- )
722
-
723
-
724
- def _copy_snapshot_files(source_root: Path, worktree_path: Path) -> list[str]:
725
- """Copy fixture files from MAIN → task worktree as read-only snapshots
726
- (FU-V3).
727
-
728
- Unlike `_link_sync_files`, this materialises a fresh on-disk copy and
729
- chmods it to `0o444` so the verifier can read the same rows the
730
- executor saw without sharing a writable handle that would corrupt the
731
- main worktree's copy. Skip rules mirror the symlink helpers (missing
732
- source → skipped silently; pre-existing dst → skipped to avoid
733
- clobbering tracked content).
734
- """
735
- notes: list[str] = []
736
- for rel in _resolve_snapshot_files(source_root):
737
- src = (source_root / rel).resolve()
738
- if not src.exists() or not src.is_file():
739
- continue
740
- dst = worktree_path / rel
741
- if dst.exists() or dst.is_symlink():
742
- continue
743
- dst.parent.mkdir(parents=True, exist_ok=True)
744
- shutil.copy2(src, dst)
745
- try:
746
- os.chmod(dst, stat.S_IRUSR | stat.S_IRGRP | stat.S_IROTH)
747
- except OSError:
748
- # chmod failure (exotic filesystems, root-squashed mounts) does
749
- # not invalidate the snapshot itself — it just means the read-
750
- # only contract is best-effort. Surface in notes so the operator
751
- # can audit if a verifier later mutates the file.
752
- notes.append(f"{rel} (rw-fallback)")
753
- continue
754
- notes.append(rel)
755
- return notes
756
-
757
-
758
- def compute_worktree_path(
759
- *,
760
- project_id: str,
761
- task_group_segment: str,
762
- task_id_segment: str,
763
- stage_number: Optional[int] = None,
764
- group_id: Optional[str] = None,
765
- ) -> Path:
766
- """Pure path computation. One worktree dir per task-key, or per
767
- `<task-key>/stage-<N>` when stage_number is given (implementation
768
- stage isolation). Uses `OKSTRA_HOME` when set (test hook), else
769
- `~/.okstra`."""
770
- if stage_number is not None and group_id is not None:
771
- raise ValueError("stage_number and group_id are mutually exclusive")
772
- base = okstra_home()
773
- path = (
774
- base / "worktrees"
775
- / _safe_segment(project_id)
776
- / _safe_segment(task_group_segment)
777
- / _safe_segment(task_id_segment)
778
- )
779
- if stage_number is not None:
780
- path = path / f"stage-{stage_number}"
781
- if group_id is not None:
782
- path = path / f"group-{group_id}"
783
- return path
784
-
785
-
786
- def compute_branch_name(
787
- *,
788
- work_category: str,
789
- task_id_segment: str,
790
- stage_number: Optional[int] = None,
791
- group_id: Optional[str] = None,
792
- ) -> str:
793
- """One branch per task-key as `<namespace>/<task-id>`, or
794
- `<namespace>/<task-id>-s<N>` for an implementation stage worktree. The
795
- namespace is a controlled constant so its slash is preserved; only the
796
- task-id segment is sanitised."""
797
- if stage_number is not None and group_id is not None:
798
- raise ValueError("stage_number and group_id are mutually exclusive")
799
- name = f"{_work_category_namespace(work_category)}/{_safe_segment(task_id_segment)}"
800
- if stage_number is not None:
801
- name = f"{name}-s{stage_number}"
802
- if group_id is not None:
803
- name = f"{name}-{group_id}"
804
- return name
805
-
806
-
807
- def provision_task_worktree(
808
- *,
809
- task_type: str,
810
- project_root: Path,
811
- project_id: str,
812
- task_group_segment: str,
813
- task_id_segment: str,
814
- work_category: str,
815
- base_ref: str = "",
816
- require_base_ref: bool = False,
817
- ) -> WorktreeProvision:
818
- """Materialise (or reuse) the task worktree for this run.
819
-
820
- First phase of a task-key creates the worktree on a new branch.
821
- Subsequent phases of the same task-key look up the registry and
822
- return the existing path + branch unchanged.
823
-
824
- ``base_ref`` is the ref (branch name, tag, or commit SHA) to branch
825
- the new worktree from on first phase. When empty, the main worktree's
826
- current ``HEAD`` is used (legacy default; the CLI enforces a
827
- non-empty value on first phase so callers go through the
828
- AskUserQuestion menu in the okstra-run skill). Subsequent phases
829
- ignore ``base_ref`` — the registered entry's base is reused.
830
-
831
- Concurrency: callers must hold ``locks.worktree_provision_mutex`` for
832
- this task-key (run.py's prepare flow does). The exists/branch pre-checks
833
- and ``git worktree add`` here are not internally locked — only the
834
- registry reserve row is — so unlocked concurrent calls race (TOCTOU).
835
- flock is non-reentrant, hence the lock lives at the caller.
836
-
837
- Raises:
838
- RuntimeError when worktree creation fails (path clash on disk
839
- that the registry does not know about, branch clash with a
840
- different task-key, `git worktree add` non-zero). The caller
841
- (`run.py`) catches and re-raises as PrepareError to keep a
842
- single error surface.
843
- """
844
- decision = preview_worktree_decision(
845
- project_root=project_root, project_id=project_id,
846
- task_group_segment=task_group_segment, task_id_segment=task_id_segment,
847
- work_category=work_category, base_ref=base_ref,
848
- )
849
-
850
- if decision.status == "skipped-not-git":
851
- return WorktreeProvision(
852
- status="skipped-not-git",
853
- path=decision.path,
854
- note=(
855
- "worktree provisioning skipped: project_root is not inside a git "
856
- "repository; task will operate directly on project_root"
857
- ),
858
- )
859
-
860
- if decision.status == "skipped-in-worktree":
861
- return WorktreeProvision(
862
- status="skipped-in-worktree",
863
- path=decision.path,
864
- note=(
865
- "worktree provisioning skipped: project_root is already inside a "
866
- "non-main git worktree; task reuses the caller's worktree"
867
- ),
868
- )
869
-
870
- safe_project = _safe_segment(project_id)
871
- safe_group = _safe_segment(task_group_segment)
872
- safe_task = _safe_segment(task_id_segment)
873
-
874
- if decision.status == "reused":
875
- worktree_registry.touch_phase(safe_project, safe_group, safe_task, task_type)
876
- _seed_worktree_settings_symlink(Path(decision.path))
877
- return WorktreeProvision(
878
- status="reused",
879
- path=decision.path,
880
- branch=decision.branch,
881
- base_ref=decision.base_ref,
882
- note=(
883
- f"task worktree reused at {decision.path} on branch "
884
- f"{decision.branch} (base {decision.base_ref[:12]}); phase {task_type}"
885
- ),
886
- )
887
-
888
- # decision.status == "new" — proceed with creation
889
- worktree_path = Path(decision.path)
890
- branch = decision.branch
891
-
892
- if worktree_path.exists():
893
- raise RuntimeError(
894
- f"task worktree path already exists but is not in the registry: "
895
- f"{worktree_path}. Remove it with `git worktree remove <path>` "
896
- "(or `rm -rf` if it is not a registered worktree) before retrying."
897
- )
898
- if _branch_exists(project_root, branch):
899
- raise RuntimeError(_branch_exists_message(project_root, branch, "task"))
900
-
901
- main_root = main_worktree_path(project_root)
902
- requested_base = (base_ref or "").strip()
903
- if not requested_base and require_base_ref:
904
- raise RuntimeError(
905
- "first-phase task worktree requires an explicit base ref; "
906
- "pass `--base-ref <branch|tag|sha>` (or invoke through the "
907
- "okstra-run skill which collects this interactively)"
908
- )
909
- if requested_base:
910
- resolved_sha = _resolve_commit_sha(main_root, requested_base)
911
- if not resolved_sha:
912
- raise RuntimeError(
913
- f"could not resolve base ref `{requested_base}` in main worktree "
914
- f"({main_root}); ensure the branch/tag/SHA exists locally"
915
- )
916
- resolved_base_ref = resolved_sha
917
- base_origin = requested_base
918
- else:
919
- resolved_base_ref = _head_sha(main_root)
920
- if not resolved_base_ref:
921
- raise RuntimeError(
922
- "could not resolve HEAD sha in main worktree; cannot create task worktree"
923
- )
924
- base_origin = "HEAD"
925
-
926
- worktree_path.parent.mkdir(parents=True, exist_ok=True)
927
- res = _git(
928
- main_root,
929
- "worktree", "add", "-b", branch, str(worktree_path), resolved_base_ref,
930
- )
931
- if res.returncode != 0:
932
- raise RuntimeError(
933
- f"`git worktree add` failed (exit={res.returncode}): "
934
- f"{(res.stderr or res.stdout).strip()}"
935
- )
936
-
937
- # Sync dirs sourced from the MAIN worktree so every task sees the
938
- # same shared state regardless of which checkout invoked okstra.
939
- linked = _link_sync_dirs(main_root, worktree_path)
940
- linked_files = _link_sync_files(main_root, worktree_path)
941
- snapshot_files = _copy_snapshot_files(main_root, worktree_path)
942
- linked_parts: list[str] = []
943
- if linked:
944
- linked_parts.append(f"linked {', '.join(linked)}")
945
- if linked_files:
946
- linked_parts.append(f"linked-files {', '.join(linked_files)}")
947
- if snapshot_files:
948
- linked_parts.append(f"snapshot {', '.join(snapshot_files)}")
949
- linked_suffix = ("; " + "; ".join(linked_parts)) if linked_parts else ""
950
-
951
- try:
952
- worktree_registry.reserve(
953
- project_id=safe_project,
954
- task_group=safe_group,
955
- task_id=safe_task,
956
- worktree_path=str(worktree_path),
957
- branch=branch,
958
- base_ref=resolved_base_ref,
959
- phase=task_type,
960
- )
961
- except RuntimeError:
962
- # Roll back the on-disk worktree so the next attempt is not
963
- # blocked by the lingering directory / branch.
964
- remove_worktree_force(main_root, worktree_path)
965
- _git(main_root, "branch", "-D", branch)
966
- raise
967
-
968
- _seed_worktree_settings_symlink(worktree_path)
969
-
970
- base_label = (
971
- f"{base_origin} @ {resolved_base_ref[:12]}"
972
- if base_origin != "HEAD"
973
- else f"HEAD @ {resolved_base_ref[:12]}"
974
- )
975
- return WorktreeProvision(
976
- status="created",
977
- path=str(worktree_path),
978
- branch=branch,
979
- base_ref=resolved_base_ref,
980
- note=(
981
- f"task worktree created at {worktree_path} on branch {branch} "
982
- f"(base {base_label}; phase {task_type}){linked_suffix}"
983
- ),
984
- )
985
-
986
-
987
- def provision_stage_worktree(
988
- *,
989
- project_root: Path,
990
- project_id: str,
991
- task_group_segment: str,
992
- task_id_segment: str,
993
- work_category: str,
994
- stage_number: int,
995
- base_commit: str,
996
- ) -> WorktreeProvision:
997
- """Materialise an isolated worktree for one implementation stage.
998
-
999
- Unlike `provision_task_worktree` (one worktree per task-key shared
1000
- across phases), this provisions a per-stage worktree branched from
1001
- `base_commit` at `<task-key>/stage-<N>/` on branch `<prefix>-<task>-s<N>`.
1002
- The stage-key (`<task-key>#stage-<N>`) is reserved atomically through
1003
- `worktree_registry`; re-entry of the same stage-key returns the
1004
- existing entry. Branch / on-disk conflicts roll back the worktree
1005
- before re-raising so a retry is not blocked.
1006
-
1007
- Concurrency: callers must hold ``locks.worktree_provision_mutex`` for
1008
- the task-key, acquired BEFORE the Stage Run Claim reads the registry
1009
- (run.py does) — otherwise two `--stage auto` runs can select the same
1010
- stage and the loser silently enters the winner's worktree via the
1011
- "reused" path. flock is non-reentrant, hence the lock lives at the
1012
- caller.
1013
- """
1014
- if not base_commit:
1015
- raise RuntimeError("provision_stage_worktree requires a base_commit")
1016
-
1017
- decision = resolve_stage_worktree_decision(
1018
- project_id=project_id,
1019
- task_group_segment=task_group_segment,
1020
- task_id_segment=task_id_segment,
1021
- work_category=work_category,
1022
- stage_number=stage_number,
1023
- )
1024
- if decision.status == "reused":
1025
- return WorktreeProvision(
1026
- status="reused",
1027
- path=decision.path,
1028
- branch=decision.branch,
1029
- base_ref=decision.base_ref,
1030
- note=(
1031
- f"stage {stage_number} worktree reused at "
1032
- f"{decision.path} on branch {decision.branch} "
1033
- f"(base {decision.base_ref[:12]})"
1034
- ),
1035
- )
1036
-
1037
- safe_project = _safe_segment(project_id)
1038
- safe_group = _safe_segment(task_group_segment)
1039
- safe_task = _safe_segment(task_id_segment)
1040
- worktree_path = Path(decision.path)
1041
- branch = decision.branch
1042
-
1043
- if worktree_path.exists():
1044
- raise RuntimeError(
1045
- f"stage worktree path already exists but is not in the registry: "
1046
- f"{worktree_path}. Remove it before retrying."
1047
- )
1048
- if _branch_exists(project_root, branch):
1049
- raise RuntimeError(_branch_exists_message(project_root, branch, "stage"))
1050
-
1051
- main_root = main_worktree_path(project_root)
1052
- resolved_sha = _resolve_commit_sha(main_root, base_commit)
1053
- if not resolved_sha:
1054
- raise RuntimeError(
1055
- f"could not resolve base_commit `{base_commit}` in main worktree "
1056
- f"({main_root}); ensure the commit exists locally"
1057
- )
1058
-
1059
- worktree_path.parent.mkdir(parents=True, exist_ok=True)
1060
- res = _git(
1061
- main_root,
1062
- "worktree", "add", "-b", branch, str(worktree_path), resolved_sha,
1063
- )
1064
- if res.returncode != 0:
1065
- raise RuntimeError(
1066
- f"`git worktree add` failed (exit={res.returncode}): "
1067
- f"{(res.stderr or res.stdout).strip()}"
1068
- )
1069
-
1070
- _link_sync_dirs(main_root, worktree_path)
1071
- _link_sync_files(main_root, worktree_path)
1072
- _copy_snapshot_files(main_root, worktree_path)
1073
-
1074
- try:
1075
- worktree_registry.reserve(
1076
- project_id=safe_project,
1077
- task_group=safe_group,
1078
- task_id=safe_task,
1079
- worktree_path=str(worktree_path),
1080
- branch=branch,
1081
- base_ref=resolved_sha,
1082
- phase="implementation",
1083
- stage_number=stage_number,
1084
- )
1085
- except RuntimeError:
1086
- remove_worktree_force(main_root, worktree_path)
1087
- _git(main_root, "branch", "-D", branch)
1088
- raise
1089
-
1090
- _seed_worktree_settings_symlink(worktree_path)
1091
-
1092
- return WorktreeProvision(
1093
- status="created", path=str(worktree_path),
1094
- branch=branch, base_ref=resolved_sha,
1095
- note=(
1096
- f"stage {stage_number} worktree created at {worktree_path} "
1097
- f"on branch {branch} (base {resolved_sha[:12]})"
1098
- ),
1099
- )