Skip to content

Commit e7949ea

Browse files
authored
Merge pull request #428 from cipherstash/dan/init-plan-or-implement-2
Dan/init plan or implement 2
2 parents e16b282 + a7de5cc commit e7949ea

23 files changed

Lines changed: 937 additions & 169 deletions

.changeset/stash-impl-command.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@
22
"stash": minor
33
---
44

5-
Split agent handoff out of `stash init` into a new `stash impl` command. `init` now owns scaffolding only (auth, database, encryption client, EQL extension) and exits at a clean checkpoint pointing at `stash impl`. `stash impl` derives plan-vs-implement mode from disk state — if `.cipherstash/plan.md` is missing it asks the agent to draft a plan; if it exists, the agent executes the plan as the source of truth. `--yolo` skips the planning checkpoint after an interactive confirmation. The earlier in-init `Plan first / Go straight to implementation` picker is removed in favour of the new command boundary.
5+
Split agent handoff out of `stash init` into a new `stash impl` command. `init` now owns scaffolding only (auth, database, encryption client, EQL extension) and exits at a clean checkpoint pointing at `stash impl`. `stash impl` derives plan-vs-implement mode from disk state — if `.cipherstash/plan.md` is missing it asks the agent to draft a plan; if it exists, the agent executes the plan as the source of truth. `--continue-without-plan` skips the planning checkpoint after an interactive confirmation. The earlier in-init `Plan first / Go straight to implementation` picker is removed in favour of the new command boundary.
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+
`stash impl` now renders a plan summary panel and asks the user to confirm before launching the implementation agent. When a plan exists, the CLI parses a machine-readable `<!-- cipherstash:plan-summary {...} -->` block (the planning agent is instructed to emit one at the top of `.cipherstash/plan.md`) and prints column counts, per-column paths, and whether the work is single-deploy or staged across 4 deploys. Default-yes on the confirm so the path of least resistance is to proceed; saying No exits cleanly. Older plans without the summary block fall back to a soft "open in your editor" panel — never an error. Non-TTY runs (CI, pipes) skip the confirm and proceed.

.changeset/stash-plan-command.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"stash": minor
3+
---
4+
5+
Extract planning into its own `stash plan` command. Three commands now own the setup lifecycle:
6+
7+
- `stash init` — scaffold (auth, db, deps, EQL). Ends with a chain prompt to `stash plan`.
8+
- `stash plan` — draft a reviewable plan at `.cipherstash/plan.md`. Ends with a chain prompt to `stash impl`.
9+
- `stash impl` — execute. With a plan, shows the summary panel and confirms. Without one, presents a `Draft a plan first / Continue without a plan` picker (the second option goes through a security confirm). `--continue-without-plan` skips the picker.
10+
11+
`stash status` reflects the new flow — its "Plan written" stage and `Next:` line route to `stash plan` when init is done but no plan exists. Non-TTY runs of `stash impl` without a plan now error out with a clear next-action rather than guessing intent.

.changeset/stash-status-command.md

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 `stash status` — a top-level lifecycle map for the project. Reads `.cipherstash/context.json`, `.cipherstash/plan.md`, and `.cipherstash/setup-prompt.md` from disk to render a panel showing whether init is done, whether a plan has been written, and whether an agent has been engaged. Points at `stash db status` for EQL install info and `stash encrypt status` for per-column migration phase. Runs in milliseconds — no auth, no database connection required. The existing `stash db status` is unchanged.

packages/cli/src/bin/stash.ts

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,12 @@ import * as p from '@clack/prompts'
1616
// Commands that depend on @cipherstash/stack are lazy-loaded in the switch below.
1717
import {
1818
authCommand,
19+
dbStatusCommand,
1920
envCommand,
2021
implCommand,
2122
initCommand,
2223
installCommand,
24+
planCommand,
2325
statusCommand,
2426
testConnectionCommand,
2527
upgradeCommand,
@@ -75,7 +77,9 @@ ${messages.cli.usagePrefix}${STASH} <command> [options]
7577
7678
Commands:
7779
init Initialize CipherStash for your project
78-
impl Draft an encryption plan (or implement, if a plan exists)
80+
plan Draft a reviewable encryption plan at .cipherstash/plan.md
81+
impl Execute the plan with a local agent
82+
status Displays implementation status
7983
auth <subcommand> Authenticate with CipherStash
8084
wizard AI-guided encryption setup (reads your codebase)
8185
@@ -107,8 +111,8 @@ Init Flags:
107111
--drizzle Use Drizzle-specific setup flow
108112
109113
Impl Flags:
110-
--yolo Skip the planning checkpoint and go straight to implementation
111-
(interactively confirms before proceeding)
114+
--continue-without-plan Skip planning and go straight to implementation
115+
(interactively confirms before proceeding)
112116
113117
DB Flags:
114118
--force (install) Reinstall / overwrite even if already installed
@@ -125,8 +129,10 @@ DB Flags:
125129
Examples:
126130
${STASH} init
127131
${STASH} init --supabase
132+
${STASH} plan
128133
${STASH} impl
129-
${STASH} impl --yolo
134+
${STASH} impl --continue-without-plan
135+
${STASH} status
130136
${STASH} auth login
131137
${STASH} wizard
132138
${STASH} db install
@@ -232,7 +238,7 @@ async function runDbCommand(
232238
break
233239
}
234240
case 'status':
235-
await statusCommand({ databaseUrl })
241+
await dbStatusCommand({ databaseUrl })
236242
break
237243
case 'test-connection':
238244
await testConnectionCommand({ databaseUrl })
@@ -375,9 +381,15 @@ async function main() {
375381
case 'init':
376382
await initCommand(flags)
377383
break
384+
case 'plan':
385+
await planCommand()
386+
break
378387
case 'impl':
379388
await implCommand(flags)
380389
break
390+
case 'status':
391+
await statusCommand()
392+
break
381393
case 'auth': {
382394
const authArgs = subcommand ? [subcommand, ...commandArgs] : commandArgs
383395
await authCommand(authArgs, flags)

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

Lines changed: 105 additions & 108 deletions
Original file line numberDiff line numberDiff line change
@@ -2,121 +2,65 @@ import { existsSync, readFileSync } from 'node:fs'
22
import { resolve } from 'node:path'
33
import * as p from '@clack/prompts'
44
import { type AgentEnvironment, detectAgents } from '../init/detect-agents.js'
5+
import { parsePlanSummary, renderPlanSummary } from '../init/lib/parse-plan.js'
6+
import { readContextFile } from '../init/lib/read-context.js'
57
import { PLAN_REL_PATH } from '../init/lib/setup-prompt.js'
68
import {
79
CONTEXT_REL_PATH,
810
type ContextFile,
911
} from '../init/lib/write-context.js'
10-
import {
11-
CancelledError,
12-
type InitMode,
13-
type InitProvider,
14-
type InitState,
15-
} from '../init/types.js'
12+
import { CancelledError, type InitState } from '../init/types.js'
1613
import { detectPackageManager, runnerCommand } from '../init/utils.js'
1714
import { howToProceedStep } from './steps/how-to-proceed.js'
1815

19-
/**
20-
* The handoff steps in `impl/steps/handoff-*.ts` accept an `InitProvider`
21-
* but ignore it (their `run` signatures take `_provider`). The provider
22-
* abstraction belongs to the `init` flow, where it picks intro copy and
23-
* default next-steps. `stash impl` reads everything it needs from
24-
* `.cipherstash/context.json` instead, so a stub keeps the type signature
25-
* happy without pretending impl has provider-specific behaviour.
26-
*/
27-
const STUB_PROVIDER: InitProvider = {
28-
name: 'impl',
29-
introMessage: '',
30-
getNextSteps: () => [],
31-
}
32-
33-
export function readContextFile(cwd: string): ContextFile | undefined {
34-
const path = resolve(cwd, CONTEXT_REL_PATH)
35-
if (!existsSync(path)) return undefined
36-
try {
37-
return JSON.parse(readFileSync(path, 'utf-8')) as ContextFile
38-
} catch {
39-
return undefined
40-
}
41-
}
42-
43-
/**
44-
* Derive the impl mode from disk state and flags.
45-
*
46-
* no `--yolo`, plan missing → `plan` (default — the safer path)
47-
* no `--yolo`, plan exists → `implement` (the plan is the source of truth)
48-
* `--yolo`, plan missing → `implement` after interactive confirmation
49-
* `--yolo`, plan exists → `implement`; `--yolo` is a no-op once a plan
50-
* exists, since the safety checkpoint already
51-
* fired
52-
*
53-
* The interactive confirmation when `--yolo` is the only thing standing
54-
* between the user and ~45–60 min of agent-driven implementation. Cheap
55-
* to ask, expensive to skip by accident.
56-
*/
57-
export async function deriveMode(
58-
cwd: string,
59-
yolo: boolean,
60-
): Promise<InitMode> {
61-
const planExists = existsSync(resolve(cwd, PLAN_REL_PATH))
62-
63-
if (yolo) {
64-
if (planExists) {
65-
p.log.info(
66-
`Plan exists at \`${PLAN_REL_PATH}\` — \`--yolo\` is a no-op when a plan is already in place.`,
67-
)
68-
return 'implement'
69-
}
70-
const confirmed = await p.confirm({
71-
message:
72-
'Skip the planning checkpoint and go straight to implementation?',
73-
initialValue: false,
74-
})
75-
if (p.isCancel(confirmed) || !confirmed) {
76-
throw new CancelledError()
77-
}
78-
return 'implement'
79-
}
80-
81-
return planExists ? 'implement' : 'plan'
82-
}
83-
8416
function buildStateFromContext(
8517
ctx: ContextFile,
86-
mode: InitMode,
8718
agents: AgentEnvironment,
8819
): InitState {
8920
return {
9021
integration: ctx.integration,
9122
clientFilePath: ctx.encryptionClientPath,
9223
schemas: ctx.schemas,
9324
envKeys: ctx.envKeys,
94-
// After init has run, these are true. The pre-flight context.json
95-
// check above is the gate — if init didn't complete, context.json
96-
// wouldn't exist and we'd have already errored.
9725
stackInstalled: true,
9826
cliInstalled: true,
9927
eqlInstalled: true,
10028
agents,
101-
mode,
29+
mode: 'implement',
30+
}
31+
}
32+
33+
/**
34+
* Confirm before launching implementation when the user has chosen to
35+
* skip the planning checkpoint. Default-no is the security stance —
36+
* passing through this prompt by accident is the failure mode we're
37+
* guarding against.
38+
*/
39+
async function confirmContinueWithoutPlan(): Promise<void> {
40+
const confirmed = await p.confirm({
41+
message: 'Implementation can take some time. Continue?',
42+
initialValue: false,
43+
})
44+
if (p.isCancel(confirmed) || !confirmed) {
45+
throw new CancelledError()
10246
}
10347
}
10448

10549
/**
106-
* `stash impl` — the agent handoff phase.
50+
* `stash impl` — execute an encryption plan.
10751
*
108-
* Pre-flights `.cipherstash/context.json` (errors with a `stash init`
109-
* pointer if missing). Derives plan-vs-implement mode from disk state and
110-
* the `--yolo` flag, then dispatches to a handoff target via
111-
* `howToProceedStep`. Modes:
52+
* Always runs in implement mode. Behaviour branches on disk state and
53+
* flags:
11254
*
113-
* - `plan` (default when no `.cipherstash/plan.md` exists): the agent
114-
* produces a reviewable plan file. The user reads it, then re-runs
115-
* `stash impl` to execute.
116-
* - `implement` (default when a plan exists): the agent executes the
117-
* plan as the source of truth.
118-
* - `--yolo` forces `implement` even with no plan, after an interactive
119-
* confirmation prompt.
55+
* - **Plan exists** (TTY): parse the structured summary block, render
56+
* a confirmation panel, ask the user to proceed. Default-yes.
57+
* - **Plan exists** (non-TTY): proceed without confirmation.
58+
* - **No plan, `--continue-without-plan`**: confirm once, then implement.
59+
* - **No plan, TTY**: present a `p.select` — draft a plan first
60+
* (delegates to `planCommand`) or continue without one (confirms
61+
* once, then implements).
62+
* - **No plan, non-TTY**: error out with a clear next-action; CI must
63+
* pass `--continue-without-plan` or run `stash plan` first.
12064
*/
12165
export async function implCommand(flags: Record<string, boolean>) {
12266
const cwd = process.cwd()
@@ -133,33 +77,86 @@ export async function implCommand(flags: Record<string, boolean>) {
13377

13478
p.intro('CipherStash Implementation')
13579

136-
try {
137-
const mode = await deriveMode(cwd, flags.yolo === true)
80+
const planPath = resolve(cwd, PLAN_REL_PATH)
81+
const planExists = existsSync(planPath)
82+
const continueWithoutPlan = flags['continue-without-plan'] === true
83+
const isTTY = process.stdout.isTTY
13884

139-
if (mode === 'plan') {
140-
p.log.info(
141-
`No plan at \`${PLAN_REL_PATH}\`. The agent will draft one for you to review.`,
142-
)
85+
try {
86+
if (planExists) {
87+
// Plan-summary checkpoint: the last save point before launching the
88+
// (potentially hour-long) implementation phase.
89+
if (isTTY) {
90+
const summary = parsePlanSummary(readFileSync(planPath, 'utf-8'))
91+
if (summary) {
92+
p.note(renderPlanSummary(summary), 'Plan summary')
93+
} else {
94+
p.note(
95+
`Plan at \`${PLAN_REL_PATH}\` doesn't include a machine-readable summary. Open it in your editor before proceeding.`,
96+
'Plan ready',
97+
)
98+
}
99+
const proceed = await p.confirm({
100+
message: 'Proceed with implementation against this plan?',
101+
initialValue: true,
102+
})
103+
if (p.isCancel(proceed) || !proceed) {
104+
throw new CancelledError()
105+
}
106+
} else {
107+
p.log.info(
108+
`Plan at \`${PLAN_REL_PATH}\` — agent will execute it as the source of truth.`,
109+
)
110+
}
143111
} else {
144-
p.log.info(
145-
`Plan at \`${PLAN_REL_PATH}\` — the agent will execute it as the source of truth.`,
146-
)
112+
// No plan on disk. Branch on flag / TTY / interactive.
113+
if (continueWithoutPlan) {
114+
await confirmContinueWithoutPlan()
115+
} else if (!isTTY) {
116+
p.log.error(
117+
`No plan at \`${PLAN_REL_PATH}\`. Run \`${cli} plan\` first, or pass --continue-without-plan to skip planning.`,
118+
)
119+
process.exit(1)
120+
} else {
121+
const choice = await p.select<'plan' | 'continue'>({
122+
message: 'No plan found. What would you like to do?',
123+
options: [
124+
{
125+
value: 'plan',
126+
label: 'Draft a plan first (recommended)',
127+
hint: `runs \`${cli} plan\` — usually 1–3 min`,
128+
},
129+
{
130+
value: 'continue',
131+
label: 'Continue without a plan',
132+
hint: 'skip the planning checkpoint',
133+
},
134+
],
135+
initialValue: 'plan',
136+
})
137+
if (p.isCancel(choice)) throw new CancelledError()
138+
139+
if (choice === 'plan') {
140+
// Lazy import avoids a circular module load between plan ↔ impl.
141+
const { planCommand } = await import('../plan/index.js')
142+
// Close the current intro frame before plan opens its own.
143+
p.outro('Handing off to `stash plan`.')
144+
await planCommand()
145+
return
146+
}
147+
148+
await confirmContinueWithoutPlan()
149+
}
147150
}
148151

149152
const agents = detectAgents(cwd, process.env)
150-
const state = buildStateFromContext(ctx, mode, agents)
153+
const state = buildStateFromContext(ctx, agents)
151154

152-
await howToProceedStep.run(state, STUB_PROVIDER)
155+
await howToProceedStep.run(state)
153156

154-
if (mode === 'plan') {
155-
p.outro(
156-
`Plan drafted at \`${PLAN_REL_PATH}\`. Review it, then run \`${cli} impl\` again to implement.`,
157-
)
158-
} else {
159-
p.outro(
160-
`Implementation handoff complete. Run \`${cli} db status\` to verify state.`,
161-
)
162-
}
157+
p.outro(
158+
`Implementation handoff complete. Run \`${cli} db status\` to verify state.`,
159+
)
163160
} catch (err) {
164161
if (err instanceof CancelledError) {
165162
p.cancel('Cancelled.')

packages/cli/src/commands/impl/steps/handoff-agents-md.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ const AGENTS_MD_REL_PATH = 'AGENTS.md'
2828
export const handoffAgentsMdStep: InitStep = {
2929
id: 'handoff-agents-md',
3030
name: 'Write AGENTS.md',
31-
async run(state: InitState, _provider: InitProvider): Promise<InitState> {
31+
async run(state: InitState, _provider?: InitProvider): Promise<InitState> {
3232
const cwd = process.cwd()
3333
const integration = state.integration ?? 'postgresql'
3434

packages/cli/src/commands/impl/steps/handoff-claude.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ const CLAUDE_INSTALL_URL = 'https://code.claude.com/docs/en/quickstart'
2121
export const handoffClaudeStep: InitStep = {
2222
id: 'handoff-claude',
2323
name: 'Hand off to Claude Code',
24-
async run(state: InitState, _provider: InitProvider): Promise<InitState> {
24+
async run(state: InitState, _provider?: InitProvider): Promise<InitState> {
2525
const cwd = process.cwd()
2626
const integration = state.integration ?? 'postgresql'
2727

packages/cli/src/commands/impl/steps/handoff-codex.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ const CODEX_INSTALL_URL = 'https://github.com/openai/codex'
2828
export const handoffCodexStep: InitStep = {
2929
id: 'handoff-codex',
3030
name: 'Hand off to Codex',
31-
async run(state: InitState, _provider: InitProvider): Promise<InitState> {
31+
async run(state: InitState, _provider?: InitProvider): Promise<InitState> {
3232
const cwd = process.cwd()
3333
const integration = state.integration ?? 'postgresql'
3434

packages/cli/src/commands/impl/steps/handoff-wizard.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ import { runWizardSpawn } from '../../wizard/index.js'
2323
export const handoffWizardStep: InitStep = {
2424
id: 'handoff-wizard',
2525
name: 'Use the CipherStash Agent',
26-
async run(state: InitState, _provider: InitProvider): Promise<InitState> {
26+
async run(state: InitState, _provider?: InitProvider): Promise<InitState> {
2727
const cwd = process.cwd()
2828
const envKeys = state.envKeys ?? []
2929

0 commit comments

Comments
 (0)