@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.
- package/LICENSE +21 -0
- package/README.md +462 -0
- package/agents/ap-co-creation.md +90 -0
- package/agents/builder.md +144 -0
- package/agents/coordinator.md +377 -0
- package/agents/lead.md +435 -0
- package/agents/merger.md +164 -0
- package/agents/monitor.md +214 -0
- package/agents/orchestrator.md +239 -0
- package/agents/reviewer.md +140 -0
- package/agents/scout.md +125 -0
- package/agents/supervisor.md +427 -0
- package/package.json +66 -0
- package/src/agents/capabilities.test.ts +85 -0
- package/src/agents/capabilities.ts +125 -0
- package/src/agents/checkpoint.test.ts +88 -0
- package/src/agents/checkpoint.ts +101 -0
- package/src/agents/copilot-hooks-deployer.test.ts +162 -0
- package/src/agents/copilot-hooks-deployer.ts +93 -0
- package/src/agents/guard-rules.test.ts +372 -0
- package/src/agents/guard-rules.ts +97 -0
- package/src/agents/headless-mail-injector.test.ts +709 -0
- package/src/agents/headless-mail-injector.ts +377 -0
- package/src/agents/headless-prompt.test.ts +102 -0
- package/src/agents/headless-prompt.ts +68 -0
- package/src/agents/hooks-deployer.test.ts +3119 -0
- package/src/agents/hooks-deployer.ts +804 -0
- package/src/agents/identity.test.ts +604 -0
- package/src/agents/identity.ts +384 -0
- package/src/agents/lifecycle.test.ts +196 -0
- package/src/agents/lifecycle.ts +183 -0
- package/src/agents/mail-poll-detect.test.ts +153 -0
- package/src/agents/mail-poll-detect.ts +73 -0
- package/src/agents/manifest.test.ts +1026 -0
- package/src/agents/manifest.ts +376 -0
- package/src/agents/overlay.test.ts +1058 -0
- package/src/agents/overlay.ts +490 -0
- package/src/agents/scope-detect.test.ts +190 -0
- package/src/agents/scope-detect.ts +146 -0
- package/src/agents/turn-lock.test.ts +181 -0
- package/src/agents/turn-lock.ts +235 -0
- package/src/agents/turn-runner-dispatch.test.ts +182 -0
- package/src/agents/turn-runner-dispatch.ts +105 -0
- package/src/agents/turn-runner.test.ts +2312 -0
- package/src/agents/turn-runner.ts +1383 -0
- package/src/beads/client.test.ts +217 -0
- package/src/beads/client.ts +230 -0
- package/src/beads/molecules.test.ts +338 -0
- package/src/beads/molecules.ts +198 -0
- package/src/commands/agents.test.ts +328 -0
- package/src/commands/agents.ts +299 -0
- package/src/commands/clean.test.ts +797 -0
- package/src/commands/clean.ts +791 -0
- package/src/commands/completions.test.ts +348 -0
- package/src/commands/completions.ts +981 -0
- package/src/commands/coordinator.test.ts +2975 -0
- package/src/commands/coordinator.ts +1841 -0
- package/src/commands/costs.test.ts +1183 -0
- package/src/commands/costs.ts +599 -0
- package/src/commands/dashboard.test.ts +954 -0
- package/src/commands/dashboard.ts +1212 -0
- package/src/commands/discover.test.ts +288 -0
- package/src/commands/discover.ts +202 -0
- package/src/commands/doctor.test.ts +303 -0
- package/src/commands/doctor.ts +311 -0
- package/src/commands/ecosystem.test.ts +226 -0
- package/src/commands/ecosystem.ts +248 -0
- package/src/commands/errors.test.ts +654 -0
- package/src/commands/errors.ts +197 -0
- package/src/commands/feed.test.ts +709 -0
- package/src/commands/feed.ts +260 -0
- package/src/commands/group.test.ts +475 -0
- package/src/commands/group.ts +546 -0
- package/src/commands/hooks.test.ts +458 -0
- package/src/commands/hooks.ts +263 -0
- package/src/commands/init.test.ts +1011 -0
- package/src/commands/init.ts +967 -0
- package/src/commands/inspect.test.ts +1239 -0
- package/src/commands/inspect.ts +648 -0
- package/src/commands/log.test.ts +1913 -0
- package/src/commands/log.ts +958 -0
- package/src/commands/logs.test.ts +801 -0
- package/src/commands/logs.ts +483 -0
- package/src/commands/mail.test.ts +1501 -0
- package/src/commands/mail.ts +848 -0
- package/src/commands/merge.test.ts +864 -0
- package/src/commands/merge.ts +381 -0
- package/src/commands/metrics.test.ts +458 -0
- package/src/commands/metrics.ts +129 -0
- package/src/commands/monitor.test.ts +191 -0
- package/src/commands/monitor.ts +409 -0
- package/src/commands/nudge.test.ts +579 -0
- package/src/commands/nudge.ts +646 -0
- package/src/commands/orchestrator.ts +42 -0
- package/src/commands/prime.test.ts +612 -0
- package/src/commands/prime.ts +359 -0
- package/src/commands/replay.test.ts +757 -0
- package/src/commands/replay.ts +231 -0
- package/src/commands/run.test.ts +469 -0
- package/src/commands/run.ts +353 -0
- package/src/commands/serve/agent-actions.test.ts +210 -0
- package/src/commands/serve/agent-actions.ts +192 -0
- package/src/commands/serve/build.test.ts +202 -0
- package/src/commands/serve/build.ts +206 -0
- package/src/commands/serve/coordinator-actions.test.ts +339 -0
- package/src/commands/serve/coordinator-actions.ts +410 -0
- package/src/commands/serve/dev.test.ts +168 -0
- package/src/commands/serve/dev.ts +117 -0
- package/src/commands/serve/mail-actions.test.ts +312 -0
- package/src/commands/serve/mail-actions.ts +167 -0
- package/src/commands/serve/rest.test.ts +1680 -0
- package/src/commands/serve/rest.ts +1130 -0
- package/src/commands/serve/static.ts +51 -0
- package/src/commands/serve/ws.test.ts +361 -0
- package/src/commands/serve/ws.ts +332 -0
- package/src/commands/serve.test.ts +459 -0
- package/src/commands/serve.ts +654 -0
- package/src/commands/sling.test.ts +1583 -0
- package/src/commands/sling.ts +1351 -0
- package/src/commands/spec.test.ts +179 -0
- package/src/commands/spec.ts +105 -0
- package/src/commands/status.test.ts +614 -0
- package/src/commands/status.ts +403 -0
- package/src/commands/stop.test.ts +964 -0
- package/src/commands/stop.ts +319 -0
- package/src/commands/supervisor.test.ts +185 -0
- package/src/commands/supervisor.ts +537 -0
- package/src/commands/trace.test.ts +762 -0
- package/src/commands/trace.ts +205 -0
- package/src/commands/update.test.ts +466 -0
- package/src/commands/update.ts +263 -0
- package/src/commands/upgrade.test.ts +48 -0
- package/src/commands/upgrade.ts +240 -0
- package/src/commands/watch.test.ts +257 -0
- package/src/commands/watch.ts +308 -0
- package/src/commands/worktree.test.ts +1297 -0
- package/src/commands/worktree.ts +451 -0
- package/src/config.test.ts +1535 -0
- package/src/config.ts +1064 -0
- package/src/doctor/agents.test.ts +523 -0
- package/src/doctor/agents.ts +399 -0
- package/src/doctor/config-check.test.ts +191 -0
- package/src/doctor/config-check.ts +183 -0
- package/src/doctor/consistency.test.ts +807 -0
- package/src/doctor/consistency.ts +347 -0
- package/src/doctor/databases.test.ts +350 -0
- package/src/doctor/databases.ts +243 -0
- package/src/doctor/dependencies.test.ts +296 -0
- package/src/doctor/dependencies.ts +272 -0
- package/src/doctor/ecosystem.test.ts +308 -0
- package/src/doctor/ecosystem.ts +156 -0
- package/src/doctor/logs.test.ts +253 -0
- package/src/doctor/logs.ts +295 -0
- package/src/doctor/merge-queue.test.ts +315 -0
- package/src/doctor/merge-queue.ts +167 -0
- package/src/doctor/providers.test.ts +409 -0
- package/src/doctor/providers.ts +250 -0
- package/src/doctor/serve.test.ts +95 -0
- package/src/doctor/serve.ts +86 -0
- package/src/doctor/structure.test.ts +423 -0
- package/src/doctor/structure.ts +285 -0
- package/src/doctor/types.ts +43 -0
- package/src/doctor/version.test.ts +241 -0
- package/src/doctor/version.ts +132 -0
- package/src/doctor/watchdog.test.ts +167 -0
- package/src/doctor/watchdog.ts +214 -0
- package/src/e2e/init-sling-lifecycle.test.ts +283 -0
- package/src/errors.test.ts +350 -0
- package/src/errors.ts +217 -0
- package/src/events/store.test.ts +660 -0
- package/src/events/store.ts +369 -0
- package/src/events/tailer.test.ts +719 -0
- package/src/events/tailer.ts +332 -0
- package/src/events/tool-filter.test.ts +330 -0
- package/src/events/tool-filter.ts +126 -0
- package/src/index.ts +533 -0
- package/src/insights/analyzer.test.ts +466 -0
- package/src/insights/analyzer.ts +203 -0
- package/src/insights/quality-gates.test.ts +141 -0
- package/src/insights/quality-gates.ts +156 -0
- package/src/json.test.ts +72 -0
- package/src/json.ts +53 -0
- package/src/loam/client.test.ts +752 -0
- package/src/loam/client.ts +664 -0
- package/src/logging/color.test.ts +252 -0
- package/src/logging/color.ts +105 -0
- package/src/logging/format.test.ts +110 -0
- package/src/logging/format.ts +255 -0
- package/src/logging/logger.test.ts +814 -0
- package/src/logging/logger.ts +266 -0
- package/src/logging/reporter.test.ts +259 -0
- package/src/logging/reporter.ts +110 -0
- package/src/logging/sanitizer.test.ts +190 -0
- package/src/logging/sanitizer.ts +57 -0
- package/src/logging/theme.ts +140 -0
- package/src/mail/broadcast.test.ts +204 -0
- package/src/mail/broadcast.ts +92 -0
- package/src/mail/client.test.ts +774 -0
- package/src/mail/client.ts +236 -0
- package/src/mail/store.test.ts +898 -0
- package/src/mail/store.ts +425 -0
- package/src/merge/lock.test.ts +149 -0
- package/src/merge/lock.ts +140 -0
- package/src/merge/predict.test.ts +387 -0
- package/src/merge/predict.ts +249 -0
- package/src/merge/queue.test.ts +426 -0
- package/src/merge/queue.ts +246 -0
- package/src/merge/resolver.test.ts +1993 -0
- package/src/merge/resolver.ts +926 -0
- package/src/metrics/pricing.test.ts +258 -0
- package/src/metrics/pricing.ts +135 -0
- package/src/metrics/store.test.ts +978 -0
- package/src/metrics/store.ts +501 -0
- package/src/metrics/summary.test.ts +398 -0
- package/src/metrics/summary.ts +178 -0
- package/src/metrics/transcript.test.ts +483 -0
- package/src/metrics/transcript.ts +114 -0
- package/src/runtimes/__fixtures__/claude-stream-fixture.ts +22 -0
- package/src/runtimes/aider.test.ts +124 -0
- package/src/runtimes/aider.ts +147 -0
- package/src/runtimes/amp.test.ts +164 -0
- package/src/runtimes/amp.ts +154 -0
- package/src/runtimes/claude.test.ts +1474 -0
- package/src/runtimes/claude.ts +579 -0
- package/src/runtimes/codex.test.ts +805 -0
- package/src/runtimes/codex.ts +273 -0
- package/src/runtimes/connections.test.ts +214 -0
- package/src/runtimes/connections.ts +103 -0
- package/src/runtimes/copilot.test.ts +707 -0
- package/src/runtimes/copilot.ts +316 -0
- package/src/runtimes/cursor.test.ts +497 -0
- package/src/runtimes/cursor.ts +205 -0
- package/src/runtimes/gemini.test.ts +537 -0
- package/src/runtimes/gemini.ts +243 -0
- package/src/runtimes/goose.test.ts +133 -0
- package/src/runtimes/goose.ts +157 -0
- package/src/runtimes/headless-connection.test.ts +264 -0
- package/src/runtimes/headless-connection.ts +158 -0
- package/src/runtimes/opencode.test.ts +325 -0
- package/src/runtimes/opencode.ts +188 -0
- package/src/runtimes/pi-guards.test.ts +486 -0
- package/src/runtimes/pi-guards.ts +367 -0
- package/src/runtimes/pi.test.ts +789 -0
- package/src/runtimes/pi.ts +305 -0
- package/src/runtimes/registry.test.ts +196 -0
- package/src/runtimes/registry.ts +99 -0
- package/src/runtimes/sapling.test.ts +1267 -0
- package/src/runtimes/sapling.ts +710 -0
- package/src/runtimes/types.ts +266 -0
- package/src/schema-consistency.test.ts +246 -0
- package/src/sessions/compat.test.ts +281 -0
- package/src/sessions/compat.ts +105 -0
- package/src/sessions/store.test.ts +1748 -0
- package/src/sessions/store.ts +858 -0
- package/src/test-helpers.test.ts +124 -0
- package/src/test-helpers.ts +145 -0
- package/src/test-setup.test.ts +31 -0
- package/src/test-setup.ts +28 -0
- package/src/tools/loam/api.ts +368 -0
- package/src/tools/loam/cli.ts +278 -0
- package/src/tools/loam/commands/add.ts +52 -0
- package/src/tools/loam/commands/archive.ts +214 -0
- package/src/tools/loam/commands/audit.ts +276 -0
- package/src/tools/loam/commands/compact.ts +1062 -0
- package/src/tools/loam/commands/completions.ts +79 -0
- package/src/tools/loam/commands/config.ts +381 -0
- package/src/tools/loam/commands/delete-domain.ts +121 -0
- package/src/tools/loam/commands/delete.ts +316 -0
- package/src/tools/loam/commands/diff.ts +200 -0
- package/src/tools/loam/commands/doctor.ts +1113 -0
- package/src/tools/loam/commands/edit.ts +226 -0
- package/src/tools/loam/commands/init.ts +31 -0
- package/src/tools/loam/commands/learn.ts +179 -0
- package/src/tools/loam/commands/move.ts +323 -0
- package/src/tools/loam/commands/onboard.ts +374 -0
- package/src/tools/loam/commands/outcome.ts +185 -0
- package/src/tools/loam/commands/prime.ts +688 -0
- package/src/tools/loam/commands/prune.ts +614 -0
- package/src/tools/loam/commands/query.ts +218 -0
- package/src/tools/loam/commands/rank.ts +180 -0
- package/src/tools/loam/commands/ready.ts +189 -0
- package/src/tools/loam/commands/record.ts +1210 -0
- package/src/tools/loam/commands/restore.ts +166 -0
- package/src/tools/loam/commands/search.ts +327 -0
- package/src/tools/loam/commands/setup.ts +887 -0
- package/src/tools/loam/commands/status.ts +103 -0
- package/src/tools/loam/commands/sync.ts +298 -0
- package/src/tools/loam/commands/update.ts +19 -0
- package/src/tools/loam/commands/upgrade.ts +93 -0
- package/src/tools/loam/commands/validate.ts +190 -0
- package/src/tools/loam/index.ts +62 -0
- package/src/tools/loam/log.ts +127 -0
- package/src/tools/loam/registry/builtins.ts +409 -0
- package/src/tools/loam/registry/custom.ts +431 -0
- package/src/tools/loam/registry/init.ts +55 -0
- package/src/tools/loam/registry/template.ts +40 -0
- package/src/tools/loam/registry/type-registry.ts +113 -0
- package/src/tools/loam/schemas/config-schema.ts +489 -0
- package/src/tools/loam/schemas/config.ts +245 -0
- package/src/tools/loam/schemas/index.ts +18 -0
- package/src/tools/loam/schemas/record-schema.ts +191 -0
- package/src/tools/loam/schemas/record.ts +115 -0
- package/src/tools/loam/utils/active-work.ts +205 -0
- package/src/tools/loam/utils/anchor-validity.ts +80 -0
- package/src/tools/loam/utils/archive.ts +146 -0
- package/src/tools/loam/utils/audit.ts +667 -0
- package/src/tools/loam/utils/bm25.ts +238 -0
- package/src/tools/loam/utils/budget.ts +142 -0
- package/src/tools/loam/utils/config.ts +344 -0
- package/src/tools/loam/utils/dir-anchors.ts +62 -0
- package/src/tools/loam/utils/domain-rules.ts +114 -0
- package/src/tools/loam/utils/expertise.ts +393 -0
- package/src/tools/loam/utils/format-helpers.ts +96 -0
- package/src/tools/loam/utils/format.ts +1234 -0
- package/src/tools/loam/utils/git-context.ts +50 -0
- package/src/tools/loam/utils/git.ts +183 -0
- package/src/tools/loam/utils/hooks.ts +299 -0
- package/src/tools/loam/utils/index.ts +52 -0
- package/src/tools/loam/utils/json-output.ts +13 -0
- package/src/tools/loam/utils/lock.ts +76 -0
- package/src/tools/loam/utils/markers.ts +48 -0
- package/src/tools/loam/utils/numeric-flags.ts +20 -0
- package/src/tools/loam/utils/palette.ts +44 -0
- package/src/tools/loam/utils/prime-ranking.ts +135 -0
- package/src/tools/loam/utils/recipe-discovery.ts +195 -0
- package/src/tools/loam/utils/runtime-flags.ts +28 -0
- package/src/tools/loam/utils/scoring.ts +94 -0
- package/src/tools/loam/utils/version.ts +116 -0
- package/src/tools/sprout/commands/block.ts +64 -0
- package/src/tools/sprout/commands/blocked.ts +86 -0
- package/src/tools/sprout/commands/close.ts +129 -0
- package/src/tools/sprout/commands/completions.ts +198 -0
- package/src/tools/sprout/commands/config.ts +238 -0
- package/src/tools/sprout/commands/create.ts +164 -0
- package/src/tools/sprout/commands/dep.ts +148 -0
- package/src/tools/sprout/commands/doctor.ts +979 -0
- package/src/tools/sprout/commands/init.ts +83 -0
- package/src/tools/sprout/commands/label.ts +178 -0
- package/src/tools/sprout/commands/list.ts +210 -0
- package/src/tools/sprout/commands/migrate.ts +133 -0
- package/src/tools/sprout/commands/onboard.ts +207 -0
- package/src/tools/sprout/commands/plan-show.ts +278 -0
- package/src/tools/sprout/commands/plan.ts +2526 -0
- package/src/tools/sprout/commands/prime.ts +399 -0
- package/src/tools/sprout/commands/ready.ts +245 -0
- package/src/tools/sprout/commands/search.ts +221 -0
- package/src/tools/sprout/commands/show.ts +277 -0
- package/src/tools/sprout/commands/stats.ts +146 -0
- package/src/tools/sprout/commands/sync.ts +134 -0
- package/src/tools/sprout/commands/tpl.ts +364 -0
- package/src/tools/sprout/commands/unblock.ts +115 -0
- package/src/tools/sprout/commands/update.ts +257 -0
- package/src/tools/sprout/commands/upgrade.ts +91 -0
- package/src/tools/sprout/config-schema.ts +152 -0
- package/src/tools/sprout/config.ts +355 -0
- package/src/tools/sprout/filter.ts +107 -0
- package/src/tools/sprout/format.ts +43 -0
- package/src/tools/sprout/id.ts +22 -0
- package/src/tools/sprout/index.ts +204 -0
- package/src/tools/sprout/log.ts +76 -0
- package/src/tools/sprout/markers.ts +22 -0
- package/src/tools/sprout/output.ts +121 -0
- package/src/tools/sprout/plan-backref.ts +93 -0
- package/src/tools/sprout/plan-context.ts +81 -0
- package/src/tools/sprout/plan-domain.ts +139 -0
- package/src/tools/sprout/plan-lifecycle.ts +65 -0
- package/src/tools/sprout/plan-loam.ts +207 -0
- package/src/tools/sprout/plan-schema.ts +209 -0
- package/src/tools/sprout/sort.ts +31 -0
- package/src/tools/sprout/store.ts +172 -0
- package/src/tools/sprout/types.ts +118 -0
- package/src/tools/sprout/validation.ts +119 -0
- package/src/tools/sprout/version.ts +1 -0
- package/src/tools/sprout/yaml.ts +387 -0
- package/src/tools/trellis/commands/archive.ts +87 -0
- package/src/tools/trellis/commands/completions.ts +610 -0
- package/src/tools/trellis/commands/config.ts +382 -0
- package/src/tools/trellis/commands/create.ts +252 -0
- package/src/tools/trellis/commands/diff.ts +150 -0
- package/src/tools/trellis/commands/doctor.ts +771 -0
- package/src/tools/trellis/commands/emit.ts +365 -0
- package/src/tools/trellis/commands/history.ts +83 -0
- package/src/tools/trellis/commands/import.ts +198 -0
- package/src/tools/trellis/commands/init.ts +81 -0
- package/src/tools/trellis/commands/list.ts +103 -0
- package/src/tools/trellis/commands/onboard.ts +156 -0
- package/src/tools/trellis/commands/pin.ts +172 -0
- package/src/tools/trellis/commands/prime.ts +193 -0
- package/src/tools/trellis/commands/render.ts +122 -0
- package/src/tools/trellis/commands/schema.ts +353 -0
- package/src/tools/trellis/commands/show.ts +115 -0
- package/src/tools/trellis/commands/stats.ts +65 -0
- package/src/tools/trellis/commands/sync.ts +112 -0
- package/src/tools/trellis/commands/tree.ts +123 -0
- package/src/tools/trellis/commands/update.ts +330 -0
- package/src/tools/trellis/commands/upgrade.ts +95 -0
- package/src/tools/trellis/commands/validate.ts +166 -0
- package/src/tools/trellis/config-schema.ts +81 -0
- package/src/tools/trellis/config.ts +108 -0
- package/src/tools/trellis/frontmatter.ts +348 -0
- package/src/tools/trellis/id.ts +24 -0
- package/src/tools/trellis/index.ts +209 -0
- package/src/tools/trellis/markers.ts +28 -0
- package/src/tools/trellis/output.ts +84 -0
- package/src/tools/trellis/render.ts +212 -0
- package/src/tools/trellis/store.ts +144 -0
- package/src/tools/trellis/types.ts +82 -0
- package/src/tools/trellis/validate.ts +199 -0
- package/src/tools/trellis/yaml.ts +309 -0
- package/src/tracker/beads.test.ts +454 -0
- package/src/tracker/beads.ts +56 -0
- package/src/tracker/factory.test.ts +90 -0
- package/src/tracker/factory.ts +65 -0
- package/src/tracker/sprout.test.ts +461 -0
- package/src/tracker/sprout.ts +182 -0
- package/src/tracker/types.ts +52 -0
- package/src/trellis/client.test.ts +107 -0
- package/src/trellis/client.ts +179 -0
- package/src/types.ts +970 -0
- package/src/utils/bin.test.ts +10 -0
- package/src/utils/bin.ts +37 -0
- package/src/utils/browser.test.ts +49 -0
- package/src/utils/browser.ts +48 -0
- package/src/utils/fs.test.ts +119 -0
- package/src/utils/fs.ts +62 -0
- package/src/utils/pid.test.ts +152 -0
- package/src/utils/pid.ts +130 -0
- package/src/utils/process-scan.test.ts +53 -0
- package/src/utils/process-scan.ts +76 -0
- package/src/utils/time.test.ts +43 -0
- package/src/utils/time.ts +37 -0
- package/src/utils/version.test.ts +33 -0
- package/src/utils/version.ts +70 -0
- package/src/version.ts +5 -0
- package/src/watchdog/daemon.test.ts +3721 -0
- package/src/watchdog/daemon.ts +1257 -0
- package/src/watchdog/health.test.ts +830 -0
- package/src/watchdog/health.ts +434 -0
- package/src/watchdog/triage.test.ts +205 -0
- package/src/watchdog/triage.ts +205 -0
- package/src/worktree/manager.test.ts +720 -0
- package/src/worktree/manager.ts +405 -0
- package/src/worktree/process.test.ts +172 -0
- package/src/worktree/process.ts +131 -0
- package/src/worktree/tmux.test.ts +1616 -0
- package/src/worktree/tmux.ts +721 -0
- package/templates/CLAUDE.md.tmpl +100 -0
- package/templates/copilot-hooks.json.tmpl +13 -0
- package/templates/hooks.json.tmpl +109 -0
- package/templates/overlay.md.tmpl +88 -0
- package/ui/dist/apple-touch-icon-bdy6teep.png +0 -0
- package/ui/dist/chunk-8s31f05k.css +1 -0
- package/ui/dist/chunk-vm5rz679.js +300 -0
- package/ui/dist/favicon-nzb39vza.svg +4 -0
- 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
|
+
}
|