Skip to content

Commit 63fd6f0

Browse files
authored
Merge pull request #427 from cipherstash/dan/init-plan-or-implement-1
Dan/init plan or implement 1
2 parents d43f996 + e7949ea commit 63fd6f0

27 files changed

Lines changed: 1132 additions & 127 deletions

.changeset/init-plan-or-implement.md

Lines changed: 0 additions & 5 deletions
This file was deleted.

.changeset/stash-impl-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+
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: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,9 +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,
21+
implCommand,
2022
initCommand,
2123
installCommand,
24+
planCommand,
2225
statusCommand,
2326
testConnectionCommand,
2427
upgradeCommand,
@@ -74,6 +77,9 @@ ${messages.cli.usagePrefix}${STASH} <command> [options]
7477
7578
Commands:
7679
init Initialize CipherStash for your project
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
7783
auth <subcommand> Authenticate with CipherStash
7884
wizard AI-guided encryption setup (reads your codebase)
7985
@@ -104,6 +110,10 @@ Init Flags:
104110
--supabase Use Supabase-specific setup flow
105111
--drizzle Use Drizzle-specific setup flow
106112
113+
Impl Flags:
114+
--continue-without-plan Skip planning and go straight to implementation
115+
(interactively confirms before proceeding)
116+
107117
DB Flags:
108118
--force (install) Reinstall / overwrite even if already installed
109119
--dry-run (install, push, upgrade) Show what would happen without making changes
@@ -119,6 +129,10 @@ DB Flags:
119129
Examples:
120130
${STASH} init
121131
${STASH} init --supabase
132+
${STASH} plan
133+
${STASH} impl
134+
${STASH} impl --continue-without-plan
135+
${STASH} status
122136
${STASH} auth login
123137
${STASH} wizard
124138
${STASH} db install
@@ -224,7 +238,7 @@ async function runDbCommand(
224238
break
225239
}
226240
case 'status':
227-
await statusCommand({ databaseUrl })
241+
await dbStatusCommand({ databaseUrl })
228242
break
229243
case 'test-connection':
230244
await testConnectionCommand({ databaseUrl })
@@ -367,6 +381,15 @@ async function main() {
367381
case 'init':
368382
await initCommand(flags)
369383
break
384+
case 'plan':
385+
await planCommand()
386+
break
387+
case 'impl':
388+
await implCommand(flags)
389+
break
390+
case 'status':
391+
await statusCommand()
392+
break
370393
case 'auth': {
371394
const authArgs = subcommand ? [subcommand, ...commandArgs] : commandArgs
372395
await authCommand(authArgs, flags)

packages/cli/src/commands/init/__tests__/how-to-proceed.test.ts renamed to packages/cli/src/commands/impl/__tests__/how-to-proceed.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { describe, expect, it } from 'vitest'
2-
import type { AgentEnvironment } from '../detect-agents.js'
2+
import type { AgentEnvironment } from '../../init/detect-agents.js'
3+
import type { InitState } from '../../init/types.js'
34
import { buildOptions, defaultChoice } from '../steps/how-to-proceed.js'
4-
import type { InitState } from '../types.js'
55

66
function makeAgents(claudeCode: boolean, codex: boolean): AgentEnvironment {
77
return {
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
import { existsSync, readFileSync } from 'node:fs'
2+
import { resolve } from 'node:path'
3+
import * as p from '@clack/prompts'
4+
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'
7+
import { PLAN_REL_PATH } from '../init/lib/setup-prompt.js'
8+
import {
9+
CONTEXT_REL_PATH,
10+
type ContextFile,
11+
} from '../init/lib/write-context.js'
12+
import { CancelledError, type InitState } from '../init/types.js'
13+
import { detectPackageManager, runnerCommand } from '../init/utils.js'
14+
import { howToProceedStep } from './steps/how-to-proceed.js'
15+
16+
function buildStateFromContext(
17+
ctx: ContextFile,
18+
agents: AgentEnvironment,
19+
): InitState {
20+
return {
21+
integration: ctx.integration,
22+
clientFilePath: ctx.encryptionClientPath,
23+
schemas: ctx.schemas,
24+
envKeys: ctx.envKeys,
25+
stackInstalled: true,
26+
cliInstalled: true,
27+
eqlInstalled: true,
28+
agents,
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()
46+
}
47+
}
48+
49+
/**
50+
* `stash impl` — execute an encryption plan.
51+
*
52+
* Always runs in implement mode. Behaviour branches on disk state and
53+
* flags:
54+
*
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.
64+
*/
65+
export async function implCommand(flags: Record<string, boolean>) {
66+
const cwd = process.cwd()
67+
const pm = detectPackageManager()
68+
const cli = runnerCommand(pm, 'stash')
69+
70+
const ctx = readContextFile(cwd)
71+
if (!ctx) {
72+
p.log.error(
73+
`No CipherStash context found at \`${CONTEXT_REL_PATH}\`. Run \`${cli} init\` first.`,
74+
)
75+
process.exit(1)
76+
}
77+
78+
p.intro('CipherStash Implementation')
79+
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
84+
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+
}
111+
} else {
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+
}
150+
}
151+
152+
const agents = detectAgents(cwd, process.env)
153+
const state = buildStateFromContext(ctx, agents)
154+
155+
await howToProceedStep.run(state)
156+
157+
p.outro(
158+
`Implementation handoff complete. Run \`${cli} db status\` to verify state.`,
159+
)
160+
} catch (err) {
161+
if (err instanceof CancelledError) {
162+
p.cancel('Cancelled.')
163+
process.exit(0)
164+
}
165+
throw err
166+
}
167+
}

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

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
22
import { resolve } from 'node:path'
33
import * as p from '@clack/prompts'
4-
import { buildAgentsMdBody } from '../lib/build-agents-md.js'
5-
import { writeArtifacts } from '../lib/handoff-helpers.js'
6-
import { upsertManagedBlock } from '../lib/sentinel-upsert.js'
4+
import { buildAgentsMdBody } from '../../init/lib/build-agents-md.js'
5+
import { writeArtifacts } from '../../init/lib/handoff-helpers.js'
6+
import { upsertManagedBlock } from '../../init/lib/sentinel-upsert.js'
77
import {
88
CONTEXT_REL_PATH,
99
SETUP_PROMPT_REL_PATH,
10-
} from '../lib/write-context.js'
11-
import type { InitProvider, InitState, InitStep } from '../types.js'
10+
} from '../../init/lib/write-context.js'
11+
import type { InitProvider, InitState, InitStep } from '../../init/types.js'
1212

1313
const AGENTS_MD_REL_PATH = 'AGENTS.md'
1414

@@ -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/init/steps/handoff-claude.ts renamed to packages/cli/src/commands/impl/steps/handoff-claude.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
import * as p from '@clack/prompts'
2-
import { spawnAgent, writeArtifacts } from '../lib/handoff-helpers.js'
3-
import { installSkills } from '../lib/install-skills.js'
2+
import { spawnAgent, writeArtifacts } from '../../init/lib/handoff-helpers.js'
3+
import { installSkills } from '../../init/lib/install-skills.js'
44
import {
55
CONTEXT_REL_PATH,
66
SETUP_PROMPT_REL_PATH,
7-
} from '../lib/write-context.js'
8-
import type { InitProvider, InitState, InitStep } from '../types.js'
7+
} from '../../init/lib/write-context.js'
8+
import type { InitProvider, InitState, InitStep } from '../../init/types.js'
99

1010
const CLAUDE_SKILLS_DIR = '.claude/skills'
1111

@@ -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

0 commit comments

Comments
 (0)