@ag-eco/agentplate-cli 0.13.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 (455) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +462 -0
  3. package/agents/ap-co-creation.md +90 -0
  4. package/agents/builder.md +144 -0
  5. package/agents/coordinator.md +377 -0
  6. package/agents/lead.md +435 -0
  7. package/agents/merger.md +164 -0
  8. package/agents/monitor.md +214 -0
  9. package/agents/orchestrator.md +239 -0
  10. package/agents/reviewer.md +140 -0
  11. package/agents/scout.md +125 -0
  12. package/agents/supervisor.md +427 -0
  13. package/package.json +66 -0
  14. package/src/agents/capabilities.test.ts +85 -0
  15. package/src/agents/capabilities.ts +125 -0
  16. package/src/agents/checkpoint.test.ts +88 -0
  17. package/src/agents/checkpoint.ts +101 -0
  18. package/src/agents/copilot-hooks-deployer.test.ts +162 -0
  19. package/src/agents/copilot-hooks-deployer.ts +93 -0
  20. package/src/agents/guard-rules.test.ts +372 -0
  21. package/src/agents/guard-rules.ts +97 -0
  22. package/src/agents/headless-mail-injector.test.ts +709 -0
  23. package/src/agents/headless-mail-injector.ts +377 -0
  24. package/src/agents/headless-prompt.test.ts +102 -0
  25. package/src/agents/headless-prompt.ts +68 -0
  26. package/src/agents/hooks-deployer.test.ts +3119 -0
  27. package/src/agents/hooks-deployer.ts +804 -0
  28. package/src/agents/identity.test.ts +604 -0
  29. package/src/agents/identity.ts +384 -0
  30. package/src/agents/lifecycle.test.ts +196 -0
  31. package/src/agents/lifecycle.ts +183 -0
  32. package/src/agents/mail-poll-detect.test.ts +153 -0
  33. package/src/agents/mail-poll-detect.ts +73 -0
  34. package/src/agents/manifest.test.ts +1026 -0
  35. package/src/agents/manifest.ts +376 -0
  36. package/src/agents/overlay.test.ts +1058 -0
  37. package/src/agents/overlay.ts +490 -0
  38. package/src/agents/scope-detect.test.ts +190 -0
  39. package/src/agents/scope-detect.ts +146 -0
  40. package/src/agents/turn-lock.test.ts +181 -0
  41. package/src/agents/turn-lock.ts +235 -0
  42. package/src/agents/turn-runner-dispatch.test.ts +182 -0
  43. package/src/agents/turn-runner-dispatch.ts +105 -0
  44. package/src/agents/turn-runner.test.ts +2312 -0
  45. package/src/agents/turn-runner.ts +1383 -0
  46. package/src/beads/client.test.ts +217 -0
  47. package/src/beads/client.ts +230 -0
  48. package/src/beads/molecules.test.ts +338 -0
  49. package/src/beads/molecules.ts +198 -0
  50. package/src/commands/agents.test.ts +328 -0
  51. package/src/commands/agents.ts +299 -0
  52. package/src/commands/clean.test.ts +797 -0
  53. package/src/commands/clean.ts +791 -0
  54. package/src/commands/completions.test.ts +348 -0
  55. package/src/commands/completions.ts +981 -0
  56. package/src/commands/coordinator.test.ts +2975 -0
  57. package/src/commands/coordinator.ts +1841 -0
  58. package/src/commands/costs.test.ts +1183 -0
  59. package/src/commands/costs.ts +599 -0
  60. package/src/commands/dashboard.test.ts +954 -0
  61. package/src/commands/dashboard.ts +1212 -0
  62. package/src/commands/discover.test.ts +288 -0
  63. package/src/commands/discover.ts +202 -0
  64. package/src/commands/doctor.test.ts +303 -0
  65. package/src/commands/doctor.ts +311 -0
  66. package/src/commands/ecosystem.test.ts +226 -0
  67. package/src/commands/ecosystem.ts +248 -0
  68. package/src/commands/errors.test.ts +654 -0
  69. package/src/commands/errors.ts +197 -0
  70. package/src/commands/feed.test.ts +709 -0
  71. package/src/commands/feed.ts +260 -0
  72. package/src/commands/group.test.ts +475 -0
  73. package/src/commands/group.ts +546 -0
  74. package/src/commands/hooks.test.ts +458 -0
  75. package/src/commands/hooks.ts +263 -0
  76. package/src/commands/init.test.ts +1011 -0
  77. package/src/commands/init.ts +967 -0
  78. package/src/commands/inspect.test.ts +1239 -0
  79. package/src/commands/inspect.ts +648 -0
  80. package/src/commands/log.test.ts +1913 -0
  81. package/src/commands/log.ts +958 -0
  82. package/src/commands/logs.test.ts +801 -0
  83. package/src/commands/logs.ts +483 -0
  84. package/src/commands/mail.test.ts +1501 -0
  85. package/src/commands/mail.ts +848 -0
  86. package/src/commands/merge.test.ts +864 -0
  87. package/src/commands/merge.ts +381 -0
  88. package/src/commands/metrics.test.ts +458 -0
  89. package/src/commands/metrics.ts +129 -0
  90. package/src/commands/monitor.test.ts +191 -0
  91. package/src/commands/monitor.ts +409 -0
  92. package/src/commands/nudge.test.ts +579 -0
  93. package/src/commands/nudge.ts +646 -0
  94. package/src/commands/orchestrator.ts +42 -0
  95. package/src/commands/prime.test.ts +612 -0
  96. package/src/commands/prime.ts +359 -0
  97. package/src/commands/replay.test.ts +757 -0
  98. package/src/commands/replay.ts +231 -0
  99. package/src/commands/run.test.ts +469 -0
  100. package/src/commands/run.ts +353 -0
  101. package/src/commands/serve/agent-actions.test.ts +210 -0
  102. package/src/commands/serve/agent-actions.ts +192 -0
  103. package/src/commands/serve/build.test.ts +202 -0
  104. package/src/commands/serve/build.ts +206 -0
  105. package/src/commands/serve/coordinator-actions.test.ts +339 -0
  106. package/src/commands/serve/coordinator-actions.ts +410 -0
  107. package/src/commands/serve/dev.test.ts +168 -0
  108. package/src/commands/serve/dev.ts +117 -0
  109. package/src/commands/serve/mail-actions.test.ts +312 -0
  110. package/src/commands/serve/mail-actions.ts +167 -0
  111. package/src/commands/serve/rest.test.ts +1680 -0
  112. package/src/commands/serve/rest.ts +1130 -0
  113. package/src/commands/serve/static.ts +51 -0
  114. package/src/commands/serve/ws.test.ts +361 -0
  115. package/src/commands/serve/ws.ts +332 -0
  116. package/src/commands/serve.test.ts +459 -0
  117. package/src/commands/serve.ts +654 -0
  118. package/src/commands/sling.test.ts +1583 -0
  119. package/src/commands/sling.ts +1351 -0
  120. package/src/commands/spec.test.ts +179 -0
  121. package/src/commands/spec.ts +105 -0
  122. package/src/commands/status.test.ts +614 -0
  123. package/src/commands/status.ts +403 -0
  124. package/src/commands/stop.test.ts +964 -0
  125. package/src/commands/stop.ts +319 -0
  126. package/src/commands/supervisor.test.ts +185 -0
  127. package/src/commands/supervisor.ts +537 -0
  128. package/src/commands/trace.test.ts +762 -0
  129. package/src/commands/trace.ts +205 -0
  130. package/src/commands/update.test.ts +466 -0
  131. package/src/commands/update.ts +263 -0
  132. package/src/commands/upgrade.test.ts +48 -0
  133. package/src/commands/upgrade.ts +240 -0
  134. package/src/commands/watch.test.ts +257 -0
  135. package/src/commands/watch.ts +308 -0
  136. package/src/commands/worktree.test.ts +1297 -0
  137. package/src/commands/worktree.ts +451 -0
  138. package/src/config.test.ts +1535 -0
  139. package/src/config.ts +1064 -0
  140. package/src/doctor/agents.test.ts +523 -0
  141. package/src/doctor/agents.ts +399 -0
  142. package/src/doctor/config-check.test.ts +191 -0
  143. package/src/doctor/config-check.ts +183 -0
  144. package/src/doctor/consistency.test.ts +807 -0
  145. package/src/doctor/consistency.ts +347 -0
  146. package/src/doctor/databases.test.ts +350 -0
  147. package/src/doctor/databases.ts +243 -0
  148. package/src/doctor/dependencies.test.ts +296 -0
  149. package/src/doctor/dependencies.ts +272 -0
  150. package/src/doctor/ecosystem.test.ts +308 -0
  151. package/src/doctor/ecosystem.ts +156 -0
  152. package/src/doctor/logs.test.ts +253 -0
  153. package/src/doctor/logs.ts +295 -0
  154. package/src/doctor/merge-queue.test.ts +315 -0
  155. package/src/doctor/merge-queue.ts +167 -0
  156. package/src/doctor/providers.test.ts +409 -0
  157. package/src/doctor/providers.ts +250 -0
  158. package/src/doctor/serve.test.ts +95 -0
  159. package/src/doctor/serve.ts +86 -0
  160. package/src/doctor/structure.test.ts +423 -0
  161. package/src/doctor/structure.ts +285 -0
  162. package/src/doctor/types.ts +43 -0
  163. package/src/doctor/version.test.ts +241 -0
  164. package/src/doctor/version.ts +132 -0
  165. package/src/doctor/watchdog.test.ts +167 -0
  166. package/src/doctor/watchdog.ts +214 -0
  167. package/src/e2e/init-sling-lifecycle.test.ts +283 -0
  168. package/src/errors.test.ts +350 -0
  169. package/src/errors.ts +217 -0
  170. package/src/events/store.test.ts +660 -0
  171. package/src/events/store.ts +369 -0
  172. package/src/events/tailer.test.ts +719 -0
  173. package/src/events/tailer.ts +332 -0
  174. package/src/events/tool-filter.test.ts +330 -0
  175. package/src/events/tool-filter.ts +126 -0
  176. package/src/index.ts +533 -0
  177. package/src/insights/analyzer.test.ts +466 -0
  178. package/src/insights/analyzer.ts +203 -0
  179. package/src/insights/quality-gates.test.ts +141 -0
  180. package/src/insights/quality-gates.ts +156 -0
  181. package/src/json.test.ts +72 -0
  182. package/src/json.ts +53 -0
  183. package/src/loam/client.test.ts +752 -0
  184. package/src/loam/client.ts +664 -0
  185. package/src/logging/color.test.ts +252 -0
  186. package/src/logging/color.ts +105 -0
  187. package/src/logging/format.test.ts +110 -0
  188. package/src/logging/format.ts +255 -0
  189. package/src/logging/logger.test.ts +814 -0
  190. package/src/logging/logger.ts +266 -0
  191. package/src/logging/reporter.test.ts +259 -0
  192. package/src/logging/reporter.ts +110 -0
  193. package/src/logging/sanitizer.test.ts +190 -0
  194. package/src/logging/sanitizer.ts +57 -0
  195. package/src/logging/theme.ts +140 -0
  196. package/src/mail/broadcast.test.ts +204 -0
  197. package/src/mail/broadcast.ts +92 -0
  198. package/src/mail/client.test.ts +774 -0
  199. package/src/mail/client.ts +236 -0
  200. package/src/mail/store.test.ts +898 -0
  201. package/src/mail/store.ts +425 -0
  202. package/src/merge/lock.test.ts +149 -0
  203. package/src/merge/lock.ts +140 -0
  204. package/src/merge/predict.test.ts +387 -0
  205. package/src/merge/predict.ts +249 -0
  206. package/src/merge/queue.test.ts +426 -0
  207. package/src/merge/queue.ts +246 -0
  208. package/src/merge/resolver.test.ts +1993 -0
  209. package/src/merge/resolver.ts +926 -0
  210. package/src/metrics/pricing.test.ts +258 -0
  211. package/src/metrics/pricing.ts +135 -0
  212. package/src/metrics/store.test.ts +978 -0
  213. package/src/metrics/store.ts +501 -0
  214. package/src/metrics/summary.test.ts +398 -0
  215. package/src/metrics/summary.ts +178 -0
  216. package/src/metrics/transcript.test.ts +483 -0
  217. package/src/metrics/transcript.ts +114 -0
  218. package/src/runtimes/__fixtures__/claude-stream-fixture.ts +22 -0
  219. package/src/runtimes/aider.test.ts +124 -0
  220. package/src/runtimes/aider.ts +147 -0
  221. package/src/runtimes/amp.test.ts +164 -0
  222. package/src/runtimes/amp.ts +154 -0
  223. package/src/runtimes/claude.test.ts +1474 -0
  224. package/src/runtimes/claude.ts +579 -0
  225. package/src/runtimes/codex.test.ts +805 -0
  226. package/src/runtimes/codex.ts +273 -0
  227. package/src/runtimes/connections.test.ts +214 -0
  228. package/src/runtimes/connections.ts +103 -0
  229. package/src/runtimes/copilot.test.ts +707 -0
  230. package/src/runtimes/copilot.ts +316 -0
  231. package/src/runtimes/cursor.test.ts +497 -0
  232. package/src/runtimes/cursor.ts +205 -0
  233. package/src/runtimes/gemini.test.ts +537 -0
  234. package/src/runtimes/gemini.ts +243 -0
  235. package/src/runtimes/goose.test.ts +133 -0
  236. package/src/runtimes/goose.ts +157 -0
  237. package/src/runtimes/headless-connection.test.ts +264 -0
  238. package/src/runtimes/headless-connection.ts +158 -0
  239. package/src/runtimes/opencode.test.ts +325 -0
  240. package/src/runtimes/opencode.ts +188 -0
  241. package/src/runtimes/pi-guards.test.ts +486 -0
  242. package/src/runtimes/pi-guards.ts +367 -0
  243. package/src/runtimes/pi.test.ts +789 -0
  244. package/src/runtimes/pi.ts +305 -0
  245. package/src/runtimes/registry.test.ts +196 -0
  246. package/src/runtimes/registry.ts +99 -0
  247. package/src/runtimes/sapling.test.ts +1267 -0
  248. package/src/runtimes/sapling.ts +710 -0
  249. package/src/runtimes/types.ts +266 -0
  250. package/src/schema-consistency.test.ts +246 -0
  251. package/src/sessions/compat.test.ts +281 -0
  252. package/src/sessions/compat.ts +105 -0
  253. package/src/sessions/store.test.ts +1748 -0
  254. package/src/sessions/store.ts +858 -0
  255. package/src/test-helpers.test.ts +124 -0
  256. package/src/test-helpers.ts +145 -0
  257. package/src/test-setup.test.ts +31 -0
  258. package/src/test-setup.ts +28 -0
  259. package/src/tools/loam/api.ts +368 -0
  260. package/src/tools/loam/cli.ts +278 -0
  261. package/src/tools/loam/commands/add.ts +52 -0
  262. package/src/tools/loam/commands/archive.ts +214 -0
  263. package/src/tools/loam/commands/audit.ts +276 -0
  264. package/src/tools/loam/commands/compact.ts +1062 -0
  265. package/src/tools/loam/commands/completions.ts +79 -0
  266. package/src/tools/loam/commands/config.ts +381 -0
  267. package/src/tools/loam/commands/delete-domain.ts +121 -0
  268. package/src/tools/loam/commands/delete.ts +316 -0
  269. package/src/tools/loam/commands/diff.ts +200 -0
  270. package/src/tools/loam/commands/doctor.ts +1113 -0
  271. package/src/tools/loam/commands/edit.ts +226 -0
  272. package/src/tools/loam/commands/init.ts +31 -0
  273. package/src/tools/loam/commands/learn.ts +179 -0
  274. package/src/tools/loam/commands/move.ts +323 -0
  275. package/src/tools/loam/commands/onboard.ts +374 -0
  276. package/src/tools/loam/commands/outcome.ts +185 -0
  277. package/src/tools/loam/commands/prime.ts +688 -0
  278. package/src/tools/loam/commands/prune.ts +614 -0
  279. package/src/tools/loam/commands/query.ts +218 -0
  280. package/src/tools/loam/commands/rank.ts +180 -0
  281. package/src/tools/loam/commands/ready.ts +189 -0
  282. package/src/tools/loam/commands/record.ts +1210 -0
  283. package/src/tools/loam/commands/restore.ts +166 -0
  284. package/src/tools/loam/commands/search.ts +327 -0
  285. package/src/tools/loam/commands/setup.ts +887 -0
  286. package/src/tools/loam/commands/status.ts +103 -0
  287. package/src/tools/loam/commands/sync.ts +298 -0
  288. package/src/tools/loam/commands/update.ts +19 -0
  289. package/src/tools/loam/commands/upgrade.ts +93 -0
  290. package/src/tools/loam/commands/validate.ts +190 -0
  291. package/src/tools/loam/index.ts +62 -0
  292. package/src/tools/loam/log.ts +127 -0
  293. package/src/tools/loam/registry/builtins.ts +409 -0
  294. package/src/tools/loam/registry/custom.ts +431 -0
  295. package/src/tools/loam/registry/init.ts +55 -0
  296. package/src/tools/loam/registry/template.ts +40 -0
  297. package/src/tools/loam/registry/type-registry.ts +113 -0
  298. package/src/tools/loam/schemas/config-schema.ts +489 -0
  299. package/src/tools/loam/schemas/config.ts +245 -0
  300. package/src/tools/loam/schemas/index.ts +18 -0
  301. package/src/tools/loam/schemas/record-schema.ts +191 -0
  302. package/src/tools/loam/schemas/record.ts +115 -0
  303. package/src/tools/loam/utils/active-work.ts +205 -0
  304. package/src/tools/loam/utils/anchor-validity.ts +80 -0
  305. package/src/tools/loam/utils/archive.ts +146 -0
  306. package/src/tools/loam/utils/audit.ts +667 -0
  307. package/src/tools/loam/utils/bm25.ts +238 -0
  308. package/src/tools/loam/utils/budget.ts +142 -0
  309. package/src/tools/loam/utils/config.ts +344 -0
  310. package/src/tools/loam/utils/dir-anchors.ts +62 -0
  311. package/src/tools/loam/utils/domain-rules.ts +114 -0
  312. package/src/tools/loam/utils/expertise.ts +393 -0
  313. package/src/tools/loam/utils/format-helpers.ts +96 -0
  314. package/src/tools/loam/utils/format.ts +1234 -0
  315. package/src/tools/loam/utils/git-context.ts +50 -0
  316. package/src/tools/loam/utils/git.ts +183 -0
  317. package/src/tools/loam/utils/hooks.ts +299 -0
  318. package/src/tools/loam/utils/index.ts +52 -0
  319. package/src/tools/loam/utils/json-output.ts +13 -0
  320. package/src/tools/loam/utils/lock.ts +76 -0
  321. package/src/tools/loam/utils/markers.ts +48 -0
  322. package/src/tools/loam/utils/numeric-flags.ts +20 -0
  323. package/src/tools/loam/utils/palette.ts +44 -0
  324. package/src/tools/loam/utils/prime-ranking.ts +135 -0
  325. package/src/tools/loam/utils/recipe-discovery.ts +195 -0
  326. package/src/tools/loam/utils/runtime-flags.ts +28 -0
  327. package/src/tools/loam/utils/scoring.ts +94 -0
  328. package/src/tools/loam/utils/version.ts +116 -0
  329. package/src/tools/sprout/commands/block.ts +64 -0
  330. package/src/tools/sprout/commands/blocked.ts +86 -0
  331. package/src/tools/sprout/commands/close.ts +129 -0
  332. package/src/tools/sprout/commands/completions.ts +198 -0
  333. package/src/tools/sprout/commands/config.ts +238 -0
  334. package/src/tools/sprout/commands/create.ts +164 -0
  335. package/src/tools/sprout/commands/dep.ts +148 -0
  336. package/src/tools/sprout/commands/doctor.ts +979 -0
  337. package/src/tools/sprout/commands/init.ts +83 -0
  338. package/src/tools/sprout/commands/label.ts +178 -0
  339. package/src/tools/sprout/commands/list.ts +210 -0
  340. package/src/tools/sprout/commands/migrate.ts +133 -0
  341. package/src/tools/sprout/commands/onboard.ts +207 -0
  342. package/src/tools/sprout/commands/plan-show.ts +278 -0
  343. package/src/tools/sprout/commands/plan.ts +2526 -0
  344. package/src/tools/sprout/commands/prime.ts +399 -0
  345. package/src/tools/sprout/commands/ready.ts +245 -0
  346. package/src/tools/sprout/commands/search.ts +221 -0
  347. package/src/tools/sprout/commands/show.ts +277 -0
  348. package/src/tools/sprout/commands/stats.ts +146 -0
  349. package/src/tools/sprout/commands/sync.ts +134 -0
  350. package/src/tools/sprout/commands/tpl.ts +364 -0
  351. package/src/tools/sprout/commands/unblock.ts +115 -0
  352. package/src/tools/sprout/commands/update.ts +257 -0
  353. package/src/tools/sprout/commands/upgrade.ts +91 -0
  354. package/src/tools/sprout/config-schema.ts +152 -0
  355. package/src/tools/sprout/config.ts +355 -0
  356. package/src/tools/sprout/filter.ts +107 -0
  357. package/src/tools/sprout/format.ts +43 -0
  358. package/src/tools/sprout/id.ts +22 -0
  359. package/src/tools/sprout/index.ts +204 -0
  360. package/src/tools/sprout/log.ts +76 -0
  361. package/src/tools/sprout/markers.ts +22 -0
  362. package/src/tools/sprout/output.ts +121 -0
  363. package/src/tools/sprout/plan-backref.ts +93 -0
  364. package/src/tools/sprout/plan-context.ts +81 -0
  365. package/src/tools/sprout/plan-domain.ts +139 -0
  366. package/src/tools/sprout/plan-lifecycle.ts +65 -0
  367. package/src/tools/sprout/plan-loam.ts +207 -0
  368. package/src/tools/sprout/plan-schema.ts +209 -0
  369. package/src/tools/sprout/sort.ts +31 -0
  370. package/src/tools/sprout/store.ts +172 -0
  371. package/src/tools/sprout/types.ts +118 -0
  372. package/src/tools/sprout/validation.ts +119 -0
  373. package/src/tools/sprout/version.ts +1 -0
  374. package/src/tools/sprout/yaml.ts +387 -0
  375. package/src/tools/trellis/commands/archive.ts +87 -0
  376. package/src/tools/trellis/commands/completions.ts +610 -0
  377. package/src/tools/trellis/commands/config.ts +382 -0
  378. package/src/tools/trellis/commands/create.ts +252 -0
  379. package/src/tools/trellis/commands/diff.ts +150 -0
  380. package/src/tools/trellis/commands/doctor.ts +771 -0
  381. package/src/tools/trellis/commands/emit.ts +365 -0
  382. package/src/tools/trellis/commands/history.ts +83 -0
  383. package/src/tools/trellis/commands/import.ts +198 -0
  384. package/src/tools/trellis/commands/init.ts +81 -0
  385. package/src/tools/trellis/commands/list.ts +103 -0
  386. package/src/tools/trellis/commands/onboard.ts +156 -0
  387. package/src/tools/trellis/commands/pin.ts +172 -0
  388. package/src/tools/trellis/commands/prime.ts +193 -0
  389. package/src/tools/trellis/commands/render.ts +122 -0
  390. package/src/tools/trellis/commands/schema.ts +353 -0
  391. package/src/tools/trellis/commands/show.ts +115 -0
  392. package/src/tools/trellis/commands/stats.ts +65 -0
  393. package/src/tools/trellis/commands/sync.ts +112 -0
  394. package/src/tools/trellis/commands/tree.ts +123 -0
  395. package/src/tools/trellis/commands/update.ts +330 -0
  396. package/src/tools/trellis/commands/upgrade.ts +95 -0
  397. package/src/tools/trellis/commands/validate.ts +166 -0
  398. package/src/tools/trellis/config-schema.ts +81 -0
  399. package/src/tools/trellis/config.ts +108 -0
  400. package/src/tools/trellis/frontmatter.ts +348 -0
  401. package/src/tools/trellis/id.ts +24 -0
  402. package/src/tools/trellis/index.ts +209 -0
  403. package/src/tools/trellis/markers.ts +28 -0
  404. package/src/tools/trellis/output.ts +84 -0
  405. package/src/tools/trellis/render.ts +212 -0
  406. package/src/tools/trellis/store.ts +144 -0
  407. package/src/tools/trellis/types.ts +82 -0
  408. package/src/tools/trellis/validate.ts +199 -0
  409. package/src/tools/trellis/yaml.ts +309 -0
  410. package/src/tracker/beads.test.ts +454 -0
  411. package/src/tracker/beads.ts +56 -0
  412. package/src/tracker/factory.test.ts +90 -0
  413. package/src/tracker/factory.ts +65 -0
  414. package/src/tracker/sprout.test.ts +461 -0
  415. package/src/tracker/sprout.ts +182 -0
  416. package/src/tracker/types.ts +52 -0
  417. package/src/trellis/client.test.ts +107 -0
  418. package/src/trellis/client.ts +179 -0
  419. package/src/types.ts +970 -0
  420. package/src/utils/bin.test.ts +10 -0
  421. package/src/utils/bin.ts +37 -0
  422. package/src/utils/browser.test.ts +49 -0
  423. package/src/utils/browser.ts +48 -0
  424. package/src/utils/fs.test.ts +119 -0
  425. package/src/utils/fs.ts +62 -0
  426. package/src/utils/pid.test.ts +152 -0
  427. package/src/utils/pid.ts +130 -0
  428. package/src/utils/process-scan.test.ts +53 -0
  429. package/src/utils/process-scan.ts +76 -0
  430. package/src/utils/time.test.ts +43 -0
  431. package/src/utils/time.ts +37 -0
  432. package/src/utils/version.test.ts +33 -0
  433. package/src/utils/version.ts +70 -0
  434. package/src/version.ts +5 -0
  435. package/src/watchdog/daemon.test.ts +3721 -0
  436. package/src/watchdog/daemon.ts +1257 -0
  437. package/src/watchdog/health.test.ts +830 -0
  438. package/src/watchdog/health.ts +434 -0
  439. package/src/watchdog/triage.test.ts +205 -0
  440. package/src/watchdog/triage.ts +205 -0
  441. package/src/worktree/manager.test.ts +720 -0
  442. package/src/worktree/manager.ts +405 -0
  443. package/src/worktree/process.test.ts +172 -0
  444. package/src/worktree/process.ts +131 -0
  445. package/src/worktree/tmux.test.ts +1616 -0
  446. package/src/worktree/tmux.ts +721 -0
  447. package/templates/CLAUDE.md.tmpl +100 -0
  448. package/templates/copilot-hooks.json.tmpl +13 -0
  449. package/templates/hooks.json.tmpl +109 -0
  450. package/templates/overlay.md.tmpl +88 -0
  451. package/ui/dist/apple-touch-icon-bdy6teep.png +0 -0
  452. package/ui/dist/chunk-8s31f05k.css +1 -0
  453. package/ui/dist/chunk-vm5rz679.js +300 -0
  454. package/ui/dist/favicon-nzb39vza.svg +4 -0
  455. package/ui/dist/index.html +17 -0
@@ -0,0 +1,2526 @@
1
+ import { dirname } from "node:path";
2
+ import { Command } from "commander";
3
+ import { findSproutDir, loadPlanTemplates, readConfig } from "../config.ts";
4
+ import { generateId } from "../id.ts";
5
+ import { accent, brand, muted, outputJson, printSuccess } from "../output.ts";
6
+ import { applyPlanBackref, buildPlanBackref, stripPlanBackref } from "../plan-backref.ts";
7
+ import { inferDomain } from "../plan-domain.ts";
8
+ import { enrichPriorArt, recordDecision } from "../plan-loam.ts";
9
+ import { compilePlanTemplate, defaultTemplateForType } from "../plan-schema.ts";
10
+ import {
11
+ appendPlan,
12
+ issuesPath,
13
+ plansPath,
14
+ readIssues,
15
+ readPlans,
16
+ withLock,
17
+ writeIssues,
18
+ writePlans,
19
+ } from "../store.ts";
20
+ import type { Issue, Plan, PlanStatus, PlanTemplate, SectionSpec } from "../types.ts";
21
+ import { VALID_TYPES } from "../types.ts";
22
+ import { normalizeLabels } from "./label.ts";
23
+ import { resolvePlanIdArg, runShow } from "./plan-show.ts";
24
+
25
+ export function register(program: Command): void {
26
+ const plan = new Command("plan").description("Plan management");
27
+
28
+ plan
29
+ .command("templates")
30
+ .description("List available plan templates")
31
+ .option("--json", "Output as JSON")
32
+ .action(async (opts: { json?: boolean }) => {
33
+ await runTemplates(Boolean(opts.json));
34
+ });
35
+
36
+ plan
37
+ .command("prompt <seed-id>")
38
+ .description("Emit structured planning prompt JSON for a seed")
39
+ .option("--template <name>", "Override the inferred template")
40
+ .option("--domain <name>", "Force the loam domain used for prior_art enrichment")
41
+ .option("--json", "Output as JSON")
42
+ .action(
43
+ async (seedId: string, opts: { template?: string; domain?: string; json?: boolean }) => {
44
+ await runPrompt(seedId, opts.template, opts.domain, Boolean(opts.json));
45
+ },
46
+ );
47
+
48
+ plan
49
+ .command("submit <seed-id>")
50
+ .description("Validate a plan, spawn children, write plans.jsonl row")
51
+ .requiredOption("--plan <file>", "Path to plan JSON, or '-' to read from stdin")
52
+ .option(
53
+ "--overwrite",
54
+ "Replace an existing non-draft plan: rewrite the row, bump revision, flag obsolete children",
55
+ )
56
+ .option(
57
+ "--record-decision",
58
+ "Best-effort: after success, record the chosen approach as a loam decision",
59
+ )
60
+ .option("--domain <name>", "Force the loam domain used for --record-decision")
61
+ .option(
62
+ "--name <text>",
63
+ "Human-readable plan label; overrides plan JSON 'name' and the seed-title default",
64
+ )
65
+ .option("--json", "Output as JSON")
66
+ .addHelpText(
67
+ "after",
68
+ `
69
+ Plan file shape:
70
+
71
+ {
72
+ "template": "feature",
73
+ "name": "Schema-driven config editor",
74
+ "sections": {
75
+ "approach": "Plain-text approach...",
76
+ "steps": [{ "title": "Step 1", "labels": ["nightwatch"] }, ...],
77
+ "acceptance": ["criterion 1", ...]
78
+ }
79
+ }
80
+
81
+ The shape mirrors 'sr plan prompt': drop the plan_request wrapper, and
82
+ sections is an object keyed by name (not the array of section metadata
83
+ that the prompt emits). Section names and value kinds match the template.
84
+
85
+ Plan name resolution:
86
+ --name flag > plan JSON 'name' > parent seed title (fallback)
87
+ `,
88
+ )
89
+ .action(
90
+ async (
91
+ seedId: string,
92
+ opts: {
93
+ plan: string;
94
+ overwrite?: boolean;
95
+ recordDecision?: boolean;
96
+ domain?: string;
97
+ name?: string;
98
+ json?: boolean;
99
+ },
100
+ ) => {
101
+ await runSubmit(seedId, opts.plan, {
102
+ overwrite: Boolean(opts.overwrite),
103
+ recordDecision: Boolean(opts.recordDecision),
104
+ domainOverride: opts.domain,
105
+ nameOverride: opts.name,
106
+ jsonMode: Boolean(opts.json),
107
+ });
108
+ },
109
+ );
110
+
111
+ plan
112
+ .command("show <id>")
113
+ .description("Show a plan with sections, children, and status (accepts plan id or seed id)")
114
+ .option("--json", "Output as JSON")
115
+ .action(async (id: string, opts: { json?: boolean }) => {
116
+ await runShow(id, Boolean(opts.json));
117
+ });
118
+
119
+ plan
120
+ .command("validate <id>")
121
+ .description(
122
+ "Re-run validation against the current template definition (accepts plan id or seed id)",
123
+ )
124
+ .option("--json", "Output as JSON")
125
+ .action(async (id: string, opts: { json?: boolean }) => {
126
+ await runValidate(id, Boolean(opts.json));
127
+ });
128
+
129
+ plan
130
+ .command("outcome <id>")
131
+ .description(
132
+ "Record a plan outcome (storage-only; not a state transition; accepts plan id or seed id)",
133
+ )
134
+ .requiredOption("--result <value>", "One of: success, partial, failure")
135
+ .option("--note <text>", "Optional free-form note")
136
+ .option("--json", "Output as JSON")
137
+ .action(async (id: string, opts: { result: string; note?: string; json?: boolean }) => {
138
+ await runOutcome(id, opts.result, opts.note, Boolean(opts.json));
139
+ });
140
+
141
+ plan
142
+ .command("review <id>")
143
+ .description(
144
+ "Record a reviewer (informational; not a state transition; accepts plan id or seed id)",
145
+ )
146
+ .requiredOption("--by <name>", "Reviewer name")
147
+ .option("--json", "Output as JSON")
148
+ .action(async (id: string, opts: { by: string; json?: boolean }) => {
149
+ await runReview(id, opts.by, Boolean(opts.json));
150
+ });
151
+
152
+ plan
153
+ .command("edit <id>")
154
+ .description("Edit plan fields in place (accepts plan id or seed id); bumps revision")
155
+ .option("--name <text>", "Set the plan's human-readable label")
156
+ .option(
157
+ "--section <name-and-text...>",
158
+ "Replace a text section: --section <name> <text> (V1: text sections only)",
159
+ )
160
+ .option("--step <i>", "1-based step index to edit (requires --title/--priority/--type)")
161
+ .option("--title <text>", "New title for the step (with --step); propagates to child seed")
162
+ .option("--priority <p>", "New priority (0-4 or P0-P4) for the step (with --step)")
163
+ .option("--type <type>", `New type for the step (with --step): ${VALID_TYPES.join("|")}`)
164
+ .option("--json", "Output as JSON")
165
+ .action(
166
+ async (
167
+ id: string,
168
+ opts: {
169
+ name?: string;
170
+ section?: string[];
171
+ step?: string;
172
+ title?: string;
173
+ priority?: string;
174
+ type?: string;
175
+ json?: boolean;
176
+ },
177
+ ) => {
178
+ await runEdit(id, {
179
+ name: opts.name,
180
+ section: opts.section,
181
+ step: opts.step,
182
+ stepTitle: opts.title,
183
+ stepPriority: opts.priority,
184
+ stepType: opts.type,
185
+ jsonMode: Boolean(opts.json),
186
+ });
187
+ },
188
+ );
189
+
190
+ plan
191
+ .command("create <seed-id>")
192
+ .description(
193
+ "Create an adopt-only plan with zero spawned children (populate via 'sr plan adopt')",
194
+ )
195
+ .option("--name <text>", "Human-readable plan label (defaults to the seed title)")
196
+ .option("--template <name>", "Plan template name (defaults to the seed type's default)")
197
+ .option("--json", "Output as JSON")
198
+ .action(async (seedId: string, opts: { name?: string; template?: string; json?: boolean }) => {
199
+ await runCreate(seedId, {
200
+ name: opts.name,
201
+ template: opts.template,
202
+ jsonMode: Boolean(opts.json),
203
+ });
204
+ });
205
+
206
+ plan
207
+ .command("adopt <plan-id> <seed-ids...>")
208
+ .description("Adopt existing open sprout into a plan (link-only; bumps plan revision)")
209
+ .option(
210
+ "--step <i>",
211
+ "1-based step index within the plan blueprint; sets plan_step_index on adopted sprout",
212
+ )
213
+ .option(
214
+ "--at <i>",
215
+ "1-based position in plan.children to insert the adopted sprout (default: append)",
216
+ )
217
+ .option("--before <seed>", "Insert the adopted sprout before this existing child seed")
218
+ .option("--after <seed>", "Insert the adopted sprout after this existing child seed")
219
+ .option("--json", "Output as JSON")
220
+ .action(
221
+ async (
222
+ planIdArg: string,
223
+ seedIds: string[],
224
+ opts: { step?: string; at?: string; before?: string; after?: string; json?: boolean },
225
+ ) => {
226
+ await runAdopt(planIdArg, seedIds, {
227
+ step: opts.step,
228
+ at: opts.at,
229
+ before: opts.before,
230
+ after: opts.after,
231
+ jsonMode: Boolean(opts.json),
232
+ });
233
+ },
234
+ );
235
+
236
+ plan
237
+ .command("reorder <plan-id> <seed-ids...>")
238
+ .description("Set the exact order of plan.children (must be a permutation of current children)")
239
+ .option("--json", "Output as JSON")
240
+ .action(async (planIdArg: string, seedIds: string[], opts: { json?: boolean }) => {
241
+ await runReorder(planIdArg, seedIds, { jsonMode: Boolean(opts.json) });
242
+ });
243
+
244
+ plan
245
+ .command("release <plan-id> <seed-ids...>")
246
+ .description("Release sprout from a plan (link-only; sprout stay open; bumps plan revision)")
247
+ .option("--json", "Output as JSON")
248
+ .action(async (planIdArg: string, seedIds: string[], opts: { json?: boolean }) => {
249
+ await runRelease(planIdArg, seedIds, {
250
+ jsonMode: Boolean(opts.json),
251
+ });
252
+ });
253
+
254
+ plan
255
+ .command("list")
256
+ .description("List plans with optional filters")
257
+ .option("--seed <id>", "Filter by parent seed id")
258
+ .option("--status <status>", "Filter by status (draft|approved|active|done)")
259
+ .option("--outcome <outcome>", "Filter by outcome (success|partial|failure)")
260
+ .option("--template <name>", "Filter by template name")
261
+ .option("--json", "Output as JSON")
262
+ .action(
263
+ async (opts: {
264
+ seed?: string;
265
+ status?: string;
266
+ outcome?: string;
267
+ template?: string;
268
+ json?: boolean;
269
+ }) => {
270
+ await runList(opts, Boolean(opts.json));
271
+ },
272
+ );
273
+
274
+ // `sr plan` (no subcommand) prints help and exits non-zero so scripted callers notice.
275
+ plan.action(() => {
276
+ plan.outputHelp();
277
+ process.exitCode = 1;
278
+ });
279
+
280
+ program.addCommand(plan);
281
+ }
282
+
283
+ async function runTemplates(jsonMode: boolean): Promise<void> {
284
+ const dir = await findSproutDir();
285
+ const templates = await loadPlanTemplates(dir);
286
+ const list = Object.keys(templates).sort();
287
+ const entries = list.map((name) => ({
288
+ name,
289
+ description: templates[name]?.description ?? "",
290
+ }));
291
+ if (jsonMode) {
292
+ await outputJson({
293
+ success: true,
294
+ command: "plan templates",
295
+ templates: entries,
296
+ count: entries.length,
297
+ });
298
+ return;
299
+ }
300
+ console.log(`${brand("Available templates:")}`);
301
+ for (const t of entries) {
302
+ const desc = t.description ? ` ${muted(t.description)}` : "";
303
+ console.log(` ${accent.bold(t.name)}${desc}`);
304
+ }
305
+ }
306
+
307
+ interface PromptSection {
308
+ name: string;
309
+ required: boolean;
310
+ kind: SectionSpec["kind"];
311
+ prompt: string;
312
+ prior_art: unknown[];
313
+ min_length?: number;
314
+ min?: number;
315
+ item?: SectionSpec["item"];
316
+ }
317
+
318
+ interface PlanRequest {
319
+ seed: string;
320
+ template: string;
321
+ instructions: string;
322
+ sections: PromptSection[];
323
+ validation: {
324
+ all_required_present: boolean;
325
+ min_steps: number;
326
+ min_acceptance: number;
327
+ };
328
+ }
329
+
330
+ const INSTRUCTIONS =
331
+ 'Fill every section. Required fields are marked. Use prior_art entries to ground decisions. Reply with JSON shaped { "template": "<name>", "name": "<short label>", "sections": { "<section-name>": <value>, ... } } — drop the plan_request wrapper, and sections in your reply is an object keyed by name (not the array of section metadata above). The top-level `name` field is an optional short human-readable label (e.g. "Schema-driven config editor"); if you omit it, sr plan submit derives one from the parent seed title. Each step is shaped { title?, type?, priority?, blocks?: number[], labels?: string[], plan_template?, existing_seed? }. In each step, `blocks` lists 1-based step indices that this step blocks (step 1 is the first step, step N is the last); e.g. step 1 with `blocks: [2]` means step 1 must finish before step 2 starts. Leave empty if nothing depends on it. Optional `labels` is an array of non-empty strings applied to the spawned (or adopted) child seed; values are normalized (lowercased, trimmed, deduped) and merged additively on adoption — they never clobber existing labels.';
332
+
333
+ function buildPlanRequest(
334
+ seedId: string,
335
+ templateName: string,
336
+ template: PlanTemplate,
337
+ ): PlanRequest {
338
+ const sections: PromptSection[] = Object.entries(template.sections).map(([name, s]) => {
339
+ const out: PromptSection = {
340
+ name,
341
+ required: s.required,
342
+ kind: s.kind,
343
+ prompt: s.prompt,
344
+ prior_art: [], // Phase 1: empty; Phase 3 fills from loam
345
+ };
346
+ if (s.min_length !== undefined) out.min_length = s.min_length;
347
+ if (s.min !== undefined) out.min = s.min;
348
+ if (s.item !== undefined) out.item = s.item;
349
+ return out;
350
+ });
351
+ const stepsSection = template.sections.steps;
352
+ const acceptanceSection = template.sections.acceptance;
353
+ return {
354
+ seed: seedId,
355
+ template: templateName,
356
+ instructions: INSTRUCTIONS,
357
+ sections,
358
+ validation: {
359
+ all_required_present: true,
360
+ min_steps: stepsSection?.min ?? 0,
361
+ min_acceptance: acceptanceSection?.min ?? 0,
362
+ },
363
+ };
364
+ }
365
+
366
+ async function runPrompt(
367
+ seedId: string,
368
+ templateOverride: string | undefined,
369
+ domainOverride: string | undefined,
370
+ jsonMode: boolean,
371
+ ): Promise<void> {
372
+ const dir = await findSproutDir();
373
+ const issues = await readIssues(dir);
374
+ const seed = issues.find((i) => i.id === seedId);
375
+ if (!seed) throw new Error(`Seed not found: ${seedId}`);
376
+
377
+ const templates = await loadPlanTemplates(dir);
378
+ // PLAN_SPEC.md:329-342 — a child spawned from a step with plan_template
379
+ // inherits that template name unless --template overrides. The back-link
380
+ // to the parent plan is via plan_step_index + plan.children[].
381
+ const inheritedTemplate = templateOverride ? undefined : await resolveStepPlanTemplate(dir, seed);
382
+ const templateName = templateOverride ?? inheritedTemplate ?? defaultTemplateForType(seed.type);
383
+ const template = templates[templateName];
384
+ if (!template) {
385
+ const available = Object.keys(templates).join(", ");
386
+ throw new Error(`Unknown template: ${templateName}. Available: ${available}`);
387
+ }
388
+
389
+ const planRequest = buildPlanRequest(seedId, templateName, template);
390
+
391
+ // Phase 3: prior_art enrichment via loam. Soft coupling — empty arrays
392
+ // when ml is absent or a domain cannot be inferred.
393
+ const { domain } = inferDomain({ seed, explicitDomain: domainOverride });
394
+ const sectionRequests = Object.entries(template.sections).map(([name, spec]) => ({
395
+ name,
396
+ loamSource: spec.loam_source,
397
+ }));
398
+ const priorArt = enrichPriorArt({ domain, sections: sectionRequests });
399
+ for (const section of planRequest.sections) {
400
+ const entries = priorArt[section.name];
401
+ if (entries && entries.length > 0) section.prior_art = entries;
402
+ }
403
+
404
+ if (jsonMode) {
405
+ await outputJson({ plan_request: planRequest });
406
+ return;
407
+ }
408
+
409
+ console.log(`${brand("Plan prompt")} for ${accent.bold(seedId)}`);
410
+ console.log(`${muted("Template:")} ${planRequest.template}`);
411
+ console.log(`${muted("Seed title:")} ${seed.title}`);
412
+ console.log("");
413
+ console.log(planRequest.instructions);
414
+ console.log("");
415
+ for (const s of planRequest.sections) {
416
+ const tag = s.required ? brand("required") : muted("optional");
417
+ const kindLabel = typeof s.kind === "string" ? s.kind : "object";
418
+ console.log(` ${accent.bold(s.name)} ${muted(`(${kindLabel})`)} ${tag}`);
419
+ console.log(` ${muted(s.prompt)}`);
420
+ if (s.min_length !== undefined) console.log(` ${muted(`min_length: ${s.min_length}`)}`);
421
+ if (s.min !== undefined) console.log(` ${muted(`min entries: ${s.min}`)}`);
422
+ }
423
+ console.log("");
424
+ console.log(`${muted("Pipe --json into a file the LLM fills, then run:")}`);
425
+ console.log(` sr plan submit ${seedId} --plan <file>`);
426
+ }
427
+
428
+ interface SubmittedStep {
429
+ // Title is optional only when `existing_seed` is set (adoption-only steps).
430
+ // The post-AJV validator in plan-schema.ts enforces the invariant; reaching
431
+ // the fresh-spawn branch implies `title` is present.
432
+ title?: string;
433
+ type?: string;
434
+ priority?: number;
435
+ blocks?: number[];
436
+ plan_template?: string;
437
+ existing_seed?: string;
438
+ // Optional per-step labels (sprout-7561 / pl-e5a8 step 1). Normalization
439
+ // (lowercase/trim/dedup) and propagation into spawned/adopted children land
440
+ // in subsequent plan steps (sprout-745e fresh-spawn, sprout-bac9 adoption).
441
+ labels?: string[];
442
+ }
443
+
444
+ // Additively merge per-step labels into an adopted seed's existing labels
445
+ // (sprout-bac9 / pl-e5a8 step 3). Normalization mirrors `sr label add`
446
+ // (lowercase, trim, drop empties); the result is deduped via Set. Returns the
447
+ // merged array when it differs from `existing`, or `undefined` when there is
448
+ // nothing to add. Adoption is link-only: we never remove labels the seed
449
+ // already carries — manual user labels survive plan submits.
450
+ function mergeAdoptedLabels(
451
+ existing: string[] | undefined,
452
+ stepLabels: string[] | undefined,
453
+ ): string[] | undefined {
454
+ if (!stepLabels || stepLabels.length === 0) return undefined;
455
+ const normalized = normalizeLabels(stepLabels);
456
+ if (normalized.length === 0) return undefined;
457
+ const current = existing ?? [];
458
+ const merged = Array.from(new Set([...current, ...normalized]));
459
+ if (merged.length === current.length && merged.every((l, i) => l === current[i])) {
460
+ return undefined;
461
+ }
462
+ return merged;
463
+ }
464
+
465
+ interface SubmittedPlan {
466
+ template: string;
467
+ name?: string;
468
+ sections: {
469
+ steps: SubmittedStep[];
470
+ [key: string]: unknown;
471
+ };
472
+ }
473
+
474
+ // Resolve the plan_template declared on the parent plan's step that spawned
475
+ // this seed (PLAN_SPEC.md:329-342). For plan_template children, plan_id is
476
+ // unset, so we fall back to scanning plans by children[] membership.
477
+ async function resolveStepPlanTemplate(dir: string, seed: Issue): Promise<string | undefined> {
478
+ if (seed.plan_step_index === undefined) return undefined;
479
+ const plans = await readPlans(dir);
480
+ let parentPlan: Plan | undefined;
481
+ if (seed.plan_id) {
482
+ parentPlan = plans.find((p) => p.id === seed.plan_id);
483
+ }
484
+ if (!parentPlan) {
485
+ parentPlan = plans.find((p) => p.children.includes(seed.id));
486
+ }
487
+ if (!parentPlan) return undefined;
488
+ const sections = parentPlan.sections as { steps?: SubmittedStep[] };
489
+ const step = sections.steps?.[seed.plan_step_index];
490
+ return step?.plan_template;
491
+ }
492
+
493
+ // PLAN_SPEC.md:329-338 — submit-time check that step.plan_template references
494
+ // a template defined in plan_templates: in config.yaml. Returns null on success
495
+ // or a one-line error message pointing the author at the template config.
496
+ function validatePlanTemplateRefs(
497
+ steps: SubmittedStep[],
498
+ templates: Record<string, PlanTemplate>,
499
+ ): string | null {
500
+ for (let i = 0; i < steps.length; i++) {
501
+ const step = steps[i];
502
+ if (!step) continue;
503
+ const ref = step.plan_template;
504
+ if (!ref) continue;
505
+ if (!templates[ref]) {
506
+ const available = Object.keys(templates).join(", ");
507
+ const label = step.title ?? "untitled";
508
+ return `step ${i + 1} (${label}): plan_template '${ref}' is not defined. Available: ${available}. Add it under plan_templates: in .sprout/config.yaml.`;
509
+ }
510
+ }
511
+ return null;
512
+ }
513
+
514
+ async function readPlanInput(planFile: string): Promise<string> {
515
+ if (planFile === "-") {
516
+ return await Bun.stdin.text();
517
+ }
518
+ const file = Bun.file(planFile);
519
+ if (!(await file.exists())) {
520
+ throw new Error(`Plan file not found: ${planFile}`);
521
+ }
522
+ return await file.text();
523
+ }
524
+
525
+ interface SubmitOptions {
526
+ overwrite: boolean;
527
+ recordDecision: boolean;
528
+ domainOverride?: string;
529
+ nameOverride?: string;
530
+ jsonMode: boolean;
531
+ }
532
+
533
+ // Plan names are short human-readable labels. Empty/whitespace-only inputs are
534
+ // treated as "not provided" so the fall-through to seed title kicks in.
535
+ function normalizePlanName(value: unknown): string | undefined {
536
+ if (typeof value !== "string") return undefined;
537
+ const trimmed = value.trim();
538
+ return trimmed.length > 0 ? trimmed : undefined;
539
+ }
540
+
541
+ async function runSubmit(seedId: string, planFile: string, opts: SubmitOptions): Promise<void> {
542
+ const {
543
+ overwrite,
544
+ recordDecision: shouldRecordDecision,
545
+ domainOverride,
546
+ nameOverride,
547
+ jsonMode,
548
+ } = opts;
549
+ const dir = await findSproutDir();
550
+
551
+ const raw = await readPlanInput(planFile);
552
+ let parsed: unknown;
553
+ try {
554
+ parsed = JSON.parse(raw);
555
+ } catch (e) {
556
+ throw new Error(`Invalid JSON in plan file: ${(e as Error).message}`);
557
+ }
558
+
559
+ const templateName =
560
+ parsed &&
561
+ typeof parsed === "object" &&
562
+ typeof (parsed as { template?: unknown }).template === "string"
563
+ ? (parsed as { template: string }).template
564
+ : "feature";
565
+
566
+ const templates = await loadPlanTemplates(dir);
567
+ const template = templates[templateName];
568
+ if (!template) {
569
+ const available = Object.keys(templates).join(", ");
570
+ throw new Error(`Unknown template in plan: ${templateName}. Available: ${available}`);
571
+ }
572
+
573
+ const validate = compilePlanTemplate(template);
574
+ const result = validate(parsed);
575
+ if (!result.valid) {
576
+ // Partial-state diff JSON to stderr (PLAN_SPEC.md:180-195).
577
+ // stdout stays clean so callers can pipe it into a file.
578
+ process.stderr.write(`${JSON.stringify(result.diff, null, 2)}\n`);
579
+ process.exitCode = 1;
580
+ return;
581
+ }
582
+
583
+ const submitted = parsed as SubmittedPlan;
584
+ const refError = validatePlanTemplateRefs(submitted.sections.steps, templates);
585
+ if (refError) {
586
+ process.stderr.write(`${refError}\n`);
587
+ process.exitCode = 1;
588
+ return;
589
+ }
590
+ const config = await readConfig(dir);
591
+
592
+ // Name resolution priority (sprout-5640): --name flag > plan JSON `name` >
593
+ // seed.title (fresh submit) or existing plan.name (overwrite). The third
594
+ // fallback is decided inside the lock so we see the live seed/plan state.
595
+ const explicitName = normalizePlanName(nameOverride) ?? normalizePlanName(submitted.name);
596
+
597
+ let createdPlanId = "";
598
+ let childIds: string[] = [];
599
+ let revision = 1;
600
+ let obsoleteChildren: Issue[] = [];
601
+ let aborted = false;
602
+ // Captured inside the lock so the post-success outbound loam write has
603
+ // access without re-reading issues.jsonl.
604
+ let seedSnapshot: Issue | null = null;
605
+ // Captured inside the lock so the post-success Next-block can decide
606
+ // whether to suggest `sr plan review` (only when no reviewer yet and the
607
+ // plan is in a reviewable state).
608
+ let planStatus: PlanStatus = "approved";
609
+ let planReviewedBy: string | undefined;
610
+
611
+ // Combined lock: hold plans + issues while we read and write both.
612
+ // Order: outer lock = plans, inner = issues. Same across submit/validate
613
+ // to avoid deadlocks.
614
+ await withLock(plansPath(dir), async () => {
615
+ await withLock(issuesPath(dir), async () => {
616
+ const allIssues = await readIssues(dir);
617
+ const allPlans = await readPlans(dir);
618
+
619
+ const seedIdx = allIssues.findIndex((i) => i.id === seedId);
620
+ const seed = allIssues[seedIdx];
621
+ if (!seed) throw new Error(`Seed not found: ${seedId}`);
622
+ seedSnapshot = seed;
623
+
624
+ const existingPlan = allPlans.find((p) => p.seed === seedId && p.status !== "draft");
625
+ if (existingPlan && !overwrite) {
626
+ // PLAN_SPEC.md:374-391 — reject without --overwrite, exit non-zero
627
+ // with a multi-line stderr message.
628
+ process.stderr.write(
629
+ `✗ plan ${existingPlan.id} already exists for ${seedId} (status: ${existingPlan.status}, revision: ${existingPlan.revision})\n Use --overwrite to replace it.\n`,
630
+ );
631
+ process.exitCode = 1;
632
+ aborted = true;
633
+ return;
634
+ }
635
+
636
+ const steps = submitted.sections.steps;
637
+ const now = new Date().toISOString();
638
+
639
+ if (existingPlan && overwrite) {
640
+ const result = applyOverwrite({
641
+ existingPlan,
642
+ seed,
643
+ seedIdx,
644
+ allIssues,
645
+ allPlans,
646
+ steps,
647
+ projectName: config.project,
648
+ templateName,
649
+ newSections: submitted.sections as Record<string, unknown>,
650
+ name: explicitName ?? existingPlan.name ?? normalizePlanName(seed.title),
651
+ now,
652
+ });
653
+ await writeIssues(dir, allIssues);
654
+ await writePlans(dir, allPlans);
655
+ createdPlanId = existingPlan.id;
656
+ childIds = result.finalChildIds;
657
+ revision = result.revision;
658
+ obsoleteChildren = result.obsolete;
659
+ // Overwrite preserves status + reviewer from the prior plan row.
660
+ planStatus = existingPlan.status;
661
+ planReviewedBy = existingPlan.reviewedBy;
662
+ return;
663
+ }
664
+
665
+ // Fresh-submit path (no existing non-draft plan).
666
+ //
667
+ // Steps may carry `existing_seed: "<id>"` to adopt an already-open
668
+ // seed instead of spawning a fresh child (sprout-3c89 / pl-43ff).
669
+ // Adoption is link-only: status/title/type/priority/assignee/labels
670
+ // stay with the seed; we only set plan_id, plan_step_index, prepend
671
+ // the backref block, and wire blocks/blockedBy edges.
672
+ const adoptions = validateAdoptions({ steps, seedId, allIssues });
673
+
674
+ const issueIds = new Set(allIssues.map((i) => i.id));
675
+ const planIds = new Set(allPlans.map((p) => p.id));
676
+ const planId = generateId("pl", planIds);
677
+
678
+ const finalChildIds: string[] = [];
679
+ for (let i = 0; i < steps.length; i++) {
680
+ const adoption = adoptions.get(i);
681
+ if (adoption) {
682
+ finalChildIds.push(adoption.seedId);
683
+ continue;
684
+ }
685
+ const id = generateId(config.project, new Set([...issueIds, ...finalChildIds]));
686
+ finalChildIds.push(id);
687
+ }
688
+
689
+ // Build fresh issues; mutate adopted sprout in place. Edge wiring
690
+ // runs in a unified pass below so adopted + fresh edges go through
691
+ // the same pipeline.
692
+ const newIssues: Issue[] = [];
693
+ for (let i = 0; i < steps.length; i++) {
694
+ const step = steps[i];
695
+ if (!step) continue;
696
+ const childId = finalChildIds[i];
697
+ if (!childId) continue;
698
+ const adoption = adoptions.get(i);
699
+ if (adoption) {
700
+ const matched = allIssues[adoption.seedAllIdx];
701
+ if (!matched) continue;
702
+ // sprout-bac9 — additively merge step.labels into the adopted
703
+ // seed's existing labels (link-only path; user-added labels
704
+ // survive).
705
+ const mergedLabels = mergeAdoptedLabels(matched.labels, step.labels);
706
+ allIssues[adoption.seedAllIdx] = {
707
+ ...matched,
708
+ plan_id: planId,
709
+ plan_step_index: i,
710
+ description: applyPlanBackref(matched.description, {
711
+ stepIndex: i,
712
+ planId,
713
+ parentSeedId: seedId,
714
+ parentSeedTitle: seed.title,
715
+ templateName,
716
+ approach: submitted.sections.approach,
717
+ }),
718
+ ...(mergedLabels ? { labels: mergedLabels } : {}),
719
+ updatedAt: now,
720
+ };
721
+ continue;
722
+ }
723
+ const stepType = (step.type ?? "task") as Issue["type"];
724
+ // Non-adopting spawn path: validateStepTitleOrAdopt guarantees title
725
+ // is present here (else this step would carry existing_seed and the
726
+ // adoption branch above would have handled it).
727
+ if (!step.title) continue;
728
+ const issue: Issue = {
729
+ id: childId,
730
+ title: step.title,
731
+ status: "open",
732
+ type: stepType,
733
+ priority: step.priority ?? 2,
734
+ plan_step_index: i,
735
+ description: buildPlanBackref({
736
+ stepIndex: i,
737
+ planId,
738
+ parentSeedId: seedId,
739
+ parentSeedTitle: seed.title,
740
+ templateName,
741
+ approach: submitted.sections.approach,
742
+ }),
743
+ createdAt: now,
744
+ updatedAt: now,
745
+ };
746
+ // sprout-745e / pl-e5a8 step 2 — apply per-step labels to the
747
+ // freshly spawned child. Normalization mirrors `sr label add`
748
+ // (lowercase, trim, dedup); empty arrays after normalization
749
+ // are omitted so the on-disk Issue stays minimal.
750
+ if (step.labels && step.labels.length > 0) {
751
+ const normalized = Array.from(new Set(normalizeLabels(step.labels)));
752
+ if (normalized.length > 0) issue.labels = normalized;
753
+ }
754
+ // PLAN_SPEC.md:329-342 — when the parent step declares a
755
+ // plan_template, the child needs its own sub-plan first. Mark it
756
+ // requires_plan and leave plan_id unset so it does not back-link
757
+ // to the parent plan; the back-link is via children: [] on the
758
+ // parent plan row + plan_step_index on the child.
759
+ if (step.plan_template) {
760
+ issue.requires_plan = true;
761
+ } else {
762
+ issue.plan_id = planId;
763
+ }
764
+ newIssues.push(issue);
765
+ }
766
+
767
+ // Unified edge wiring: source/target may each be fresh or adopted.
768
+ // Order matters — forward step.blocks edges first, then parent-seed
769
+ // reverse edge, so a fresh child's `blocks` reads as
770
+ // [...stepTargets, parentSeed] (preserves the pre-adoption shape).
771
+ const updateChildField = (
772
+ stepIdx: number,
773
+ field: "blocks" | "blockedBy",
774
+ id: string,
775
+ ): void => {
776
+ const adoption = adoptions.get(stepIdx);
777
+ if (adoption) {
778
+ const m = allIssues[adoption.seedAllIdx];
779
+ if (!m) return;
780
+ const next = appendUnique(m[field], id);
781
+ if (next === m[field]) return;
782
+ allIssues[adoption.seedAllIdx] = { ...m, [field]: next, updatedAt: now };
783
+ return;
784
+ }
785
+ const childId = finalChildIds[stepIdx];
786
+ if (!childId) return;
787
+ const fresh = newIssues.find((n) => n.id === childId);
788
+ if (!fresh) return;
789
+ fresh[field] = appendUnique(fresh[field], id);
790
+ };
791
+
792
+ // PLAN_SPEC.md:248-257 — forward semantics: step i with blocks=[j]
793
+ // means "this step blocks step j". 1-based (sprout-185f).
794
+ for (let i = 0; i < steps.length; i++) {
795
+ const step = steps[i];
796
+ if (!step) continue;
797
+ const sourceId = finalChildIds[i];
798
+ if (!sourceId) continue;
799
+ for (const j of step.blocks ?? []) {
800
+ const targetId = finalChildIds[j - 1];
801
+ if (!targetId) continue;
802
+ updateChildField(i, "blocks", targetId);
803
+ updateChildField(j - 1, "blockedBy", sourceId);
804
+ }
805
+ }
806
+
807
+ // Each child blocks the parent seed.
808
+ for (let i = 0; i < steps.length; i++) {
809
+ updateChildField(i, "blocks", seedId);
810
+ }
811
+
812
+ // Parent seed picks up every child as a blocker. Dedupe so an
813
+ // adopted seed the parent already depended on doesn't double up.
814
+ const dedupedBlockedBy = [...(seed.blockedBy ?? [])];
815
+ for (const cid of finalChildIds) {
816
+ if (!dedupedBlockedBy.includes(cid)) dedupedBlockedBy.push(cid);
817
+ }
818
+ const updatedSeed: Issue = {
819
+ ...seed,
820
+ plan_id: planId,
821
+ blockedBy: dedupedBlockedBy,
822
+ updatedAt: now,
823
+ };
824
+ allIssues[seedIdx] = updatedSeed;
825
+
826
+ const resolvedName = explicitName ?? normalizePlanName(seed.title);
827
+ const plan: Plan = {
828
+ id: planId,
829
+ seed: seedId,
830
+ template: templateName,
831
+ status: "approved",
832
+ revision: 1,
833
+ sections: submitted.sections as Record<string, unknown>,
834
+ children: finalChildIds,
835
+ createdAt: now,
836
+ updatedAt: now,
837
+ };
838
+ if (resolvedName) plan.name = resolvedName;
839
+ // Track submit-time existing_seed adoptions so `sr plan show` can tag
840
+ // them (sprout-a3ab). Only persist when non-empty so plans that don't
841
+ // use adoption stay byte-identical to pre-feature output.
842
+ const submitAdopted = [...adoptions.values()].map((a) => a.seedId);
843
+ if (submitAdopted.length > 0) plan.adoptedChildren = submitAdopted;
844
+
845
+ await writeIssues(dir, [...allIssues, ...newIssues]);
846
+
847
+ const draftIdx = allPlans.findIndex((p) => p.seed === seedId && p.status === "draft");
848
+ if (draftIdx >= 0) {
849
+ allPlans[draftIdx] = plan;
850
+ await writePlans(dir, allPlans);
851
+ } else {
852
+ await appendPlan(dir, plan);
853
+ }
854
+
855
+ createdPlanId = planId;
856
+ childIds = finalChildIds;
857
+ });
858
+ });
859
+
860
+ if (aborted) return;
861
+
862
+ let recordedLoamId: string | null = null;
863
+ if (shouldRecordDecision && seedSnapshot) {
864
+ // PLAN_SPEC.md:354-356 — best-effort outbound write. Submit has already
865
+ // succeeded by this point; any failure here warns on stderr and leaves
866
+ // the plan + children intact.
867
+ recordedLoamId = await runOutboundDecision({
868
+ seed: seedSnapshot,
869
+ planId: createdPlanId,
870
+ approach: submitted.sections.approach,
871
+ domainOverride,
872
+ cwd: dir,
873
+ });
874
+ }
875
+
876
+ if (obsoleteChildren.length > 0) {
877
+ // PLAN_SPEC.md:388 — emit one suggestion line per obsolete child to
878
+ // stderr; never auto-close.
879
+ for (const o of obsoleteChildren) {
880
+ process.stderr.write(
881
+ `sr close ${o.id} --reason "obsoleted by plan ${createdPlanId} revision ${revision}"\n`,
882
+ );
883
+ }
884
+ }
885
+
886
+ if (jsonMode) {
887
+ await outputJson({
888
+ success: true,
889
+ command: "plan submit",
890
+ plan_id: createdPlanId,
891
+ children: childIds,
892
+ parent_seed: seedId,
893
+ revision,
894
+ obsolete: obsoleteChildren.map((o) => o.id),
895
+ overwritten: revision > 1,
896
+ });
897
+ return;
898
+ }
899
+
900
+ if (revision > 1) {
901
+ printSuccess(
902
+ `plan ${accent(createdPlanId)} overwritten (revision ${revision}, status: approved)`,
903
+ );
904
+ } else {
905
+ printSuccess(`plan ${accent(createdPlanId)} created (status: approved)`);
906
+ }
907
+ printSuccess(
908
+ `${childIds.length} child seed${childIds.length === 1 ? "" : "s"}: ${childIds
909
+ .map((id) => accent(id))
910
+ .join(", ")}`,
911
+ );
912
+ if (obsoleteChildren.length > 0) {
913
+ printSuccess(
914
+ `${obsoleteChildren.length} obsolete child seed${
915
+ obsoleteChildren.length === 1 ? "" : "s"
916
+ } flagged (see stderr for close suggestions)`,
917
+ );
918
+ }
919
+ printSuccess(`${accent(seedId)} now blocked by ${childIds.length} children`);
920
+ if (recordedLoamId) {
921
+ printSuccess(`recorded loam decision ${accent(recordedLoamId)}`);
922
+ }
923
+ writeNextHints({
924
+ planId: createdPlanId,
925
+ reviewable: !planReviewedBy && (planStatus === "approved" || planStatus === "active"),
926
+ });
927
+ }
928
+
929
+ // Next-block hints follow the convention used by the obsolete-children
930
+ // suggestions: stderr only, so JSON consumers (stdout) stay clean. The review
931
+ // hint is conditional — once a reviewer is on the plan, suggesting
932
+ // `sr plan review` again is just noise.
933
+ function writeNextHints(opts: { planId: string; reviewable: boolean }): void {
934
+ const lines: string[] = [
935
+ "",
936
+ "Next:",
937
+ ` sr plan show ${opts.planId} # review the plan as a unit`,
938
+ " sr ready # pick up the first child step",
939
+ ];
940
+ if (opts.reviewable) {
941
+ lines.push(` sr plan review ${opts.planId} --by <name> # record approval (optional)`);
942
+ }
943
+ process.stderr.write(`${lines.join("\n")}\n`);
944
+ }
945
+
946
+ interface AdoptionEntry {
947
+ seedId: string;
948
+ seedAllIdx: number;
949
+ }
950
+
951
+ interface AdoptionValidationArgs {
952
+ steps: SubmittedStep[];
953
+ seedId: string;
954
+ allIssues: Issue[];
955
+ // When set, sprout with plan_id === allowedCurrentPlanId pass the
956
+ // already-attached check. The overwrite path passes the live plan id so a
957
+ // step can reference a current plan-child by id (rename + reorder) without
958
+ // being mistaken for cross-plan poaching.
959
+ allowedCurrentPlanId?: string;
960
+ }
961
+
962
+ // Validate every step's existing_seed before any writes. Returns a
963
+ // step-index → adoption map for the fresh-submit pipeline. Throws on the first
964
+ // invalid candidate so the lock callback aborts cleanly.
965
+ function validateAdoptions(args: AdoptionValidationArgs): Map<number, AdoptionEntry> {
966
+ const { steps, seedId, allIssues, allowedCurrentPlanId } = args;
967
+ const out = new Map<number, AdoptionEntry>();
968
+ const seen = new Set<string>();
969
+ for (let i = 0; i < steps.length; i++) {
970
+ const step = steps[i];
971
+ if (!step?.existing_seed) continue;
972
+ const adoptId = step.existing_seed;
973
+ // Step titles are optional on adoption-only steps (sprout-5583), so the
974
+ // error label falls back to the adopted seed id when title is absent.
975
+ const label = step.title ? `step ${i + 1} (${step.title})` : `step ${i + 1} (adopt ${adoptId})`;
976
+ if (step.plan_template) {
977
+ throw new Error(
978
+ `${label}: existing_seed and plan_template are mutually exclusive — adoption replaces spawning, so a sub-plan template cannot apply.`,
979
+ );
980
+ }
981
+ if (adoptId === seedId) {
982
+ throw new Error(`${label}: cannot adopt the parent seed ${seedId} into its own plan.`);
983
+ }
984
+ if (seen.has(adoptId)) {
985
+ throw new Error(
986
+ `${label}: existing_seed ${adoptId} is already adopted by an earlier step in this plan.`,
987
+ );
988
+ }
989
+ const idx = allIssues.findIndex((iss) => iss.id === adoptId);
990
+ const seed = allIssues[idx];
991
+ if (!seed) {
992
+ throw new Error(`${label}: existing_seed ${adoptId} not found.`);
993
+ }
994
+ if (seed.status === "closed") {
995
+ throw new Error(
996
+ `${label}: existing_seed ${adoptId} is closed; only open or in-progress sprout can be adopted.`,
997
+ );
998
+ }
999
+ if (seed.plan_id && seed.plan_id !== allowedCurrentPlanId) {
1000
+ throw new Error(
1001
+ `${label}: existing_seed ${adoptId} is already attached to plan ${seed.plan_id}.`,
1002
+ );
1003
+ }
1004
+ // The mismatch warning only fires when the author supplied an explicit
1005
+ // step.title that disagrees with the adopted seed. Omitted titles
1006
+ // (synthesis-style submits) are not a mismatch.
1007
+ if (step.title && seed.title !== step.title) {
1008
+ process.stderr.write(
1009
+ `⚠ step ${i + 1}: existing_seed ${adoptId} title "${seed.title}" differs from step.title "${step.title}"; seed title is preserved.\n`,
1010
+ );
1011
+ }
1012
+ seen.add(adoptId);
1013
+ out.set(i, { seedId: adoptId, seedAllIdx: idx });
1014
+ }
1015
+ return out;
1016
+ }
1017
+
1018
+ // Append-unique helper used by the fresh-submit edge wiring. Returns the same
1019
+ // reference when no change is needed so callers can short-circuit writes.
1020
+ function appendUnique(list: string[] | undefined, id: string): string[] {
1021
+ const arr = list ?? [];
1022
+ if (arr.includes(id)) return arr;
1023
+ return [...arr, id];
1024
+ }
1025
+
1026
+ interface OverwriteArgs {
1027
+ existingPlan: Plan;
1028
+ seed: Issue;
1029
+ seedIdx: number;
1030
+ allIssues: Issue[];
1031
+ allPlans: Plan[];
1032
+ steps: SubmittedStep[];
1033
+ projectName: string;
1034
+ templateName: string;
1035
+ newSections: Record<string, unknown>;
1036
+ name?: string;
1037
+ now: string;
1038
+ }
1039
+
1040
+ interface OverwriteResult {
1041
+ finalChildIds: string[];
1042
+ revision: number;
1043
+ obsolete: Issue[];
1044
+ }
1045
+
1046
+ // applyOverwrite mutates allIssues + allPlans in place. The caller is expected
1047
+ // to have already acquired the plans + issues locks.
1048
+ function applyOverwrite(args: OverwriteArgs): OverwriteResult {
1049
+ const {
1050
+ existingPlan,
1051
+ seed,
1052
+ seedIdx,
1053
+ allIssues,
1054
+ allPlans,
1055
+ steps,
1056
+ projectName,
1057
+ templateName,
1058
+ newSections,
1059
+ name,
1060
+ now,
1061
+ } = args;
1062
+
1063
+ // Validate any existing_seed adoptions before mutating state. Sprout
1064
+ // already attached to *this* plan are allowed; that's how the overwrite
1065
+ // path lets a step pin to a current plan-child by id (rename, reorder)
1066
+ // instead of relying on title matching alone. (sprout-99ae / pl-43ff step 3)
1067
+ const adoptions = validateAdoptions({
1068
+ steps,
1069
+ seedId: seed.id,
1070
+ allIssues,
1071
+ allowedCurrentPlanId: existingPlan.id,
1072
+ });
1073
+
1074
+ // Match existing children to new steps. Precedence:
1075
+ // 1. step.existing_seed id — current plan-child or external adoption.
1076
+ // 2. step.title against unmatched current plan-children (legacy path).
1077
+ // 3. Spawn a fresh child.
1078
+ // (PLAN_SPEC.md:387-388)
1079
+ const oldChildIssues: Issue[] = [];
1080
+ for (const cid of existingPlan.children) {
1081
+ const c = allIssues.find((i) => i.id === cid);
1082
+ if (c) oldChildIssues.push(c);
1083
+ }
1084
+ const oldChildIdSet = new Set(oldChildIssues.map((c) => c.id));
1085
+ const usedOldIds = new Set<string>();
1086
+ const adoptedExternalIds = new Set<string>();
1087
+ const finalChildIds: string[] = [];
1088
+ const newSpawnedIds: string[] = [];
1089
+ const issueIds = new Set(allIssues.map((i) => i.id));
1090
+
1091
+ for (let i = 0; i < steps.length; i++) {
1092
+ const step = steps[i];
1093
+ if (!step) continue;
1094
+ const adoption = adoptions.get(i);
1095
+ if (adoption) {
1096
+ finalChildIds.push(adoption.seedId);
1097
+ if (oldChildIdSet.has(adoption.seedId)) {
1098
+ usedOldIds.add(adoption.seedId);
1099
+ } else {
1100
+ adoptedExternalIds.add(adoption.seedId);
1101
+ }
1102
+ continue;
1103
+ }
1104
+ const match = oldChildIssues.find((c) => !usedOldIds.has(c.id) && c.title === step.title);
1105
+ if (match) {
1106
+ usedOldIds.add(match.id);
1107
+ finalChildIds.push(match.id);
1108
+ } else {
1109
+ const taken = new Set([...issueIds, ...newSpawnedIds, ...finalChildIds]);
1110
+ const id = generateId(projectName, taken);
1111
+ newSpawnedIds.push(id);
1112
+ finalChildIds.push(id);
1113
+ }
1114
+ }
1115
+
1116
+ // Build issues for newly spawned children. Existing matched children keep
1117
+ // their fields (assignee, labels, status, etc.) but their backref block is
1118
+ // refreshed in place so the snippet stays in sync with the live plan
1119
+ // (sprout-76af). External adoptions get linked into the plan (plan_id,
1120
+ // plan_step_index, backref) without touching other fields; the parent-
1121
+ // blocks edge is added in the unified wiring pass below.
1122
+ const approach = (newSections as { approach?: unknown }).approach;
1123
+ const newIssues: Issue[] = [];
1124
+ for (let i = 0; i < steps.length; i++) {
1125
+ const step = steps[i];
1126
+ if (!step) continue;
1127
+ const childId = finalChildIds[i];
1128
+ if (!childId) continue;
1129
+ if (usedOldIds.has(childId)) {
1130
+ const matchedIdx = allIssues.findIndex((iss) => iss.id === childId);
1131
+ const matched = allIssues[matchedIdx];
1132
+ if (matched) {
1133
+ // sprout-bac9 — overwrite/revision path: additively merge
1134
+ // step.labels into the matched child's labels. Never strips
1135
+ // labels — manual edits and previously merged labels survive.
1136
+ const mergedLabels = mergeAdoptedLabels(matched.labels, step.labels);
1137
+ allIssues[matchedIdx] = {
1138
+ ...matched,
1139
+ description: applyPlanBackref(matched.description, {
1140
+ stepIndex: i,
1141
+ planId: existingPlan.id,
1142
+ parentSeedId: seed.id,
1143
+ parentSeedTitle: seed.title,
1144
+ templateName,
1145
+ approach,
1146
+ }),
1147
+ ...(mergedLabels ? { labels: mergedLabels } : {}),
1148
+ updatedAt: now,
1149
+ };
1150
+ }
1151
+ continue;
1152
+ }
1153
+ if (adoptedExternalIds.has(childId)) {
1154
+ const matchedIdx = allIssues.findIndex((iss) => iss.id === childId);
1155
+ const matched = allIssues[matchedIdx];
1156
+ if (matched) {
1157
+ // sprout-bac9 — overwrite path external adoption: merge
1158
+ // step.labels into the newly linked seed's existing labels.
1159
+ const mergedLabels = mergeAdoptedLabels(matched.labels, step.labels);
1160
+ allIssues[matchedIdx] = {
1161
+ ...matched,
1162
+ plan_id: existingPlan.id,
1163
+ plan_step_index: i,
1164
+ description: applyPlanBackref(matched.description, {
1165
+ stepIndex: i,
1166
+ planId: existingPlan.id,
1167
+ parentSeedId: seed.id,
1168
+ parentSeedTitle: seed.title,
1169
+ templateName,
1170
+ approach,
1171
+ }),
1172
+ ...(mergedLabels ? { labels: mergedLabels } : {}),
1173
+ updatedAt: now,
1174
+ };
1175
+ }
1176
+ continue;
1177
+ }
1178
+ const stepType = (step.type ?? "task") as Issue["type"];
1179
+ // Non-adopting spawn path: validateStepTitleOrAdopt guarantees title.
1180
+ if (!step.title) continue;
1181
+ const issue: Issue = {
1182
+ id: childId,
1183
+ title: step.title,
1184
+ status: "open",
1185
+ type: stepType,
1186
+ priority: step.priority ?? 2,
1187
+ plan_step_index: i,
1188
+ description: buildPlanBackref({
1189
+ stepIndex: i,
1190
+ planId: existingPlan.id,
1191
+ parentSeedId: seed.id,
1192
+ parentSeedTitle: seed.title,
1193
+ templateName,
1194
+ approach,
1195
+ }),
1196
+ createdAt: now,
1197
+ updatedAt: now,
1198
+ };
1199
+ if (step.plan_template) {
1200
+ issue.requires_plan = true;
1201
+ } else {
1202
+ issue.plan_id = existingPlan.id;
1203
+ }
1204
+ // sprout-745e / pl-e5a8 step 2 — apply per-step labels to the freshly
1205
+ // spawned child on the overwrite/revision path. Same normalization as
1206
+ // the initial-submit branch above.
1207
+ if (step.labels && step.labels.length > 0) {
1208
+ const normalized = Array.from(new Set(normalizeLabels(step.labels)));
1209
+ if (normalized.length > 0) issue.labels = normalized;
1210
+ }
1211
+ // PLAN_SPEC.md:248-257 — forward semantics: step.blocks=[j] means
1212
+ // this step blocks step j. Both directions are wired below in a
1213
+ // unified pass that handles new and matched children alike.
1214
+ issue.blocks = [seed.id];
1215
+ newIssues.push(issue);
1216
+ }
1217
+
1218
+ // Wire step.blocks edges in both directions:
1219
+ // source.blocks gains targetId
1220
+ // target.blockedBy gains sourceId
1221
+ // Source and target may each be freshly spawned (in newIssues) or matched
1222
+ // (in allIssues). Dedupe so edges already present from the prior revision
1223
+ // don't compound; we don't strip stale edges (full reconciliation is out
1224
+ // of scope).
1225
+ const addToList = (
1226
+ list: string[] | undefined,
1227
+ id: string,
1228
+ ): { list: string[]; changed: boolean } => {
1229
+ const arr = list ?? [];
1230
+ if (arr.includes(id)) return { list: arr, changed: false };
1231
+ return { list: [...arr, id], changed: true };
1232
+ };
1233
+ const updateMatched = (
1234
+ targetId: string,
1235
+ field: "blocks" | "blockedBy",
1236
+ valueId: string,
1237
+ ): boolean => {
1238
+ const idx = allIssues.findIndex((iss) => iss.id === targetId);
1239
+ if (idx < 0) return false;
1240
+ const matched = allIssues[idx];
1241
+ if (!matched) return false;
1242
+ const result = addToList(matched[field], valueId);
1243
+ if (!result.changed) return false;
1244
+ allIssues[idx] = { ...matched, [field]: result.list, updatedAt: now };
1245
+ return true;
1246
+ };
1247
+ for (let i = 0; i < steps.length; i++) {
1248
+ const step = steps[i];
1249
+ if (!step) continue;
1250
+ const sourceId = finalChildIds[i];
1251
+ if (!sourceId) continue;
1252
+ // step.blocks is 1-based (sprout-185f); translate to 0-based.
1253
+ for (const j of step.blocks ?? []) {
1254
+ const targetId = finalChildIds[j - 1];
1255
+ if (!targetId) continue;
1256
+ // Forward edge on source.
1257
+ const newSource = newIssues.find((ni) => ni.id === sourceId);
1258
+ if (newSource) {
1259
+ const r = addToList(newSource.blocks, targetId);
1260
+ if (r.changed) newSource.blocks = r.list;
1261
+ } else {
1262
+ updateMatched(sourceId, "blocks", targetId);
1263
+ }
1264
+ // Reverse edge on target.
1265
+ const newTarget = newIssues.find((ni) => ni.id === targetId);
1266
+ if (newTarget) {
1267
+ const r = addToList(newTarget.blockedBy, sourceId);
1268
+ if (r.changed) newTarget.blockedBy = r.list;
1269
+ } else {
1270
+ updateMatched(targetId, "blockedBy", sourceId);
1271
+ }
1272
+ }
1273
+ }
1274
+
1275
+ // External adoptions need the parent-blocks edge added (matched old
1276
+ // children already have it; fresh children get it inline above).
1277
+ for (const childId of adoptedExternalIds) {
1278
+ updateMatched(childId, "blocks", seed.id);
1279
+ }
1280
+
1281
+ // Obsolete children = old plan children with no matching step in new plan.
1282
+ const obsolete: Issue[] = oldChildIssues.filter((c) => !usedOldIds.has(c.id));
1283
+
1284
+ // Parent seed: drop obsolete from blockedBy, ensure all current plan
1285
+ // children are present. Preserve unrelated blockers; dedupe against
1286
+ // finalChildIds so an externally-adopted seed the parent already depended
1287
+ // on doesn't double up.
1288
+ const oldChildSet = new Set(existingPlan.children);
1289
+ const finalChildSet = new Set(finalChildIds);
1290
+ const externalBlockers = (seed.blockedBy ?? []).filter(
1291
+ (b) => !oldChildSet.has(b) && !finalChildSet.has(b),
1292
+ );
1293
+ const updatedSeed: Issue = {
1294
+ ...seed,
1295
+ plan_id: existingPlan.id,
1296
+ blockedBy: [...externalBlockers, ...finalChildIds],
1297
+ updatedAt: now,
1298
+ };
1299
+ allIssues[seedIdx] = updatedSeed;
1300
+
1301
+ // Update the plan row in place — single mutation per overwrite.
1302
+ const planIdx = allPlans.findIndex((p) => p.id === existingPlan.id);
1303
+ // Preserve prior adoptions for children that survive the overwrite, then
1304
+ // add the freshly-adopted external sprout from this rewrite. sprout-a3ab.
1305
+ const finalChildIdSet = new Set(finalChildIds);
1306
+ const survivingAdopted = (existingPlan.adoptedChildren ?? []).filter((id) =>
1307
+ finalChildIdSet.has(id),
1308
+ );
1309
+ const mergedAdopted: string[] = [...survivingAdopted];
1310
+ for (const id of adoptedExternalIds) {
1311
+ if (!mergedAdopted.includes(id)) mergedAdopted.push(id);
1312
+ }
1313
+ const updatedPlan: Plan = {
1314
+ ...existingPlan,
1315
+ template: templateName,
1316
+ sections: newSections,
1317
+ children: finalChildIds,
1318
+ revision: existingPlan.revision + 1,
1319
+ updatedAt: now,
1320
+ };
1321
+ if (mergedAdopted.length > 0) {
1322
+ updatedPlan.adoptedChildren = mergedAdopted;
1323
+ } else {
1324
+ delete updatedPlan.adoptedChildren;
1325
+ }
1326
+ if (name) updatedPlan.name = name;
1327
+ if (planIdx >= 0) allPlans[planIdx] = updatedPlan;
1328
+
1329
+ const allIssuesWithNew = [...allIssues, ...newIssues];
1330
+ // allIssues is mutated; replace its contents to reflect appended new children
1331
+ // so the caller's writeIssues snapshot is consistent.
1332
+ allIssues.length = 0;
1333
+ allIssues.push(...allIssuesWithNew);
1334
+
1335
+ // Caller already holds locks; do the persistence here so the row mutation
1336
+ // of plans.jsonl shows up as a single replaced line in git diff.
1337
+ return { finalChildIds, revision: updatedPlan.revision, obsolete };
1338
+ }
1339
+
1340
+ interface ListFilters {
1341
+ seed?: string;
1342
+ status?: string;
1343
+ outcome?: string;
1344
+ template?: string;
1345
+ }
1346
+
1347
+ const VALID_PLAN_STATUSES = new Set(["draft", "approved", "active", "done"]);
1348
+ const VALID_PLAN_OUTCOMES = new Set(["success", "partial", "failure"]);
1349
+
1350
+ async function runList(filters: ListFilters, jsonMode: boolean): Promise<void> {
1351
+ if (filters.status && !VALID_PLAN_STATUSES.has(filters.status)) {
1352
+ throw new Error(
1353
+ `--status must be one of: ${[...VALID_PLAN_STATUSES].join(", ")} (got: ${filters.status})`,
1354
+ );
1355
+ }
1356
+ if (filters.outcome && !VALID_PLAN_OUTCOMES.has(filters.outcome)) {
1357
+ throw new Error(
1358
+ `--outcome must be one of: ${[...VALID_PLAN_OUTCOMES].join(", ")} (got: ${filters.outcome})`,
1359
+ );
1360
+ }
1361
+
1362
+ const dir = await findSproutDir();
1363
+ const plans = await readPlans(dir);
1364
+ const filtered = plans
1365
+ .filter((p) => (filters.seed ? p.seed === filters.seed : true))
1366
+ .filter((p) => (filters.status ? p.status === filters.status : true))
1367
+ .filter((p) => (filters.outcome ? p.outcome === filters.outcome : true))
1368
+ .filter((p) => (filters.template ? p.template === filters.template : true))
1369
+ .sort((a, b) => (a.createdAt < b.createdAt ? 1 : a.createdAt > b.createdAt ? -1 : 0));
1370
+
1371
+ if (jsonMode) {
1372
+ await outputJson({
1373
+ success: true,
1374
+ command: "plan list",
1375
+ plans: filtered,
1376
+ count: filtered.length,
1377
+ });
1378
+ return;
1379
+ }
1380
+
1381
+ if (filtered.length === 0) {
1382
+ console.log(muted("No plans match."));
1383
+ return;
1384
+ }
1385
+ const nameWidth = 40;
1386
+ for (const p of filtered) {
1387
+ const outcome = p.outcome ? muted(` (${p.outcome})`) : "";
1388
+ const namePart = p.name
1389
+ ? ` ${truncateName(p.name, nameWidth)}`
1390
+ : ` ${muted("(unnamed)".padEnd(nameWidth))}`;
1391
+ console.log(
1392
+ `${accent.bold(p.id)} ${muted(p.status)} rev ${p.revision}${namePart} ${muted(p.template)} ${muted(`seed=${p.seed}`)} ${muted(`children=${p.children.length}`)}${outcome} ${muted(p.createdAt)}`,
1393
+ );
1394
+ }
1395
+ }
1396
+
1397
+ function truncateName(value: string, width: number): string {
1398
+ if (value.length <= width) return value.padEnd(width);
1399
+ return `${value.slice(0, Math.max(0, width - 1))}…`;
1400
+ }
1401
+
1402
+ const VALID_OUTCOMES = new Set(["success", "partial", "failure"]);
1403
+
1404
+ async function runOutcome(
1405
+ idArg: string,
1406
+ result: string,
1407
+ note: string | undefined,
1408
+ jsonMode: boolean,
1409
+ ): Promise<void> {
1410
+ if (!VALID_OUTCOMES.has(result)) {
1411
+ throw new Error(`--result must be one of: ${[...VALID_OUTCOMES].join(", ")} (got: ${result})`);
1412
+ }
1413
+ const dir = await findSproutDir();
1414
+ const planId = await resolvePlanIdArg(idArg, dir);
1415
+ let updatedPlan: Plan | null = null;
1416
+ let openChildren = 0;
1417
+ await withLock(plansPath(dir), async () => {
1418
+ await withLock(issuesPath(dir), async () => {
1419
+ const plans = await readPlans(dir);
1420
+ const idx = plans.findIndex((p) => p.id === planId);
1421
+ const plan = plans[idx];
1422
+ if (!plan) {
1423
+ throw new Error(`Plan not found: ${planId}. Run 'sr plan list' to see available plans.`);
1424
+ }
1425
+ const issues = await readIssues(dir);
1426
+ openChildren = plan.children.filter((cid) => {
1427
+ const issue = issues.find((i) => i.id === cid);
1428
+ return issue && issue.status !== "closed";
1429
+ }).length;
1430
+ const next: Plan = {
1431
+ ...plan,
1432
+ outcome: result as Plan["outcome"],
1433
+ updatedAt: new Date().toISOString(),
1434
+ };
1435
+ if (note !== undefined) next.outcomeNote = note;
1436
+ plans[idx] = next;
1437
+ await writePlans(dir, plans);
1438
+ updatedPlan = next;
1439
+ });
1440
+ });
1441
+
1442
+ if (!updatedPlan) return; // unreachable; throw above
1443
+ const finalPlan: Plan = updatedPlan;
1444
+
1445
+ // PLAN_SPEC.md:431 — open children → warning, not error.
1446
+ if (openChildren > 0) {
1447
+ process.stderr.write(
1448
+ `⚠ plan ${finalPlan.id} has ${openChildren} open child${openChildren === 1 ? "" : "ren"}\n`,
1449
+ );
1450
+ }
1451
+
1452
+ if (jsonMode) {
1453
+ await outputJson({
1454
+ success: true,
1455
+ command: "plan outcome",
1456
+ plan_id: finalPlan.id,
1457
+ outcome: finalPlan.outcome,
1458
+ outcomeNote: finalPlan.outcomeNote,
1459
+ open_children: openChildren,
1460
+ });
1461
+ return;
1462
+ }
1463
+ const noteSuffix = finalPlan.outcomeNote ? ` — ${finalPlan.outcomeNote}` : "";
1464
+ printSuccess(`plan ${accent(finalPlan.id)} outcome recorded: ${finalPlan.outcome}${noteSuffix}`);
1465
+ }
1466
+
1467
+ interface CreateOptions {
1468
+ name?: string;
1469
+ template?: string;
1470
+ jsonMode: boolean;
1471
+ }
1472
+
1473
+ // sr plan create <seed-id> (sprout-3dd1). First-class adopt-only plan: writes a
1474
+ // plan row with zero spawned children and an empty steps blueprint, intended to
1475
+ // be populated via `sr plan adopt`. This removes the placeholder-step dance
1476
+ // (submit 2 throwaway steps → release → close) the release-train use case
1477
+ // previously required. Link contract mirrors submit's fresh path: the parent
1478
+ // seed's plan_id is set so `sr plan show <seed>`/adopt resolve seed → plan.
1479
+ // No children means no blockedBy edges yet — those land as sprout are adopted.
1480
+ // Lock order matches submit/adopt: outer plans, inner issues (mx-f29e43).
1481
+ async function runCreate(seedId: string, opts: CreateOptions): Promise<void> {
1482
+ const dir = await findSproutDir();
1483
+ const templates = await loadPlanTemplates(dir);
1484
+ const explicitName = normalizePlanName(opts.name);
1485
+
1486
+ let createdPlanId = "";
1487
+ let templateName = "";
1488
+ let aborted = false;
1489
+
1490
+ await withLock(plansPath(dir), async () => {
1491
+ await withLock(issuesPath(dir), async () => {
1492
+ const allIssues = await readIssues(dir);
1493
+ const allPlans = await readPlans(dir);
1494
+
1495
+ const seedIdx = allIssues.findIndex((i) => i.id === seedId);
1496
+ const seed = allIssues[seedIdx];
1497
+ if (!seed) throw new Error(`Seed not found: ${seedId}`);
1498
+
1499
+ templateName = opts.template ?? defaultTemplateForType(seed.type);
1500
+ if (!templates[templateName]) {
1501
+ const available = Object.keys(templates).join(", ");
1502
+ throw new Error(`Unknown template: ${templateName}. Available: ${available}`);
1503
+ }
1504
+
1505
+ const existingPlan = allPlans.find((p) => p.seed === seedId && p.status !== "draft");
1506
+ if (existingPlan) {
1507
+ process.stderr.write(
1508
+ `✗ plan ${existingPlan.id} already exists for ${seedId} (status: ${existingPlan.status}, revision: ${existingPlan.revision})\n Adopt sprout into it with 'sr plan adopt ${existingPlan.id} <seed-ids...>'.\n`,
1509
+ );
1510
+ process.exitCode = 1;
1511
+ aborted = true;
1512
+ return;
1513
+ }
1514
+
1515
+ const planIds = new Set(allPlans.map((p) => p.id));
1516
+ const planId = generateId("pl", planIds);
1517
+ const now = new Date().toISOString();
1518
+ const resolvedName = explicitName ?? normalizePlanName(seed.title);
1519
+
1520
+ const plan: Plan = {
1521
+ id: planId,
1522
+ seed: seedId,
1523
+ template: templateName,
1524
+ status: "approved",
1525
+ revision: 1,
1526
+ sections: { steps: [] },
1527
+ children: [],
1528
+ createdAt: now,
1529
+ updatedAt: now,
1530
+ };
1531
+ if (resolvedName) plan.name = resolvedName;
1532
+
1533
+ allIssues[seedIdx] = { ...seed, plan_id: planId, updatedAt: now };
1534
+ await writeIssues(dir, allIssues);
1535
+
1536
+ const draftIdx = allPlans.findIndex((p) => p.seed === seedId && p.status === "draft");
1537
+ if (draftIdx >= 0) {
1538
+ allPlans[draftIdx] = plan;
1539
+ await writePlans(dir, allPlans);
1540
+ } else {
1541
+ await appendPlan(dir, plan);
1542
+ }
1543
+
1544
+ createdPlanId = planId;
1545
+ });
1546
+ });
1547
+
1548
+ if (aborted) return;
1549
+
1550
+ if (opts.jsonMode) {
1551
+ await outputJson({
1552
+ success: true,
1553
+ command: "plan create",
1554
+ plan_id: createdPlanId,
1555
+ parent_seed: seedId,
1556
+ template: templateName,
1557
+ children: [],
1558
+ });
1559
+ return;
1560
+ }
1561
+ printSuccess(`plan ${accent(createdPlanId)} created (status: approved, adopt-only)`);
1562
+ process.stderr.write(
1563
+ `\nNext:\n sr plan adopt ${createdPlanId} <seed-ids...> # populate children in order\n sr plan reorder ${createdPlanId} <seed-ids...> # set the exact children order\n`,
1564
+ );
1565
+ }
1566
+
1567
+ interface AdoptOptions {
1568
+ step?: string;
1569
+ at?: string;
1570
+ before?: string;
1571
+ after?: string;
1572
+ jsonMode: boolean;
1573
+ }
1574
+
1575
+ // Positioning for `sr plan adopt`: at most one of --at/--before/--after may be
1576
+ // given. --at is a 1-based slot in the resulting children array; --before /
1577
+ // --after anchor on an existing child id. Returns a discriminated spec the
1578
+ // in-lock resolver translates into a 0-based insertion index once the live
1579
+ // children array is known. Default (none given) appends.
1580
+ type AdoptPosition =
1581
+ | { kind: "append" }
1582
+ | { kind: "at"; index: number }
1583
+ | { kind: "before"; anchor: string }
1584
+ | { kind: "after"; anchor: string };
1585
+
1586
+ function parseAdoptPosition(opts: AdoptOptions): AdoptPosition {
1587
+ const provided = [
1588
+ opts.at !== undefined ? "--at" : null,
1589
+ opts.before !== undefined ? "--before" : null,
1590
+ opts.after !== undefined ? "--after" : null,
1591
+ ].filter((x): x is string => x !== null);
1592
+ if (provided.length > 1) {
1593
+ throw new Error(
1594
+ `--at, --before, and --after are mutually exclusive (got: ${provided.join(", ")}).`,
1595
+ );
1596
+ }
1597
+ if (opts.at !== undefined) {
1598
+ const n = Number.parseInt(opts.at, 10);
1599
+ if (!Number.isInteger(n) || String(n) !== opts.at.trim() || n < 1) {
1600
+ throw new Error(`--at must be a positive integer (got: ${opts.at}).`);
1601
+ }
1602
+ return { kind: "at", index: n - 1 };
1603
+ }
1604
+ if (opts.before !== undefined) {
1605
+ if (opts.before.trim().length === 0) throw new Error("--before requires a seed id.");
1606
+ return { kind: "before", anchor: opts.before };
1607
+ }
1608
+ if (opts.after !== undefined) {
1609
+ if (opts.after.trim().length === 0) throw new Error("--after requires a seed id.");
1610
+ return { kind: "after", anchor: opts.after };
1611
+ }
1612
+ return { kind: "append" };
1613
+ }
1614
+
1615
+ // Translate an AdoptPosition into a 0-based insertion index against the live
1616
+ // children array. Throws (aborting before writes) when --at is out of range or
1617
+ // a --before/--after anchor is not a current child.
1618
+ function resolveInsertIndex(position: AdoptPosition, children: string[], planId: string): number {
1619
+ switch (position.kind) {
1620
+ case "append":
1621
+ return children.length;
1622
+ case "at":
1623
+ if (position.index > children.length) {
1624
+ throw new Error(
1625
+ `--at ${position.index + 1} is out of range (plan ${planId} has ${children.length} child${children.length === 1 ? "" : "ren"}; valid range 1..${children.length + 1}).`,
1626
+ );
1627
+ }
1628
+ return position.index;
1629
+ case "before": {
1630
+ const idx = children.indexOf(position.anchor);
1631
+ if (idx < 0) {
1632
+ throw new Error(`--before ${position.anchor} is not a child of plan ${planId}.`);
1633
+ }
1634
+ return idx;
1635
+ }
1636
+ case "after": {
1637
+ const idx = children.indexOf(position.anchor);
1638
+ if (idx < 0) {
1639
+ throw new Error(`--after ${position.anchor} is not a child of plan ${planId}.`);
1640
+ }
1641
+ return idx + 1;
1642
+ }
1643
+ }
1644
+ }
1645
+
1646
+ // sr plan adopt <plan-id> <seed-ids...> [--step <i>] (sprout-2b93 / pl-43ff step 4).
1647
+ // Post-submit adoption: link existing open sprout into an active plan without
1648
+ // spawning fresh children. Adoption is link-only — status, type, priority,
1649
+ // assignee, labels stay with the seed; we only set plan_id (+ optionally
1650
+ // plan_step_index when --step is given), prepend the sprout:plan-backref block,
1651
+ // wire the seed.blocks/parent.blockedBy edges, append to plan.children, and
1652
+ // bump plan.revision. Validation runs in a single pre-write pass so an invalid
1653
+ // candidate leaves issues + plans untouched. Lock order matches submit:
1654
+ // outer plans, inner issues (mx-f29e43).
1655
+ async function runAdopt(planIdArg: string, seedIds: string[], opts: AdoptOptions): Promise<void> {
1656
+ const dir = await findSproutDir();
1657
+ const planId = await resolvePlanIdArg(planIdArg, dir);
1658
+
1659
+ if (seedIds.length === 0) {
1660
+ throw new Error("At least one seed id is required.");
1661
+ }
1662
+ const dupes = findDuplicates(seedIds);
1663
+ if (dupes.length > 0) {
1664
+ throw new Error(
1665
+ `Duplicate seed id${dupes.length === 1 ? "" : "s"} in args: ${dupes.join(", ")}.`,
1666
+ );
1667
+ }
1668
+
1669
+ const stepIndex = parseStepFlag(opts.step);
1670
+ const position = parseAdoptPosition(opts);
1671
+
1672
+ let finalPlan: Plan | null = null;
1673
+ let adoptedIds: string[] = [];
1674
+
1675
+ await withLock(plansPath(dir), async () => {
1676
+ await withLock(issuesPath(dir), async () => {
1677
+ const allIssues = await readIssues(dir);
1678
+ const allPlans = await readPlans(dir);
1679
+
1680
+ const planIdx = allPlans.findIndex((p) => p.id === planId);
1681
+ const plan = allPlans[planIdx];
1682
+ if (!plan) {
1683
+ throw new Error(`Plan not found: ${planId}. Run 'sr plan list' to see available plans.`);
1684
+ }
1685
+
1686
+ // --step (when given) must be in-range against the blueprint, so a
1687
+ // typo at the CLI is caught instead of silently writing a dangling
1688
+ // plan_step_index.
1689
+ if (stepIndex !== undefined) {
1690
+ const blueprintSteps = countBlueprintSteps(plan);
1691
+ if (stepIndex < 0 || stepIndex >= blueprintSteps) {
1692
+ throw new Error(
1693
+ `--step ${stepIndex + 1} is out of range (plan ${planId} has ${blueprintSteps} step${blueprintSteps === 1 ? "" : "s"}).`,
1694
+ );
1695
+ }
1696
+ }
1697
+
1698
+ const parentIdx = allIssues.findIndex((i) => i.id === plan.seed);
1699
+ const parentSeed = allIssues[parentIdx];
1700
+ if (!parentSeed) {
1701
+ throw new Error(
1702
+ `Plan ${planId} references parent seed ${plan.seed} which no longer exists.`,
1703
+ );
1704
+ }
1705
+
1706
+ // Resolve every candidate first; any failure aborts before writes.
1707
+ interface Resolved {
1708
+ seedId: string;
1709
+ idx: number;
1710
+ }
1711
+ const resolved: Resolved[] = [];
1712
+ for (const seedId of seedIds) {
1713
+ if (seedId === plan.seed) {
1714
+ throw new Error(`cannot adopt the parent seed ${seedId} into its own plan ${planId}.`);
1715
+ }
1716
+ const idx = allIssues.findIndex((i) => i.id === seedId);
1717
+ const seed = allIssues[idx];
1718
+ if (!seed) {
1719
+ throw new Error(`seed ${seedId} not found.`);
1720
+ }
1721
+ if (seed.status === "closed") {
1722
+ throw new Error(
1723
+ `seed ${seedId} is closed; only open or in-progress sprout can be adopted.`,
1724
+ );
1725
+ }
1726
+ if (seed.plan_id) {
1727
+ throw new Error(
1728
+ `seed ${seedId} is already attached to plan ${seed.plan_id}; release it first.`,
1729
+ );
1730
+ }
1731
+ resolved.push({ seedId, idx });
1732
+ }
1733
+
1734
+ const now = new Date().toISOString();
1735
+ const templateName = plan.template;
1736
+ const approach = (plan.sections as { approach?: unknown }).approach;
1737
+
1738
+ // When the operator pins these adoptions to a specific blueprint
1739
+ // step (--step <i>), pick up any labels declared on that step so
1740
+ // post-submit adoption ends up label-equivalent to submit-time
1741
+ // adoption (sprout-bac9 / pl-e5a8 step 3). No --step ⇒ no step ⇒
1742
+ // no labels to merge.
1743
+ let stepLabels: string[] | undefined;
1744
+ if (stepIndex !== undefined) {
1745
+ const blueprintSteps = (plan.sections as { steps?: SubmittedStep[] }).steps;
1746
+ stepLabels = blueprintSteps?.[stepIndex]?.labels;
1747
+ }
1748
+
1749
+ // Apply all link mutations under the lock.
1750
+ for (const { idx } of resolved) {
1751
+ const seed = allIssues[idx];
1752
+ if (!seed) continue;
1753
+ const mergedLabels = mergeAdoptedLabels(seed.labels, stepLabels);
1754
+ const updated: Issue = {
1755
+ ...seed,
1756
+ plan_id: planId,
1757
+ description: applyPlanBackref(seed.description, {
1758
+ stepIndex,
1759
+ planId,
1760
+ parentSeedId: parentSeed.id,
1761
+ parentSeedTitle: parentSeed.title,
1762
+ templateName,
1763
+ approach,
1764
+ }),
1765
+ blocks: appendUnique(seed.blocks, parentSeed.id),
1766
+ updatedAt: now,
1767
+ };
1768
+ if (stepIndex !== undefined) {
1769
+ updated.plan_step_index = stepIndex;
1770
+ }
1771
+ if (mergedLabels) updated.labels = mergedLabels;
1772
+ allIssues[idx] = updated;
1773
+ }
1774
+
1775
+ // Parent seed: blockedBy gains each adopted child (deduped). The
1776
+ // parent's plan_id is already set on submit; we don't touch it.
1777
+ const updatedParentBlockedBy = [...(parentSeed.blockedBy ?? [])];
1778
+ for (const { seedId } of resolved) {
1779
+ if (!updatedParentBlockedBy.includes(seedId)) updatedParentBlockedBy.push(seedId);
1780
+ }
1781
+ allIssues[parentIdx] = {
1782
+ ...parentSeed,
1783
+ blockedBy: updatedParentBlockedBy,
1784
+ updatedAt: now,
1785
+ };
1786
+
1787
+ // Plan row: insert adopted ids into children at the resolved
1788
+ // position (default append), preserving command-line order. The
1789
+ // already-attached check above rejects re-adoption, so the candidate
1790
+ // ids are guaranteed absent from plan.children. Bump revision once
1791
+ // per command call.
1792
+ const insertAt = resolveInsertIndex(position, plan.children, plan.id);
1793
+ const insertedIds = resolved.map((r) => r.seedId);
1794
+ const nextChildren = [
1795
+ ...plan.children.slice(0, insertAt),
1796
+ ...insertedIds,
1797
+ ...plan.children.slice(insertAt),
1798
+ ];
1799
+ // sprout-a3ab: tag these ids on the plan so `sr plan show` renders
1800
+ // them with "(adopted)". Always non-empty here because runAdopt
1801
+ // requires at least one seed id.
1802
+ const nextAdopted = [...(plan.adoptedChildren ?? [])];
1803
+ for (const { seedId } of resolved) {
1804
+ if (!nextAdopted.includes(seedId)) nextAdopted.push(seedId);
1805
+ }
1806
+ const updatedPlan: Plan = {
1807
+ ...plan,
1808
+ children: nextChildren,
1809
+ adoptedChildren: nextAdopted,
1810
+ revision: plan.revision + 1,
1811
+ updatedAt: now,
1812
+ };
1813
+ allPlans[planIdx] = updatedPlan;
1814
+
1815
+ await writeIssues(dir, allIssues);
1816
+ await writePlans(dir, allPlans);
1817
+
1818
+ finalPlan = updatedPlan;
1819
+ adoptedIds = resolved.map((r) => r.seedId);
1820
+ });
1821
+ });
1822
+
1823
+ if (!finalPlan) return;
1824
+ const plan: Plan = finalPlan;
1825
+
1826
+ if (opts.jsonMode) {
1827
+ await outputJson({
1828
+ success: true,
1829
+ command: "plan adopt",
1830
+ plan_id: plan.id,
1831
+ adopted: adoptedIds,
1832
+ revision: plan.revision,
1833
+ });
1834
+ return;
1835
+ }
1836
+ for (const id of adoptedIds) {
1837
+ printSuccess(`${accent(id)} adopted into plan ${accent(plan.id)}`);
1838
+ }
1839
+ printSuccess(`plan ${accent(plan.id)} revision bumped to ${plan.revision}`);
1840
+ }
1841
+
1842
+ interface ReleaseOptions {
1843
+ jsonMode: boolean;
1844
+ }
1845
+
1846
+ // sr plan release <plan-id> <seed-ids...> (sprout-2b8a / pl-43ff step 5).
1847
+ // Inverse of runAdopt: detach sprout from a plan without closing them. Each
1848
+ // candidate must currently be attached to the named plan (seed.plan_id ===
1849
+ // planId). Mutation per seed: strip the sprout:plan-backref block, clear
1850
+ // plan_id + plan_step_index, drop parent.id from seed.blocks; on the parent,
1851
+ // drop seed.id from blockedBy. The plan row drops seed.id from children and
1852
+ // bumps revision once per command call. Validation runs in a single pre-write
1853
+ // pass so an invalid candidate leaves issues + plans untouched. Lock order
1854
+ // matches submit/adopt: outer plans, inner issues (mx-f29e43).
1855
+ async function runRelease(
1856
+ planIdArg: string,
1857
+ seedIds: string[],
1858
+ opts: ReleaseOptions,
1859
+ ): Promise<void> {
1860
+ const dir = await findSproutDir();
1861
+ const planId = await resolvePlanIdArg(planIdArg, dir);
1862
+
1863
+ if (seedIds.length === 0) {
1864
+ throw new Error("At least one seed id is required.");
1865
+ }
1866
+ const dupes = findDuplicates(seedIds);
1867
+ if (dupes.length > 0) {
1868
+ throw new Error(
1869
+ `Duplicate seed id${dupes.length === 1 ? "" : "s"} in args: ${dupes.join(", ")}.`,
1870
+ );
1871
+ }
1872
+
1873
+ let finalPlan: Plan | null = null;
1874
+ let releasedIds: string[] = [];
1875
+
1876
+ await withLock(plansPath(dir), async () => {
1877
+ await withLock(issuesPath(dir), async () => {
1878
+ const allIssues = await readIssues(dir);
1879
+ const allPlans = await readPlans(dir);
1880
+
1881
+ const planIdx = allPlans.findIndex((p) => p.id === planId);
1882
+ const plan = allPlans[planIdx];
1883
+ if (!plan) {
1884
+ throw new Error(`Plan not found: ${planId}. Run 'sr plan list' to see available plans.`);
1885
+ }
1886
+
1887
+ const parentIdx = allIssues.findIndex((i) => i.id === plan.seed);
1888
+ const parentSeed = allIssues[parentIdx];
1889
+ if (!parentSeed) {
1890
+ throw new Error(
1891
+ `Plan ${planId} references parent seed ${plan.seed} which no longer exists.`,
1892
+ );
1893
+ }
1894
+
1895
+ // Resolve every candidate first; any failure aborts before writes.
1896
+ interface Resolved {
1897
+ seedId: string;
1898
+ idx: number;
1899
+ }
1900
+ const resolved: Resolved[] = [];
1901
+ for (const seedId of seedIds) {
1902
+ if (seedId === plan.seed) {
1903
+ throw new Error(`cannot release the parent seed ${seedId} from its own plan ${planId}.`);
1904
+ }
1905
+ const idx = allIssues.findIndex((i) => i.id === seedId);
1906
+ const seed = allIssues[idx];
1907
+ if (!seed) {
1908
+ throw new Error(`seed ${seedId} not found.`);
1909
+ }
1910
+ if (seed.plan_id !== planId) {
1911
+ if (seed.plan_id) {
1912
+ throw new Error(`seed ${seedId} is attached to plan ${seed.plan_id}, not ${planId}.`);
1913
+ }
1914
+ throw new Error(`seed ${seedId} is not attached to plan ${planId}.`);
1915
+ }
1916
+ resolved.push({ seedId, idx });
1917
+ }
1918
+
1919
+ const now = new Date().toISOString();
1920
+
1921
+ // Apply all unlink mutations under the lock. plan_id and plan_step_index
1922
+ // are set to undefined so JSON.stringify drops them from the row
1923
+ // (matches the closedAt-on-reopen convention, mx-8b2e32).
1924
+ for (const { idx } of resolved) {
1925
+ const seed = allIssues[idx];
1926
+ if (!seed) continue;
1927
+ const updated: Issue = {
1928
+ ...seed,
1929
+ plan_id: undefined,
1930
+ plan_step_index: undefined,
1931
+ description: stripPlanBackref(seed.description),
1932
+ blocks: removeValue(seed.blocks, parentSeed.id),
1933
+ updatedAt: now,
1934
+ };
1935
+ allIssues[idx] = updated;
1936
+ }
1937
+
1938
+ // Parent seed: drop each released child from blockedBy.
1939
+ const releasedSet = new Set(resolved.map((r) => r.seedId));
1940
+ const nextParentBlockedBy = (parentSeed.blockedBy ?? []).filter((id) => !releasedSet.has(id));
1941
+ allIssues[parentIdx] = {
1942
+ ...parentSeed,
1943
+ blockedBy: nextParentBlockedBy,
1944
+ updatedAt: now,
1945
+ };
1946
+
1947
+ // Plan row: drop released ids from children, bump revision once per
1948
+ // command call.
1949
+ const nextChildren = plan.children.filter((id) => !releasedSet.has(id));
1950
+ // sprout-a3ab: mirror children — released ids leave adoptedChildren
1951
+ // too. Drop the field when it becomes empty so JSONL diffs stay
1952
+ // minimal for plans that never used adoption.
1953
+ const nextAdopted = (plan.adoptedChildren ?? []).filter((id) => !releasedSet.has(id));
1954
+ const updatedPlan: Plan = {
1955
+ ...plan,
1956
+ children: nextChildren,
1957
+ revision: plan.revision + 1,
1958
+ updatedAt: now,
1959
+ };
1960
+ if (nextAdopted.length > 0) {
1961
+ updatedPlan.adoptedChildren = nextAdopted;
1962
+ } else {
1963
+ delete updatedPlan.adoptedChildren;
1964
+ }
1965
+ allPlans[planIdx] = updatedPlan;
1966
+
1967
+ await writeIssues(dir, allIssues);
1968
+ await writePlans(dir, allPlans);
1969
+
1970
+ finalPlan = updatedPlan;
1971
+ releasedIds = resolved.map((r) => r.seedId);
1972
+ });
1973
+ });
1974
+
1975
+ if (!finalPlan) return;
1976
+ const plan: Plan = finalPlan;
1977
+
1978
+ if (opts.jsonMode) {
1979
+ await outputJson({
1980
+ success: true,
1981
+ command: "plan release",
1982
+ plan_id: plan.id,
1983
+ released: releasedIds,
1984
+ revision: plan.revision,
1985
+ });
1986
+ return;
1987
+ }
1988
+ for (const id of releasedIds) {
1989
+ printSuccess(`${accent(id)} released from plan ${accent(plan.id)}`);
1990
+ }
1991
+ printSuccess(`plan ${accent(plan.id)} revision bumped to ${plan.revision}`);
1992
+ }
1993
+
1994
+ interface ReorderOptions {
1995
+ jsonMode: boolean;
1996
+ }
1997
+
1998
+ // sr plan reorder <plan-id> <seed-ids...> (sprout-3dd1). Set the exact order of
1999
+ // plan.children in one call. The provided ids must be a permutation of the
2000
+ // current children (same set, no missing, no extra, no dupes) — reorder is a
2001
+ // pure ordering operation, never an add/remove (use adopt/release for that).
2002
+ // warren's plan-run consumes plan.children order verbatim (seq = index + 1), so
2003
+ // this is the surface for pinning a release seed last. Link state on the sprout
2004
+ // (plan_id, plan_step_index, blockedBy edges) is untouched; only the plan row's
2005
+ // children array order changes. Bumps revision once per call. Lock: plans only
2006
+ // — no issue mutation.
2007
+ async function runReorder(
2008
+ planIdArg: string,
2009
+ seedIds: string[],
2010
+ opts: ReorderOptions,
2011
+ ): Promise<void> {
2012
+ const dir = await findSproutDir();
2013
+ const planId = await resolvePlanIdArg(planIdArg, dir);
2014
+
2015
+ if (seedIds.length === 0) {
2016
+ throw new Error("At least one seed id is required.");
2017
+ }
2018
+ const dupes = findDuplicates(seedIds);
2019
+ if (dupes.length > 0) {
2020
+ throw new Error(
2021
+ `Duplicate seed id${dupes.length === 1 ? "" : "s"} in args: ${dupes.join(", ")}.`,
2022
+ );
2023
+ }
2024
+
2025
+ let finalPlan: Plan | null = null;
2026
+
2027
+ await withLock(plansPath(dir), async () => {
2028
+ const allPlans = await readPlans(dir);
2029
+ const planIdx = allPlans.findIndex((p) => p.id === planId);
2030
+ const plan = allPlans[planIdx];
2031
+ if (!plan) {
2032
+ throw new Error(`Plan not found: ${planId}. Run 'sr plan list' to see available plans.`);
2033
+ }
2034
+
2035
+ const current = new Set(plan.children);
2036
+ const provided = new Set(seedIds);
2037
+ const missing = plan.children.filter((id) => !provided.has(id));
2038
+ const extra = seedIds.filter((id) => !current.has(id));
2039
+ if (extra.length > 0) {
2040
+ throw new Error(
2041
+ `${extra.join(", ")} ${extra.length === 1 ? "is" : "are"} not ${extra.length === 1 ? "a child" : "children"} of plan ${planId}. Adopt first with 'sr plan adopt'.`,
2042
+ );
2043
+ }
2044
+ if (missing.length > 0) {
2045
+ throw new Error(
2046
+ `reorder must list every child exactly once; missing: ${missing.join(", ")}. Use 'sr plan release' to drop a child.`,
2047
+ );
2048
+ }
2049
+
2050
+ const now = new Date().toISOString();
2051
+ const updatedPlan: Plan = {
2052
+ ...plan,
2053
+ children: [...seedIds],
2054
+ revision: plan.revision + 1,
2055
+ updatedAt: now,
2056
+ };
2057
+ allPlans[planIdx] = updatedPlan;
2058
+ await writePlans(dir, allPlans);
2059
+ finalPlan = updatedPlan;
2060
+ });
2061
+
2062
+ if (!finalPlan) return;
2063
+ const plan: Plan = finalPlan;
2064
+
2065
+ if (opts.jsonMode) {
2066
+ await outputJson({
2067
+ success: true,
2068
+ command: "plan reorder",
2069
+ plan_id: plan.id,
2070
+ children: plan.children,
2071
+ revision: plan.revision,
2072
+ });
2073
+ return;
2074
+ }
2075
+ printSuccess(`plan ${accent(plan.id)} children reordered (revision ${plan.revision})`);
2076
+ printSuccess(`order: ${plan.children.map((id) => accent(id)).join(", ")}`);
2077
+ }
2078
+
2079
+ // removeValue: inverse of appendUnique. Returns undefined when the resulting
2080
+ // array is empty so the field gets dropped from the serialized issue.
2081
+ function removeValue(list: string[] | undefined, id: string): string[] | undefined {
2082
+ if (!list || list.length === 0) return list;
2083
+ const next = list.filter((x) => x !== id);
2084
+ if (next.length === list.length) return list;
2085
+ return next.length === 0 ? undefined : next;
2086
+ }
2087
+
2088
+ function findDuplicates(ids: string[]): string[] {
2089
+ const seen = new Set<string>();
2090
+ const dupes = new Set<string>();
2091
+ for (const id of ids) {
2092
+ if (seen.has(id)) dupes.add(id);
2093
+ seen.add(id);
2094
+ }
2095
+ return [...dupes];
2096
+ }
2097
+
2098
+ // --step is 1-based on the CLI (mx-cf60e9) and stored 0-based internally.
2099
+ function parseStepFlag(raw: string | undefined): number | undefined {
2100
+ if (raw === undefined) return undefined;
2101
+ const n = Number.parseInt(raw, 10);
2102
+ if (!Number.isInteger(n) || String(n) !== raw.trim() || n < 1) {
2103
+ throw new Error(`--step must be a positive integer (got: ${raw}).`);
2104
+ }
2105
+ return n - 1;
2106
+ }
2107
+
2108
+ function countBlueprintSteps(plan: Plan): number {
2109
+ const steps = (plan.sections as { steps?: unknown }).steps;
2110
+ return Array.isArray(steps) ? steps.length : 0;
2111
+ }
2112
+
2113
+ interface EditOptions {
2114
+ name?: string;
2115
+ section?: string[];
2116
+ step?: string;
2117
+ stepTitle?: string;
2118
+ stepPriority?: string;
2119
+ stepType?: string;
2120
+ jsonMode: boolean;
2121
+ }
2122
+
2123
+ // Parse `--priority` for step edits. Mirrors update.ts: accepts P0..P4 or 0..4.
2124
+ function parseStepPriority(raw: string): number {
2125
+ const s = raw.trim();
2126
+ const n = s.toUpperCase().startsWith("P")
2127
+ ? Number.parseInt(s.slice(1), 10)
2128
+ : Number.parseInt(s, 10);
2129
+ if (!Number.isInteger(n) || n < 0 || n > 4) {
2130
+ throw new Error(`--priority must be 0-4 or P0-P4 (got: ${raw}).`);
2131
+ }
2132
+ return n;
2133
+ }
2134
+
2135
+ interface StepPatch {
2136
+ index: number; // 0-based
2137
+ title?: string;
2138
+ priority?: number;
2139
+ type?: Issue["type"];
2140
+ }
2141
+
2142
+ // Validate and parse the --step / --title / --priority / --type combination.
2143
+ // Returns undefined when --step is absent (and the title/priority/type flags
2144
+ // must also be absent in that case — they only make sense with --step).
2145
+ function parseStepPatch(opts: EditOptions): StepPatch | undefined {
2146
+ const stepProvided = opts.step !== undefined;
2147
+ const stepTitleProvided = opts.stepTitle !== undefined;
2148
+ const stepPriorityProvided = opts.stepPriority !== undefined;
2149
+ const stepTypeProvided = opts.stepType !== undefined;
2150
+ const anyMetaProvided = stepTitleProvided || stepPriorityProvided || stepTypeProvided;
2151
+ if (!stepProvided) {
2152
+ if (anyMetaProvided) {
2153
+ throw new Error("--title/--priority/--type require --step <i> (the step index to edit).");
2154
+ }
2155
+ return undefined;
2156
+ }
2157
+ const index = parseStepFlag(opts.step);
2158
+ if (index === undefined) {
2159
+ throw new Error("--step requires a value (1-based step index).");
2160
+ }
2161
+ if (!anyMetaProvided) {
2162
+ throw new Error("--step requires at least one of --title, --priority, --type.");
2163
+ }
2164
+ const patch: StepPatch = { index };
2165
+ if (stepTitleProvided) {
2166
+ const t = (opts.stepTitle ?? "").trim();
2167
+ if (t.length === 0) throw new Error("--title must be a non-empty string.");
2168
+ patch.title = t;
2169
+ }
2170
+ if (stepPriorityProvided && opts.stepPriority !== undefined) {
2171
+ patch.priority = parseStepPriority(opts.stepPriority);
2172
+ }
2173
+ if (stepTypeProvided && opts.stepType !== undefined) {
2174
+ const t = opts.stepType;
2175
+ if (!(VALID_TYPES as readonly string[]).includes(t)) {
2176
+ throw new Error(`--type must be one of: ${VALID_TYPES.join(", ")}`);
2177
+ }
2178
+ patch.type = t as Issue["type"];
2179
+ }
2180
+ return patch;
2181
+ }
2182
+
2183
+ // Parse `--section <name> <text>` variadic capture into (name, text). The
2184
+ // commander option type is `<name-and-text...>` so users MUST shell-quote the
2185
+ // text (otherwise additional words spill into the array and we error rather
2186
+ // than silently joining — agents wrapping sprout rely on explicit failure).
2187
+ function parseSectionFlag(raw: string[] | undefined): { name: string; text: string } | undefined {
2188
+ if (raw === undefined) return undefined;
2189
+ if (raw.length < 2) {
2190
+ throw new Error("--section requires two arguments: --section <name> <text> (quote the text).");
2191
+ }
2192
+ if (raw.length > 2) {
2193
+ throw new Error(
2194
+ '--section received more than two arguments. Quote the text: --section <name> "<text>".',
2195
+ );
2196
+ }
2197
+ const name = raw[0];
2198
+ const text = raw[1];
2199
+ if (!name || name.trim().length === 0) {
2200
+ throw new Error("--section name must be a non-empty string.");
2201
+ }
2202
+ return { name, text: text ?? "" };
2203
+ }
2204
+
2205
+ // sr plan edit <id> (pl-dee8). In-place plan field editing. V1 supports --name,
2206
+ // --section (text sections only), and --step <i> --title/--priority/--type
2207
+ // (step metadata; propagates to the child seed at plan_step_index=i-1).
2208
+ // Mutation always bumps revision + updatedAt, even when no fields actually
2209
+ // changed from prior values — the revision bump is the contract, callers rely
2210
+ // on it for cache invalidation.
2211
+ //
2212
+ // Lock order: outer plans, inner issues (mx-f29e43). Issues lock is only
2213
+ // acquired when --section approach changes (children backref refresh) or when
2214
+ // --step propagates title/priority/type to a child seed.
2215
+ async function runEdit(idArg: string, opts: EditOptions): Promise<void> {
2216
+ const dir = await findSproutDir();
2217
+ const planId = await resolvePlanIdArg(idArg, dir);
2218
+
2219
+ const section = parseSectionFlag(opts.section);
2220
+ const stepPatch = parseStepPatch(opts);
2221
+
2222
+ const editedFields: string[] = [];
2223
+ if (opts.name !== undefined) editedFields.push("name");
2224
+ if (section !== undefined) editedFields.push(`section:${section.name}`);
2225
+ if (stepPatch !== undefined) {
2226
+ const oneBased = stepPatch.index + 1;
2227
+ if (stepPatch.title !== undefined) editedFields.push(`step:${oneBased}:title`);
2228
+ if (stepPatch.priority !== undefined) editedFields.push(`step:${oneBased}:priority`);
2229
+ if (stepPatch.type !== undefined) editedFields.push(`step:${oneBased}:type`);
2230
+ }
2231
+ if (editedFields.length === 0) {
2232
+ throw new Error(
2233
+ "No fields to edit. Pass at least one of: --name <text>, --section <name> <text>, --step <i> --title/--priority/--type.",
2234
+ );
2235
+ }
2236
+
2237
+ let nextName: string | undefined;
2238
+ if (opts.name !== undefined) {
2239
+ nextName = normalizePlanName(opts.name);
2240
+ if (!nextName) {
2241
+ throw new Error("--name must be a non-empty string.");
2242
+ }
2243
+ }
2244
+
2245
+ let updatedPlan: Plan | null = null;
2246
+ let approachChanged = false;
2247
+ const propagatedChildren: string[] = [];
2248
+ await withLock(plansPath(dir), async () => {
2249
+ const plans = await readPlans(dir);
2250
+ const idx = plans.findIndex((p) => p.id === planId);
2251
+ const plan = plans[idx];
2252
+ if (!plan) {
2253
+ throw new Error(`Plan not found: ${planId}. Run 'sr plan list' to see available plans.`);
2254
+ }
2255
+
2256
+ let nextSections = plan.sections;
2257
+ if (section !== undefined) {
2258
+ const templates = await loadPlanTemplates(dir);
2259
+ const template = templates[plan.template];
2260
+ if (!template) {
2261
+ const available = Object.keys(templates).join(", ");
2262
+ throw new Error(
2263
+ `Plan ${planId} references unknown template '${plan.template}'. Available: ${available}.`,
2264
+ );
2265
+ }
2266
+ const spec = template.sections[section.name];
2267
+ if (!spec) {
2268
+ const known = Object.keys(template.sections).join(", ");
2269
+ throw new Error(
2270
+ `Unknown section '${section.name}' for template '${plan.template}'. Known: ${known}.`,
2271
+ );
2272
+ }
2273
+ if (spec.kind !== "text") {
2274
+ throw new Error(
2275
+ `--section editing supports kind=text only (V1). Section '${section.name}' is kind=${typeof spec.kind === "string" ? spec.kind : "object"}. Use 'sr plan submit --overwrite' for structural edits.`,
2276
+ );
2277
+ }
2278
+ const minLength = spec.min_length ?? 0;
2279
+ if (spec.required && section.text.trim().length === 0) {
2280
+ throw new Error(`Section '${section.name}' is required and cannot be empty.`);
2281
+ }
2282
+ if (minLength > 0 && section.text.length < minLength) {
2283
+ throw new Error(
2284
+ `Section '${section.name}' must be at least ${minLength} characters (got ${section.text.length}).`,
2285
+ );
2286
+ }
2287
+ const prior = (plan.sections as Record<string, unknown>)[section.name];
2288
+ nextSections = { ...plan.sections, [section.name]: section.text };
2289
+ if (section.name === "approach" && prior !== section.text) {
2290
+ approachChanged = true;
2291
+ }
2292
+ }
2293
+
2294
+ if (stepPatch !== undefined) {
2295
+ const rawSteps = (nextSections as { steps?: unknown }).steps;
2296
+ if (!Array.isArray(rawSteps)) {
2297
+ throw new Error(
2298
+ `Plan ${planId} has no steps section to edit. Use 'sr plan submit --overwrite' to add steps.`,
2299
+ );
2300
+ }
2301
+ const total = rawSteps.length;
2302
+ if (stepPatch.index < 0 || stepPatch.index >= total) {
2303
+ throw new Error(
2304
+ `--step ${stepPatch.index + 1} is out of range (plan ${planId} has ${total} step${total === 1 ? "" : "s"}).`,
2305
+ );
2306
+ }
2307
+ const existing = rawSteps[stepPatch.index];
2308
+ const existingObj =
2309
+ existing && typeof existing === "object" && !Array.isArray(existing)
2310
+ ? (existing as Record<string, unknown>)
2311
+ : {};
2312
+ const nextStep: Record<string, unknown> = { ...existingObj };
2313
+ if (stepPatch.title !== undefined) nextStep.title = stepPatch.title;
2314
+ if (stepPatch.priority !== undefined) nextStep.priority = stepPatch.priority;
2315
+ if (stepPatch.type !== undefined) nextStep.type = stepPatch.type;
2316
+ const nextSteps = rawSteps.slice();
2317
+ nextSteps[stepPatch.index] = nextStep;
2318
+ nextSections = { ...nextSections, steps: nextSteps };
2319
+ }
2320
+
2321
+ const now = new Date().toISOString();
2322
+ const next: Plan = {
2323
+ ...plan,
2324
+ sections: nextSections,
2325
+ revision: plan.revision + 1,
2326
+ updatedAt: now,
2327
+ };
2328
+ if (nextName !== undefined) next.name = nextName;
2329
+ plans[idx] = next;
2330
+ await writePlans(dir, plans);
2331
+ updatedPlan = next;
2332
+
2333
+ // Refresh backref on every child seed when approach text changes (plan
2334
+ // children are ordered to align with sections.steps — children[i] is the
2335
+ // seed for step i; loose adoptions hit the stepIndex=undefined branch in
2336
+ // applyPlanBackref) and/or propagate --step metadata to the child(ren)
2337
+ // whose plan_step_index matches. Both happen under a single issues lock
2338
+ // so combined edits remain atomic.
2339
+ if (approachChanged || stepPatch !== undefined) {
2340
+ await withLock(issuesPath(dir), async () => {
2341
+ const allIssues = await readIssues(dir);
2342
+ const parentIdx = allIssues.findIndex((iss) => iss.id === next.seed);
2343
+ const parent = allIssues[parentIdx];
2344
+ const approach = (next.sections as { approach?: unknown }).approach;
2345
+ let dirty = false;
2346
+ if (approachChanged && parent) {
2347
+ for (const childId of next.children) {
2348
+ const cIdx = allIssues.findIndex((iss) => iss.id === childId);
2349
+ const child = allIssues[cIdx];
2350
+ if (!child) continue;
2351
+ const stepIndex = child.plan_step_index;
2352
+ allIssues[cIdx] = {
2353
+ ...child,
2354
+ description: applyPlanBackref(child.description, {
2355
+ stepIndex,
2356
+ planId: next.id,
2357
+ parentSeedId: parent.id,
2358
+ parentSeedTitle: parent.title,
2359
+ templateName: next.template,
2360
+ approach,
2361
+ }),
2362
+ updatedAt: now,
2363
+ };
2364
+ dirty = true;
2365
+ }
2366
+ }
2367
+ if (stepPatch !== undefined) {
2368
+ // Match every child that carries plan_step_index === stepPatch.index.
2369
+ // Multiple matches are legal (adoption via `sr plan adopt --step`
2370
+ // stamps the same index on extra sprout); propagate to all of them.
2371
+ for (let i = 0; i < allIssues.length; i++) {
2372
+ const child = allIssues[i];
2373
+ if (!child) continue;
2374
+ if (!next.children.includes(child.id)) continue;
2375
+ if (child.plan_step_index !== stepPatch.index) continue;
2376
+ const updates: Partial<Issue> = { updatedAt: now };
2377
+ if (stepPatch.title !== undefined) updates.title = stepPatch.title;
2378
+ if (stepPatch.priority !== undefined) updates.priority = stepPatch.priority;
2379
+ if (stepPatch.type !== undefined) updates.type = stepPatch.type;
2380
+ allIssues[i] = { ...child, ...updates };
2381
+ propagatedChildren.push(child.id);
2382
+ dirty = true;
2383
+ }
2384
+ }
2385
+ if (dirty) await writeIssues(dir, allIssues);
2386
+ });
2387
+ }
2388
+ });
2389
+
2390
+ if (!updatedPlan) return;
2391
+ const finalPlan: Plan = updatedPlan;
2392
+
2393
+ if (opts.jsonMode) {
2394
+ await outputJson({
2395
+ success: true,
2396
+ command: "plan edit",
2397
+ plan_id: finalPlan.id,
2398
+ revision: finalPlan.revision,
2399
+ edited: editedFields,
2400
+ name: finalPlan.name,
2401
+ backrefs_refreshed: approachChanged ? finalPlan.children.length : 0,
2402
+ propagated_children: propagatedChildren,
2403
+ });
2404
+ return;
2405
+ }
2406
+ printSuccess(
2407
+ `plan ${accent(finalPlan.id)} edited (${editedFields.join(", ")}); revision ${finalPlan.revision}`,
2408
+ );
2409
+ if (approachChanged) {
2410
+ printSuccess(`refreshed backrefs on ${finalPlan.children.length} child seed(s)`);
2411
+ }
2412
+ if (propagatedChildren.length > 0) {
2413
+ printSuccess(
2414
+ `propagated step metadata to ${propagatedChildren.length} child seed(s): ${propagatedChildren.join(", ")}`,
2415
+ );
2416
+ }
2417
+ }
2418
+
2419
+ async function runReview(idArg: string, by: string, jsonMode: boolean): Promise<void> {
2420
+ const dir = await findSproutDir();
2421
+ const planId = await resolvePlanIdArg(idArg, dir);
2422
+ let updatedPlan: Plan | null = null;
2423
+ await withLock(plansPath(dir), async () => {
2424
+ const plans = await readPlans(dir);
2425
+ const idx = plans.findIndex((p) => p.id === planId);
2426
+ const plan = plans[idx];
2427
+ if (!plan) {
2428
+ throw new Error(`Plan not found: ${planId}. Run 'sr plan list' to see available plans.`);
2429
+ }
2430
+ const next: Plan = { ...plan, reviewedBy: by, updatedAt: new Date().toISOString() };
2431
+ plans[idx] = next;
2432
+ await writePlans(dir, plans);
2433
+ updatedPlan = next;
2434
+ });
2435
+
2436
+ if (!updatedPlan) return;
2437
+ const finalPlan: Plan = updatedPlan;
2438
+
2439
+ if (jsonMode) {
2440
+ await outputJson({
2441
+ success: true,
2442
+ command: "plan review",
2443
+ plan_id: finalPlan.id,
2444
+ reviewedBy: finalPlan.reviewedBy,
2445
+ });
2446
+ return;
2447
+ }
2448
+ printSuccess(`plan ${accent(finalPlan.id)} reviewed by ${finalPlan.reviewedBy}`);
2449
+ }
2450
+
2451
+ async function runValidate(idArg: string, jsonMode: boolean): Promise<void> {
2452
+ const dir = await findSproutDir();
2453
+ const planId = await resolvePlanIdArg(idArg, dir);
2454
+ const plans = await readPlans(dir);
2455
+ const plan = plans.find((p) => p.id === planId);
2456
+ if (!plan) {
2457
+ throw new Error(`Plan not found: ${planId}. Run 'sr plan list' to see available plans.`);
2458
+ }
2459
+ const templates = await loadPlanTemplates(dir);
2460
+ const template = templates[plan.template];
2461
+ if (!template) {
2462
+ const available = Object.keys(templates).join(", ");
2463
+ throw new Error(
2464
+ `Plan ${planId} references unknown template '${plan.template}'. Available: ${available}.`,
2465
+ );
2466
+ }
2467
+
2468
+ // Re-run the same validator submit uses so the partial-state diff shape stays
2469
+ // in sync (PLAN_SPEC.md:148-149 + 180-195).
2470
+ const validate = compilePlanTemplate(template);
2471
+ const subject = { template: plan.template, sections: plan.sections };
2472
+ const result = validate(subject);
2473
+
2474
+ if (result.valid) {
2475
+ if (jsonMode) {
2476
+ await outputJson({ success: true, command: "plan validate", valid: true, plan_id: planId });
2477
+ } else {
2478
+ printSuccess(`plan ${accent(planId)} valid`);
2479
+ }
2480
+ return;
2481
+ }
2482
+
2483
+ process.stderr.write(`${JSON.stringify(result.diff, null, 2)}\n`);
2484
+ process.exitCode = 1;
2485
+ }
2486
+
2487
+ interface OutboundDecisionArgs {
2488
+ seed: Issue;
2489
+ planId: string;
2490
+ approach: unknown;
2491
+ domainOverride?: string;
2492
+ cwd: string;
2493
+ }
2494
+
2495
+ async function runOutboundDecision(args: OutboundDecisionArgs): Promise<string | null> {
2496
+ const projectRoot = dirname(args.cwd);
2497
+ // Check ml availability first so the stderr warning distinguishes
2498
+ // "ml not installed" from "no domain matched" — the spec mandates the
2499
+ // former phrasing for the absent-ml branch (PLAN_SPEC.md:354-356).
2500
+ if (!Bun.which("ml", { PATH: process.env.PATH })) {
2501
+ process.stderr.write("⚠ --record-decision: ml not found on PATH; skipping\n");
2502
+ return null;
2503
+ }
2504
+ const { domain } = inferDomain({
2505
+ seed: args.seed,
2506
+ explicitDomain: args.domainOverride,
2507
+ cwd: projectRoot,
2508
+ });
2509
+ if (!domain) {
2510
+ process.stderr.write("⚠ --record-decision: no loam domain inferred (skipping)\n");
2511
+ return null;
2512
+ }
2513
+ const approach = typeof args.approach === "string" ? args.approach : "";
2514
+ const result = recordDecision({
2515
+ domain,
2516
+ planId: args.planId,
2517
+ title: args.seed.title,
2518
+ approach,
2519
+ cwd: projectRoot,
2520
+ });
2521
+ if (!result.ok) {
2522
+ process.stderr.write(`⚠ --record-decision: ${result.reason ?? "failed"}\n`);
2523
+ return null;
2524
+ }
2525
+ return result.loamId ?? null;
2526
+ }