Skip to content

Commit d43f996

Browse files
committed
feat(cli): plan-or-implement choice in stash init
Adds a step before the handoff target picker that asks the user whether the agent should produce a reviewable plan first or go straight to implementation. Plan-first is the default — for migrate-existing-column work the wrong order of operations is hard to recover from, so a plan checkpoint is the safer default. Plan mode currently routes only to Claude Code or Codex (AGENTS.md and the CipherStash Agent / wizard don't yet have planning prompt templates). The implementation prompt now reads `.cipherstash/plan.md` as the source of truth for routing if it exists, rather than re-asking which path applies. Closes #408
1 parent f66ca99 commit d43f996

12 files changed

Lines changed: 454 additions & 31 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"stash": minor
3+
---
4+
5+
Add a plan-or-implement choice to `stash init`. After the install/detection steps, the user picks whether the agent handoff should produce a reviewable plan at `.cipherstash/plan.md` first (the recommended default) or go straight to implementation. Plan mode currently routes only to Claude Code or Codex; implement mode preserves the existing four-target picker. The implementation prompt now reads an existing plan as the source of truth for routing rather than re-asking which path applies.
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { describe, expect, it } from 'vitest'
2+
import type { AgentEnvironment } from '../detect-agents.js'
3+
import { buildOptions, defaultChoice } from '../steps/how-to-proceed.js'
4+
import type { InitState } from '../types.js'
5+
6+
function makeAgents(claudeCode: boolean, codex: boolean): AgentEnvironment {
7+
return {
8+
cli: { claudeCode, codex },
9+
project: {
10+
claudeDir: false,
11+
claudeMd: false,
12+
claudeSkillsDir: false,
13+
agentsMd: false,
14+
},
15+
editor: 'unknown',
16+
}
17+
}
18+
19+
const noAgents: InitState = { agents: makeAgents(false, false) }
20+
const claudeOnly: InitState = { agents: makeAgents(true, false) }
21+
const codexOnly: InitState = { agents: makeAgents(false, true) }
22+
23+
describe('howToProceed — buildOptions', () => {
24+
it('offers all four targets in implement mode', () => {
25+
const opts = buildOptions(noAgents, 'implement')
26+
const values = opts.map((o) => o.value)
27+
expect(values).toEqual(['claude-code', 'codex', 'wizard', 'agents-md'])
28+
})
29+
30+
it('offers only claude-code and codex in plan mode', () => {
31+
const opts = buildOptions(noAgents, 'plan')
32+
const values = opts.map((o) => o.value)
33+
expect(values).toEqual(['claude-code', 'codex'])
34+
})
35+
36+
it('reflects detection state in hints regardless of mode', () => {
37+
const implement = buildOptions(claudeOnly, 'implement')
38+
const plan = buildOptions(claudeOnly, 'plan')
39+
40+
const implementClaude = implement.find((o) => o.value === 'claude-code')
41+
const planClaude = plan.find((o) => o.value === 'claude-code')
42+
43+
expect(implementClaude?.hint).toMatch(/detected/)
44+
expect(planClaude?.hint).toMatch(/detected/)
45+
})
46+
})
47+
48+
describe('howToProceed — defaultChoice', () => {
49+
it('prefers claude-code when detected', () => {
50+
expect(defaultChoice(claudeOnly, 'implement')).toBe('claude-code')
51+
expect(defaultChoice(claudeOnly, 'plan')).toBe('claude-code')
52+
})
53+
54+
it('prefers codex when claude is absent and codex is detected', () => {
55+
expect(defaultChoice(codexOnly, 'implement')).toBe('codex')
56+
expect(defaultChoice(codexOnly, 'plan')).toBe('codex')
57+
})
58+
59+
it('falls back to agents-md in implement mode when no CLI is detected', () => {
60+
expect(defaultChoice(noAgents, 'implement')).toBe('agents-md')
61+
})
62+
63+
it('falls back to claude-code in plan mode when no CLI is detected', () => {
64+
// Plan mode never offers agents-md; claude-code is the listed default
65+
// so the picker has a valid initialValue rather than falling through
66+
// to a hidden option.
67+
expect(defaultChoice(noAgents, 'plan')).toBe('claude-code')
68+
})
69+
})

packages/cli/src/commands/init/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { createDrizzleProvider } from './providers/drizzle.js'
44
import { createSupabaseProvider } from './providers/supabase.js'
55
import { authenticateStep } from './steps/authenticate.js'
66
import { buildSchemaStep } from './steps/build-schema.js'
7+
import { chooseModeStep } from './steps/choose-mode.js'
78
import { gatherContextStep } from './steps/gather-context.js'
89
import { howToProceedStep } from './steps/how-to-proceed.js'
910
import { installDepsStep } from './steps/install-deps.js'
@@ -25,6 +26,7 @@ const STEPS = [
2526
installDepsStep,
2627
installEqlStep,
2728
gatherContextStep,
29+
chooseModeStep,
2830
howToProceedStep,
2931
nextStepsStep,
3032
]

packages/cli/src/commands/init/lib/__tests__/setup-prompt.test.ts

Lines changed: 83 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,11 @@ const baseCtx: SetupPromptContext = {
1010
stackInstalled: true,
1111
cliInstalled: true,
1212
handoff: 'claude-code',
13+
mode: 'implement',
1314
installedSkills: ['stash-encryption', 'stash-drizzle', 'stash-cli'],
1415
}
1516

16-
describe('renderSetupPrompt — orient + route', () => {
17+
describe('renderSetupPrompt — orient + route (implement mode)', () => {
1718
it('emits integration + package manager in the header', () => {
1819
const out = renderSetupPrompt(baseCtx)
1920
expect(out).toContain('Integration: `drizzle`')
@@ -139,4 +140,85 @@ describe('renderSetupPrompt — orient + route', () => {
139140
expect(out).toContain('serverExternalPackages')
140141
expect(out).toContain('@cipherstash/protect-ffi')
141142
})
143+
144+
it('directs the agent to read .cipherstash/plan.md first if it exists', () => {
145+
// Plan mode produces .cipherstash/plan.md; if the user later runs init
146+
// again in implement mode, the plan must be the source of truth — not
147+
// a re-asked routing question.
148+
const out = renderSetupPrompt(baseCtx)
149+
expect(out).toContain('.cipherstash/plan.md')
150+
expect(out).toMatch(/source of truth/i)
151+
})
152+
})
153+
154+
describe('renderSetupPrompt — plan mode', () => {
155+
const planCtx: SetupPromptContext = { ...baseCtx, mode: 'plan' }
156+
157+
it('frames the deliverable as a plan file, not code changes', () => {
158+
const out = renderSetupPrompt(planCtx)
159+
expect(out).toContain('# CipherStash setup — write a plan')
160+
expect(out).toContain('.cipherstash/plan.md')
161+
expect(out).toMatch(/produce a plan file/i)
162+
})
163+
164+
it('explicitly forbids mutating commands during planning', () => {
165+
const out = renderSetupPrompt(planCtx)
166+
expect(out).toContain('## What you must NOT do')
167+
expect(out).toMatch(/db push/)
168+
expect(out).toMatch(/encrypt backfill/)
169+
expect(out).toMatch(/encrypt cutover/)
170+
expect(out).toMatch(/encrypt drop/)
171+
})
172+
173+
it('allows read-only inspection commands', () => {
174+
const out = renderSetupPrompt(planCtx)
175+
expect(out).toMatch(/db status/)
176+
expect(out).toMatch(/Read-only/i)
177+
})
178+
179+
it('tells the agent to offer copying the plan into docs/plans when it exists', () => {
180+
const out = renderSetupPrompt(planCtx)
181+
expect(out).toContain('docs/plans/')
182+
expect(out).toMatch(/offer to copy/i)
183+
})
184+
185+
it('lists project-specific risk classes the plan must cover', () => {
186+
const out = renderSetupPrompt(planCtx)
187+
expect(out).toMatch(/bundler exclusion/i)
188+
expect(out).toMatch(/top-level-await/i)
189+
expect(out).toMatch(/partial CipherStash/i)
190+
})
191+
192+
it('requires the plan to identify which lifecycle path applies per column', () => {
193+
const out = renderSetupPrompt(planCtx)
194+
expect(out).toMatch(/path 1/i)
195+
expect(out).toMatch(/path 3/i)
196+
expect(out).toMatch(/four-deploy sequence/i)
197+
})
198+
199+
it('still tells the agent its first response is an orientation message, not action', () => {
200+
const out = renderSetupPrompt(planCtx)
201+
expect(out).toContain('## Your first response')
202+
expect(out).toMatch(/orientation message/i)
203+
})
204+
205+
it('references concrete table/column names from .cipherstash/context.json', () => {
206+
const out = renderSetupPrompt(planCtx)
207+
expect(out).toContain('.cipherstash/context.json')
208+
})
209+
210+
it('preserves the integration + package manager header in plan mode', () => {
211+
const out = renderSetupPrompt(planCtx)
212+
expect(out).toContain('Integration: `drizzle`')
213+
expect(out).toContain('Package manager: `pnpm`')
214+
})
215+
216+
it('does not emit the implement-mode flow walkthroughs verbatim', () => {
217+
// Plan mode summarises the two options in one line each rather than
218+
// restating the full numbered walkthroughs; the walkthroughs live in
219+
// the implement prompt.
220+
const out = renderSetupPrompt(planCtx)
221+
expect(out).not.toContain('### Add a new encrypted column')
222+
expect(out).not.toContain('### Migrate an existing column to encrypted')
223+
})
142224
})

0 commit comments

Comments
 (0)