From d43f996fb124a1e6b318d08541427f55aafef35d Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Tue, 5 May 2026 14:35:52 +1000 Subject: [PATCH 1/6] feat(cli): plan-or-implement choice in stash init MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .changeset/init-plan-or-implement.md | 5 + .../init/__tests__/how-to-proceed.test.ts | 69 +++++++ packages/cli/src/commands/init/index.ts | 2 + .../init/lib/__tests__/setup-prompt.test.ts | 84 ++++++++- .../cli/src/commands/init/lib/setup-prompt.ts | 177 +++++++++++++++++- .../src/commands/init/lib/write-context.ts | 1 + .../src/commands/init/steps/choose-mode.ts | 45 +++++ .../src/commands/init/steps/handoff-claude.ts | 6 +- .../src/commands/init/steps/handoff-codex.ts | 6 +- .../src/commands/init/steps/how-to-proceed.ts | 62 +++--- .../cli/src/commands/init/steps/next-steps.ts | 17 +- packages/cli/src/commands/init/types.ts | 11 ++ 12 files changed, 454 insertions(+), 31 deletions(-) create mode 100644 .changeset/init-plan-or-implement.md create mode 100644 packages/cli/src/commands/init/__tests__/how-to-proceed.test.ts create mode 100644 packages/cli/src/commands/init/steps/choose-mode.ts diff --git a/.changeset/init-plan-or-implement.md b/.changeset/init-plan-or-implement.md new file mode 100644 index 00000000..38d7ca52 --- /dev/null +++ b/.changeset/init-plan-or-implement.md @@ -0,0 +1,5 @@ +--- +"stash": minor +--- + +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. diff --git a/packages/cli/src/commands/init/__tests__/how-to-proceed.test.ts b/packages/cli/src/commands/init/__tests__/how-to-proceed.test.ts new file mode 100644 index 00000000..4ebfa602 --- /dev/null +++ b/packages/cli/src/commands/init/__tests__/how-to-proceed.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from 'vitest' +import type { AgentEnvironment } from '../detect-agents.js' +import { buildOptions, defaultChoice } from '../steps/how-to-proceed.js' +import type { InitState } from '../types.js' + +function makeAgents(claudeCode: boolean, codex: boolean): AgentEnvironment { + return { + cli: { claudeCode, codex }, + project: { + claudeDir: false, + claudeMd: false, + claudeSkillsDir: false, + agentsMd: false, + }, + editor: 'unknown', + } +} + +const noAgents: InitState = { agents: makeAgents(false, false) } +const claudeOnly: InitState = { agents: makeAgents(true, false) } +const codexOnly: InitState = { agents: makeAgents(false, true) } + +describe('howToProceed — buildOptions', () => { + it('offers all four targets in implement mode', () => { + const opts = buildOptions(noAgents, 'implement') + const values = opts.map((o) => o.value) + expect(values).toEqual(['claude-code', 'codex', 'wizard', 'agents-md']) + }) + + it('offers only claude-code and codex in plan mode', () => { + const opts = buildOptions(noAgents, 'plan') + const values = opts.map((o) => o.value) + expect(values).toEqual(['claude-code', 'codex']) + }) + + it('reflects detection state in hints regardless of mode', () => { + const implement = buildOptions(claudeOnly, 'implement') + const plan = buildOptions(claudeOnly, 'plan') + + const implementClaude = implement.find((o) => o.value === 'claude-code') + const planClaude = plan.find((o) => o.value === 'claude-code') + + expect(implementClaude?.hint).toMatch(/detected/) + expect(planClaude?.hint).toMatch(/detected/) + }) +}) + +describe('howToProceed — defaultChoice', () => { + it('prefers claude-code when detected', () => { + expect(defaultChoice(claudeOnly, 'implement')).toBe('claude-code') + expect(defaultChoice(claudeOnly, 'plan')).toBe('claude-code') + }) + + it('prefers codex when claude is absent and codex is detected', () => { + expect(defaultChoice(codexOnly, 'implement')).toBe('codex') + expect(defaultChoice(codexOnly, 'plan')).toBe('codex') + }) + + it('falls back to agents-md in implement mode when no CLI is detected', () => { + expect(defaultChoice(noAgents, 'implement')).toBe('agents-md') + }) + + it('falls back to claude-code in plan mode when no CLI is detected', () => { + // Plan mode never offers agents-md; claude-code is the listed default + // so the picker has a valid initialValue rather than falling through + // to a hidden option. + expect(defaultChoice(noAgents, 'plan')).toBe('claude-code') + }) +}) diff --git a/packages/cli/src/commands/init/index.ts b/packages/cli/src/commands/init/index.ts index 20c79696..6624eae9 100644 --- a/packages/cli/src/commands/init/index.ts +++ b/packages/cli/src/commands/init/index.ts @@ -4,6 +4,7 @@ import { createDrizzleProvider } from './providers/drizzle.js' import { createSupabaseProvider } from './providers/supabase.js' import { authenticateStep } from './steps/authenticate.js' import { buildSchemaStep } from './steps/build-schema.js' +import { chooseModeStep } from './steps/choose-mode.js' import { gatherContextStep } from './steps/gather-context.js' import { howToProceedStep } from './steps/how-to-proceed.js' import { installDepsStep } from './steps/install-deps.js' @@ -25,6 +26,7 @@ const STEPS = [ installDepsStep, installEqlStep, gatherContextStep, + chooseModeStep, howToProceedStep, nextStepsStep, ] diff --git a/packages/cli/src/commands/init/lib/__tests__/setup-prompt.test.ts b/packages/cli/src/commands/init/lib/__tests__/setup-prompt.test.ts index 418a4cce..c4a9f19a 100644 --- a/packages/cli/src/commands/init/lib/__tests__/setup-prompt.test.ts +++ b/packages/cli/src/commands/init/lib/__tests__/setup-prompt.test.ts @@ -10,10 +10,11 @@ const baseCtx: SetupPromptContext = { stackInstalled: true, cliInstalled: true, handoff: 'claude-code', + mode: 'implement', installedSkills: ['stash-encryption', 'stash-drizzle', 'stash-cli'], } -describe('renderSetupPrompt — orient + route', () => { +describe('renderSetupPrompt — orient + route (implement mode)', () => { it('emits integration + package manager in the header', () => { const out = renderSetupPrompt(baseCtx) expect(out).toContain('Integration: `drizzle`') @@ -139,4 +140,85 @@ describe('renderSetupPrompt — orient + route', () => { expect(out).toContain('serverExternalPackages') expect(out).toContain('@cipherstash/protect-ffi') }) + + it('directs the agent to read .cipherstash/plan.md first if it exists', () => { + // Plan mode produces .cipherstash/plan.md; if the user later runs init + // again in implement mode, the plan must be the source of truth — not + // a re-asked routing question. + const out = renderSetupPrompt(baseCtx) + expect(out).toContain('.cipherstash/plan.md') + expect(out).toMatch(/source of truth/i) + }) +}) + +describe('renderSetupPrompt — plan mode', () => { + const planCtx: SetupPromptContext = { ...baseCtx, mode: 'plan' } + + it('frames the deliverable as a plan file, not code changes', () => { + const out = renderSetupPrompt(planCtx) + expect(out).toContain('# CipherStash setup — write a plan') + expect(out).toContain('.cipherstash/plan.md') + expect(out).toMatch(/produce a plan file/i) + }) + + it('explicitly forbids mutating commands during planning', () => { + const out = renderSetupPrompt(planCtx) + expect(out).toContain('## What you must NOT do') + expect(out).toMatch(/db push/) + expect(out).toMatch(/encrypt backfill/) + expect(out).toMatch(/encrypt cutover/) + expect(out).toMatch(/encrypt drop/) + }) + + it('allows read-only inspection commands', () => { + const out = renderSetupPrompt(planCtx) + expect(out).toMatch(/db status/) + expect(out).toMatch(/Read-only/i) + }) + + it('tells the agent to offer copying the plan into docs/plans when it exists', () => { + const out = renderSetupPrompt(planCtx) + expect(out).toContain('docs/plans/') + expect(out).toMatch(/offer to copy/i) + }) + + it('lists project-specific risk classes the plan must cover', () => { + const out = renderSetupPrompt(planCtx) + expect(out).toMatch(/bundler exclusion/i) + expect(out).toMatch(/top-level-await/i) + expect(out).toMatch(/partial CipherStash/i) + }) + + it('requires the plan to identify which lifecycle path applies per column', () => { + const out = renderSetupPrompt(planCtx) + expect(out).toMatch(/path 1/i) + expect(out).toMatch(/path 3/i) + expect(out).toMatch(/four-deploy sequence/i) + }) + + it('still tells the agent its first response is an orientation message, not action', () => { + const out = renderSetupPrompt(planCtx) + expect(out).toContain('## Your first response') + expect(out).toMatch(/orientation message/i) + }) + + it('references concrete table/column names from .cipherstash/context.json', () => { + const out = renderSetupPrompt(planCtx) + expect(out).toContain('.cipherstash/context.json') + }) + + it('preserves the integration + package manager header in plan mode', () => { + const out = renderSetupPrompt(planCtx) + expect(out).toContain('Integration: `drizzle`') + expect(out).toContain('Package manager: `pnpm`') + }) + + it('does not emit the implement-mode flow walkthroughs verbatim', () => { + // Plan mode summarises the two options in one line each rather than + // restating the full numbered walkthroughs; the walkthroughs live in + // the implement prompt. + const out = renderSetupPrompt(planCtx) + expect(out).not.toContain('### Add a new encrypted column') + expect(out).not.toContain('### Migrate an existing column to encrypted') + }) }) diff --git a/packages/cli/src/commands/init/lib/setup-prompt.ts b/packages/cli/src/commands/init/lib/setup-prompt.ts index 3225d908..54d9057d 100644 --- a/packages/cli/src/commands/init/lib/setup-prompt.ts +++ b/packages/cli/src/commands/init/lib/setup-prompt.ts @@ -1,6 +1,8 @@ -import type { HandoffChoice, Integration } from '../types.js' +import type { HandoffChoice, InitMode, Integration } from '../types.js' import { type PackageManager, runnerCommand } from '../utils.js' +export const PLAN_REL_PATH = '.cipherstash/plan.md' + export interface SetupPromptContext { integration: Integration encryptionClientPath: string @@ -12,6 +14,11 @@ export interface SetupPromptContext { /** Which handoff option the user picked. Lets us tailor wording (e.g. the * Codex prompt names AGENTS.md, Claude names the skill). */ handoff: HandoffChoice + /** Whether the agent should produce a plan first or implement directly. + * Drives the entire prompt body — plan-mode tells the agent its task is + * to produce `.cipherstash/plan.md`; implement-mode is the original + * orient-and-route action prompt. */ + mode: InitMode /** Names of skills `stash init` copied into the project (e.g. * `stash-encryption`, `stash-drizzle`, `stash-cli`). The action prompt * names them so the agent knows which references to consult. Empty for @@ -133,11 +140,24 @@ function renderSkillIndex(installedSkills: string[]): string { } /** - * Render the project-specific action prompt. + * Render the project-specific action prompt. Dispatches to the plan-mode or + * implement-mode renderer based on `ctx.mode`. Both produce the same shape + * (orient → describe options → tell the agent what its first response is) + * but the *deliverable* differs: plan-mode writes `.cipherstash/plan.md`, + * implement-mode edits code and runs lifecycle commands. + */ +export function renderSetupPrompt(ctx: SetupPromptContext): string { + return ctx.mode === 'plan' + ? renderPlanPrompt(ctx) + : renderImplementPrompt(ctx) +} + +/** + * Render the implementation action prompt. * - * This is the file the agent reads first after `stash init` hands off. It - * does NOT prescribe a fixed sequence of edits — the agent doesn't yet know - * what the user wants. Instead the prompt: + * This is the file the agent reads first after `stash init` hands off in + * implement mode. It does NOT prescribe a fixed sequence of edits — the + * agent doesn't yet know what the user wants. Instead the prompt: * * 1. Confirms what setup is complete. * 2. Names the skills loaded and what each is for. @@ -148,8 +168,13 @@ function renderSkillIndex(installedSkills: string[]): string { * 4. Tells the agent its FIRST response should be a routing question, not * an action. * 5. Lists the "stop and ask" rules that override flow mechanics. + * + * If `.cipherstash/plan.md` exists (a previous `stash init --plan` run), + * the prompt directs the agent to read it first and treat it as the + * source of truth for routing — the user has already done the + * orientation pass. */ -export function renderSetupPrompt(ctx: SetupPromptContext): string { +export function renderImplementPrompt(ctx: SetupPromptContext): string { const cli = runnerCommand(ctx.packageManager, 'stash') const migration = migrationCommands(ctx.integration, ctx.packageManager) @@ -181,6 +206,12 @@ export function renderSetupPrompt(ctx: SetupPromptContext): string { '', '`stash init` has finished its mechanical setup. Your job is **not** to start editing schema or running migrations immediately. Your job is to **orient the user with the two real options for encrypting a column, then ask which one they want before touching anything**. Pick concrete table/column names from `.cipherstash/context.json` when describing the options so the user can recognise their own data.', '', + '## Existing plan', + '', + `Before anything else, check whether \`${PLAN_REL_PATH}\` exists. If it does, the user has already done a planning pass with you (or another agent). Read it as the source of truth for which path applies, which tables/columns are in scope, and the deploy ordering — do not re-ask the routing question. Confirm with the user that the plan is still current, then execute it. If the plan looks stale (the schema or context has moved on), say so and propose specific updates rather than starting fresh.`, + '', + `If \`${PLAN_REL_PATH}\` does **not** exist, proceed with the orient-and-route flow below.`, + '', '## What `stash init` already did', '', ...done, @@ -256,3 +287,137 @@ export function renderSetupPrompt(ctx: SetupPromptContext): string { return sections.join('\n') } + +/** + * Render the planning action prompt. + * + * Plan-mode tells the agent its task is to produce a reviewable plan file + * at `.cipherstash/plan.md` — no schema edits, no migrations, no `db push`, + * no `encrypt *` mutations during this phase. Read-only inspection + * (`stash db status`, schema grep, file reads) is fine. + * + * The plan covers: which table(s) and column(s) to protect, which + * lifecycle path applies per column (path 1 = new column / path 3 = + * migrate existing), the deploy ordering for path-3 columns, any + * project-specific risks, and the exact CLI sequence to execute when the + * user is ready to implement. + */ +export function renderPlanPrompt(ctx: SetupPromptContext): string { + const cli = runnerCommand(ctx.packageManager, 'stash') + + const done: string[] = [ + checked('Authenticated to CipherStash and selected a workspace'), + checked(`Detected integration: \`${ctx.integration}\``), + checked( + `Wrote a placeholder encryption client at \`${ctx.encryptionClientPath}\` (a small file showing the encryption-client patterns; the user's real Drizzle/Supabase schema files remain authoritative)`, + ), + ] + if (ctx.stackInstalled) { + done.push(checked('Installed `@cipherstash/stack` (runtime)')) + } + if (ctx.cliInstalled) { + done.push(checked('Installed `stash` (CLI, dev dep)')) + } + if (ctx.eqlInstalled) { + done.push( + checked( + 'Installed the EQL extension and `cipherstash.cs_migrations` into the database', + ), + ) + } + + const sections: string[] = [ + '# CipherStash setup — write a plan', + '', + `Integration: \`${ctx.integration}\` · Package manager: \`${ctx.packageManager}\``, + '', + `\`stash init\` has finished its mechanical setup. The user picked **plan-first** — your job is to produce a reviewable plan at \`${PLAN_REL_PATH}\`, **not** to make schema or code changes. Read-only inspection (\`${cli} db status\`, reading schema files, grepping the codebase) is encouraged. Schema edits, migrations, \`${cli} db push\`, and any \`${cli} encrypt *\` mutations are deferred to the implementation phase that runs after the user reviews and approves the plan.`, + '', + '## What `stash init` already did', + '', + ...done, + '', + '## Skills loaded', + '', + `Reusable rules and worked examples live in ${rulesLocation(ctx.handoff)}:`, + '', + renderSkillIndex(ctx.installedSkills), + '', + 'Read the skills before answering API or pattern questions. The doctrine in `AGENTS.md` (or its inlined equivalent) covers the invariants that apply regardless of which flow you take — never log plaintext, never `.notNull()` on creation, etc.', + '', + '## The two options', + '', + 'There are exactly two supported ways to encrypt a column. The plan must identify which one applies per column:', + '', + '- **Add a new encrypted column** — the column does not yet exist; no plaintext predecessor to preserve. Single-deploy.', + '- **Migrate an existing column** — the column already exists with live data. Staged across four deploys: schema-add + dual-write → backfill run → cutover + read-from-encrypted → drop. The lifecycle is irreducible; the plan must spell out the deploy ordering so reviewers can sequence PRs correctly.', + '', + 'Converting a populated column in place is **not** supported — any "just swap the type" approach corrupts data. If the user asks for that, the plan must explain why and route them to the migrate-existing flow.', + '', + '## Your task: produce a plan file', + '', + `Write \`${PLAN_REL_PATH}\` covering, for each table+column the user wants to protect:`, + '', + bullet( + "The table and column names (extract candidates from `.cipherstash/context.json`; if the user hasn't yet said which columns matter, ask before writing the plan).", + ), + bullet( + 'Which lifecycle path applies (path 1 = add new / path 3 = migrate existing). Justify briefly — the user should be able to verify the choice without reading the skill.', + ), + bullet( + 'For path-3 columns: the four-deploy sequence with one line per deploy on what changes (schema, app code, lifecycle command). Call out which step the deploy gates on (e.g. "deploy 2 must wait until deploy 1 is live in production before backfill is safe").', + ), + bullet( + `Project-specific risks. Common ones: bundler exclusion not yet configured (Next.js / webpack / Vite), top-level-await in the placeholder encryption client breaks non-Next contexts, existing partial CipherStash state (run \`${cli} db status\` and note any pre-existing encrypted columns or pending configs).`, + ), + bullet( + `The exact CLI sequence to execute when the user is ready to implement (the \`${cli} encrypt {backfill,cutover,drop}\` invocations with concrete \`--table\` / \`--column\` values).`, + ), + bullet( + "Open questions for the user — anything you can't determine from the schema, context.json, or the skills. Better to surface than guess.", + ), + '', + `After writing the plan, also offer to copy it into \`docs/plans/cipherstash-encryption.md\` if the project has a \`docs/plans/\` directory — many teams version their plans alongside the code. Don't copy without asking. If \`docs/plans/\` does not exist, leave the plan at \`${PLAN_REL_PATH}\` and don't create the directory.`, + '', + '## What you must NOT do', + '', + bullet( + 'Edit schema files, application code, or migration files. The plan describes future changes — it does not perform them.', + ), + bullet( + `Run \`${cli} db push\`, \`${cli} encrypt backfill\`, \`${cli} encrypt cutover\`, \`${cli} encrypt drop\`, \`${cli} db activate\`, or any other state-mutating command.`, + ), + bullet( + 'Run schema migrations (`drizzle-kit migrate`, `supabase migration up`, `prisma migrate`, etc.).', + ), + bullet( + 'Modify the placeholder encryption client beyond what is required to read it.', + ), + '', + `Read-only commands (\`${cli} db status\`, file reads, greps, \`${cli} doctor\` if available) are fine and encouraged — the plan is more useful when grounded in the actual current state.`, + '', + '## Your first response', + '', + `Send the user a short orientation message before writing anything. Confirm setup is complete, list the skills loaded with one-line purposes, summarise the two options in your own words, and end with a clear question — *"Which table(s) and column(s) would you like the plan to cover? You can name them or describe what you're trying to protect."* Reference concrete tables/columns from \`.cipherstash/context.json\` when it helps.`, + '', + `Once the user answers, write \`${PLAN_REL_PATH}\`. Show the plan in chat as well so the user can react inline. After the plan is approved, tell the user how to proceed to implementation — re-run \`${cli} init\` and pick **Go straight to implementation**, or paste the implementation prompt manually.`, + '', + '## Stop and ask the user when', + '', + bullet( + "The user asks to convert a populated column in place. Explain why it doesn't work and offer the migrate-existing-column flow instead.", + ), + bullet( + "A column the user names is already encrypted (`eql_v2_encrypted` udt) but with a different EQL config than they've described. This is the post-cutover re-encryption case (`stash encrypt update`, not yet shipped) — surface it in the plan as a flagged risk.", + ), + bullet( + 'You discover existing partial CipherStash setup that disagrees with what the user is describing — someone else may have run `stash init` earlier with different choices. Note this in the plan and ask the user to clarify before writing prescriptive steps.', + ), + bullet( + "The user names columns that don't appear in `.cipherstash/context.json` or in the schema files you can see. Confirm the names rather than guessing.", + ), + '', + ] + + return sections.join('\n') +} diff --git a/packages/cli/src/commands/init/lib/write-context.ts b/packages/cli/src/commands/init/lib/write-context.ts index 74d85006..1bffbdff 100644 --- a/packages/cli/src/commands/init/lib/write-context.ts +++ b/packages/cli/src/commands/init/lib/write-context.ts @@ -154,6 +154,7 @@ export function buildSetupPromptContext( stackInstalled: state.stackInstalled ?? false, cliInstalled: state.cliInstalled ?? false, handoff, + mode: state.mode ?? 'implement', installedSkills, } } diff --git a/packages/cli/src/commands/init/steps/choose-mode.ts b/packages/cli/src/commands/init/steps/choose-mode.ts new file mode 100644 index 00000000..fd6d90e6 --- /dev/null +++ b/packages/cli/src/commands/init/steps/choose-mode.ts @@ -0,0 +1,45 @@ +import * as p from '@clack/prompts' +import { + CancelledError, + type InitMode, + type InitProvider, + type InitState, + type InitStep, +} from '../types.js' + +/** + * Ask the user whether the agent handoff should produce a 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 reviewable plan checkpoint is the safer default. + * + * Plan mode currently routes only to Claude Code or Codex. The next step + * (`how-to-proceed`) reads `state.mode` and filters its target list + * accordingly. + */ +export const chooseModeStep: InitStep = { + id: 'choose-mode', + name: 'Choose mode', + async run(state: InitState, _provider: InitProvider): Promise { + const mode = await p.select({ + message: 'Plan first, or go straight to implementation?', + options: [ + { + value: 'plan', + label: 'Write a plan first (recommended)', + hint: 'agent produces .cipherstash/plan.md for review — Claude Code or Codex only', + }, + { + value: 'implement', + label: 'Go straight to implementation', + hint: 'agent makes schema and code changes directly', + }, + ], + initialValue: 'plan', + }) + + if (p.isCancel(mode)) throw new CancelledError() + + return { ...state, mode } + }, +} diff --git a/packages/cli/src/commands/init/steps/handoff-claude.ts b/packages/cli/src/commands/init/steps/handoff-claude.ts index ce018e32..cdb5c47b 100644 --- a/packages/cli/src/commands/init/steps/handoff-claude.ts +++ b/packages/cli/src/commands/init/steps/handoff-claude.ts @@ -34,7 +34,11 @@ export const handoffClaudeStep: InitStep = { writeArtifacts(cwd, state, 'claude-code', installed) - const launchPrompt = `Read ${SETUP_PROMPT_REL_PATH} and complete the setup steps. The installed skills under ${CLAUDE_SKILLS_DIR}/ have the rules; ${CONTEXT_REL_PATH} has the project facts.` + const mode = state.mode ?? 'implement' + const launchPrompt = + mode === 'plan' + ? `Read ${SETUP_PROMPT_REL_PATH} and produce the planning deliverable it describes. The installed skills under ${CLAUDE_SKILLS_DIR}/ have the rules; ${CONTEXT_REL_PATH} has the project facts. Do not edit code or run mutating commands during this phase.` + : `Read ${SETUP_PROMPT_REL_PATH} and complete the setup steps. The installed skills under ${CLAUDE_SKILLS_DIR}/ have the rules; ${CONTEXT_REL_PATH} has the project facts.` if (!state.agents?.cli.claudeCode) { p.note( diff --git a/packages/cli/src/commands/init/steps/handoff-codex.ts b/packages/cli/src/commands/init/steps/handoff-codex.ts index dd243d40..0dad13e1 100644 --- a/packages/cli/src/commands/init/steps/handoff-codex.ts +++ b/packages/cli/src/commands/init/steps/handoff-codex.ts @@ -53,7 +53,11 @@ export const handoffCodexStep: InitStep = { writeArtifacts(cwd, state, 'codex', installed) - const launchPrompt = `Read ${SETUP_PROMPT_REL_PATH} and complete the setup steps. AGENTS.md has the durable rules; the skills under ${CODEX_SKILLS_DIR}/ have the API details; ${CONTEXT_REL_PATH} has the project facts.` + const mode = state.mode ?? 'implement' + const launchPrompt = + mode === 'plan' + ? `Read ${SETUP_PROMPT_REL_PATH} and produce the planning deliverable it describes. AGENTS.md has the durable rules; the skills under ${CODEX_SKILLS_DIR}/ have the API details; ${CONTEXT_REL_PATH} has the project facts. Do not edit code or run mutating commands during this phase.` + : `Read ${SETUP_PROMPT_REL_PATH} and complete the setup steps. AGENTS.md has the durable rules; the skills under ${CODEX_SKILLS_DIR}/ have the API details; ${CONTEXT_REL_PATH} has the project facts.` if (!state.agents?.cli.codex) { p.note( diff --git a/packages/cli/src/commands/init/steps/how-to-proceed.ts b/packages/cli/src/commands/init/steps/how-to-proceed.ts index 46d85499..b184dbce 100644 --- a/packages/cli/src/commands/init/steps/how-to-proceed.ts +++ b/packages/cli/src/commands/init/steps/how-to-proceed.ts @@ -2,6 +2,7 @@ import * as p from '@clack/prompts' import { CancelledError, type HandoffChoice, + type InitMode, type InitProvider, type InitState, type InitStep, @@ -12,27 +13,33 @@ import { handoffCodexStep } from './handoff-codex.js' import { handoffWizardStep } from './handoff-wizard.js' /** - * Pick the default option in the four-way menu. + * Pick the default option in the menu. * * Detected CLIs win — Claude Code first, then Codex. Otherwise we default to * the AGENTS.md path because that's the broadest "works without anything else * installed" option. The CipherStash Agent option is positioned as a fallback * (slow first run, requires the wizard package on top of the CLI) and is - * never selected by default. + * never selected by default. In plan mode, AGENTS.md and wizard aren't + * offered — the default falls back to `claude-code`. */ -function defaultChoice(state: InitState): HandoffChoice { +export function defaultChoice(state: InitState, mode: InitMode): HandoffChoice { if (state.agents?.cli.claudeCode) return 'claude-code' if (state.agents?.cli.codex) return 'codex' - return 'agents-md' + return mode === 'plan' ? 'claude-code' : 'agents-md' } /** - * Build the option list for the four-way menu. Hints reflect detection state - * — a missing CLI doesn't hide the option (handoff steps still write the + * Build the option list for the menu. Hints reflect detection state — a + * missing CLI doesn't hide the option (handoff steps still write the * rules files and print install instructions), it just nudges the user. + * + * In plan mode we only offer Claude Code and Codex. AGENTS.md and the + * wizard don't yet have planning prompt templates, so suppress them + * entirely rather than degrading silently. */ -function buildOptions( +export function buildOptions( state: InitState, + mode: InitMode, ): { value: HandoffChoice; label: string; hint?: string }[] { const claudeHint = state.agents?.cli.claudeCode ? 'claude detected — will launch interactively' @@ -41,7 +48,7 @@ function buildOptions( ? 'codex detected — will launch interactively' : 'codex not on PATH — files will be written, install link shown' - return [ + const options: { value: HandoffChoice; label: string; hint?: string }[] = [ { value: 'claude-code', label: 'Hand off to Claude Code', @@ -52,27 +59,40 @@ function buildOptions( label: 'Hand off to Codex', hint: codexHint, }, - { - value: 'wizard', - label: 'Use the CipherStash Agent', - hint: 'our hosted setup wizard (runs `stash wizard`)', - }, - { - value: 'agents-md', - label: 'Write AGENTS.md', - hint: 'works with Cursor, Windsurf, Cline, and more', - }, ] + + if (mode === 'implement') { + options.push( + { + value: 'wizard', + label: 'Use the CipherStash Agent', + hint: 'our hosted setup wizard (runs `stash wizard`)', + }, + { + value: 'agents-md', + label: 'Write AGENTS.md', + hint: 'works with Cursor, Windsurf, Cline, and more', + }, + ) + } + + return options } export const howToProceedStep: InitStep = { id: 'how-to-proceed', name: 'How to proceed', async run(state: InitState, provider: InitProvider): Promise { + const mode: InitMode = state.mode ?? 'implement' + const message = + mode === 'plan' + ? 'Which agent should write the plan?' + : 'How would you like to finish setup?' + const choice = await p.select({ - message: 'How would you like to finish setup?', - options: buildOptions(state), - initialValue: defaultChoice(state), + message, + options: buildOptions(state, mode), + initialValue: defaultChoice(state, mode), }) if (p.isCancel(choice)) throw new CancelledError() diff --git a/packages/cli/src/commands/init/steps/next-steps.ts b/packages/cli/src/commands/init/steps/next-steps.ts index bf8e290f..4a3ef65d 100644 --- a/packages/cli/src/commands/init/steps/next-steps.ts +++ b/packages/cli/src/commands/init/steps/next-steps.ts @@ -1,12 +1,27 @@ import * as p from '@clack/prompts' +import { PLAN_REL_PATH } from '../lib/setup-prompt.js' import type { InitProvider, InitState, InitStep } from '../types.js' -import { detectPackageManager } from '../utils.js' +import { detectPackageManager, runnerCommand } from '../utils.js' export const nextStepsStep: InitStep = { id: 'next-steps', name: 'Next steps', async run(state: InitState, provider: InitProvider): Promise { const pm = detectPackageManager() + + if (state.mode === 'plan') { + const cli = runnerCommand(pm, 'stash') + p.note( + [ + `1. Review ${PLAN_REL_PATH} (the agent should have written it, or be writing it now).`, + `2. When the plan looks right, re-run \`${cli} init\` and pick "Go straight to implementation".`, + '3. Quickstart: https://cipherstash.com/docs/stack/quickstart', + ].join('\n'), + 'Next Steps — plan mode', + ) + return state + } + const steps = provider.getNextSteps(state, pm) const numbered = steps.map((s, i) => `${i + 1}. ${s}`).join('\n') p.note(numbered, 'Next Steps') diff --git a/packages/cli/src/commands/init/types.ts b/packages/cli/src/commands/init/types.ts index 6e957fc8..def734f7 100644 --- a/packages/cli/src/commands/init/types.ts +++ b/packages/cli/src/commands/init/types.ts @@ -20,6 +20,14 @@ export interface SchemaDef { export type HandoffChoice = 'claude-code' | 'codex' | 'agents-md' | 'wizard' +/** + * Whether the handoff agent should produce a plan first (`plan`) or go + * straight to implementation (`implement`). `plan` is the default — it + * gives the user a reviewable plan file at `.cipherstash/plan.md` before + * any code or schema changes happen. + */ +export type InitMode = 'plan' | 'implement' + export interface InitState { authenticated?: boolean /** Resolved DATABASE_URL. Set by resolve-database; threaded into every @@ -50,6 +58,9 @@ export interface InitState { agents?: AgentEnvironment /** What the user picked at the "how to proceed" step. */ handoff?: HandoffChoice + /** Whether the handoff should plan first or implement directly. Set by + * choose-mode. Defaults to `plan` when the user accepts the recommendation. */ + mode?: InitMode } export interface InitStep { From e16b282b8f77ad8f860acd1cbebeb0e8cae4c147 Mon Sep 17 00:00:00 2001 From: CJ Brewer Date: Tue, 5 May 2026 12:31:51 -0600 Subject: [PATCH 2/6] feat: introduce stash impl We split and added to gamify the encryption setup with a save-point between scaffolding and the agent handoff, plus an opt-in prompt at end of init to chain straight into plan mode. --- .changeset/init-plan-or-implement.md | 5 - .changeset/stash-impl-command.md | 5 + packages/cli/src/bin/stash.ts | 11 ++ .../impl/__tests__/derive-mode.test.ts | 85 +++++++++ .../__tests__/how-to-proceed.test.ts | 4 +- packages/cli/src/commands/impl/index.ts | 170 ++++++++++++++++++ .../{init => impl}/steps/handoff-agents-md.ts | 10 +- .../{init => impl}/steps/handoff-claude.ts | 8 +- .../{init => impl}/steps/handoff-codex.ts | 12 +- .../{init => impl}/steps/handoff-wizard.ts | 10 +- .../{init => impl}/steps/how-to-proceed.ts | 2 +- packages/cli/src/commands/index.ts | 1 + packages/cli/src/commands/init/index.ts | 60 ++++++- .../src/commands/init/steps/choose-mode.ts | 45 ----- .../cli/src/commands/init/steps/next-steps.ts | 30 ---- packages/cli/src/commands/init/types.ts | 5 +- 16 files changed, 350 insertions(+), 113 deletions(-) delete mode 100644 .changeset/init-plan-or-implement.md create mode 100644 .changeset/stash-impl-command.md create mode 100644 packages/cli/src/commands/impl/__tests__/derive-mode.test.ts rename packages/cli/src/commands/{init => impl}/__tests__/how-to-proceed.test.ts (95%) create mode 100644 packages/cli/src/commands/impl/index.ts rename packages/cli/src/commands/{init => impl}/steps/handoff-agents-md.ts (85%) rename packages/cli/src/commands/{init => impl}/steps/handoff-claude.ts (89%) rename packages/cli/src/commands/{init => impl}/steps/handoff-codex.ts (87%) rename packages/cli/src/commands/{init => impl}/steps/handoff-wizard.ts (86%) rename packages/cli/src/commands/{init => impl}/steps/how-to-proceed.ts (99%) delete mode 100644 packages/cli/src/commands/init/steps/choose-mode.ts delete mode 100644 packages/cli/src/commands/init/steps/next-steps.ts diff --git a/.changeset/init-plan-or-implement.md b/.changeset/init-plan-or-implement.md deleted file mode 100644 index 38d7ca52..00000000 --- a/.changeset/init-plan-or-implement.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"stash": minor ---- - -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. diff --git a/.changeset/stash-impl-command.md b/.changeset/stash-impl-command.md new file mode 100644 index 00000000..43bc73be --- /dev/null +++ b/.changeset/stash-impl-command.md @@ -0,0 +1,5 @@ +--- +"stash": minor +--- + +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. diff --git a/packages/cli/src/bin/stash.ts b/packages/cli/src/bin/stash.ts index 2a46ade6..1c6b43d3 100644 --- a/packages/cli/src/bin/stash.ts +++ b/packages/cli/src/bin/stash.ts @@ -17,6 +17,7 @@ import * as p from '@clack/prompts' import { authCommand, envCommand, + implCommand, initCommand, installCommand, statusCommand, @@ -74,6 +75,7 @@ ${messages.cli.usagePrefix}${STASH} [options] Commands: init Initialize CipherStash for your project + impl Draft an encryption plan (or implement, if a plan exists) auth Authenticate with CipherStash wizard AI-guided encryption setup (reads your codebase) @@ -104,6 +106,10 @@ Init Flags: --supabase Use Supabase-specific setup flow --drizzle Use Drizzle-specific setup flow +Impl Flags: + --yolo Skip the planning checkpoint and go straight to implementation + (interactively confirms before proceeding) + DB Flags: --force (install) Reinstall / overwrite even if already installed --dry-run (install, push, upgrade) Show what would happen without making changes @@ -119,6 +125,8 @@ DB Flags: Examples: ${STASH} init ${STASH} init --supabase + ${STASH} impl + ${STASH} impl --yolo ${STASH} auth login ${STASH} wizard ${STASH} db install @@ -367,6 +375,9 @@ async function main() { case 'init': await initCommand(flags) break + case 'impl': + await implCommand(flags) + break case 'auth': { const authArgs = subcommand ? [subcommand, ...commandArgs] : commandArgs await authCommand(authArgs, flags) diff --git a/packages/cli/src/commands/impl/__tests__/derive-mode.test.ts b/packages/cli/src/commands/impl/__tests__/derive-mode.test.ts new file mode 100644 index 00000000..0dc2614e --- /dev/null +++ b/packages/cli/src/commands/impl/__tests__/derive-mode.test.ts @@ -0,0 +1,85 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { deriveMode, readContextFile } from '../index.js' + +let cwd: string + +beforeEach(() => { + cwd = mkdtempSync(join(tmpdir(), 'stash-impl-')) +}) + +afterEach(() => { + rmSync(cwd, { recursive: true, force: true }) +}) + +function writePlan(): void { + mkdirSync(join(cwd, '.cipherstash'), { recursive: true }) + writeFileSync(join(cwd, '.cipherstash', 'plan.md'), '# plan\n', 'utf-8') +} + +function writeContext(payload: Record): void { + mkdirSync(join(cwd, '.cipherstash'), { recursive: true }) + writeFileSync( + join(cwd, '.cipherstash', 'context.json'), + JSON.stringify(payload), + 'utf-8', + ) +} + +describe('deriveMode (no --yolo)', () => { + it('returns plan when no plan file exists', async () => { + expect(await deriveMode(cwd, false)).toBe('plan') + }) + + it('returns implement when plan file exists', async () => { + writePlan() + expect(await deriveMode(cwd, false)).toBe('implement') + }) +}) + +describe('deriveMode (--yolo)', () => { + it('is a no-op when a plan already exists — no prompt, returns implement', async () => { + // The interactive confirmation must NOT fire when a plan exists, since + // the safety checkpoint (the plan itself) has already happened. + writePlan() + expect(await deriveMode(cwd, true)).toBe('implement') + }) + + // The `--yolo + no plan` path is interactive (p.confirm). Covered by + // manual smoke tests; mocking @clack/prompts isn't worth the churn here. +}) + +describe('readContextFile', () => { + it('returns undefined when context.json is missing', () => { + expect(readContextFile(cwd)).toBeUndefined() + }) + + it('returns the parsed context when present', () => { + writeContext({ + cliVersion: '0.0.0', + integration: 'drizzle', + encryptionClientPath: './src/encryption/index.ts', + packageManager: 'pnpm', + installCommand: 'pnpm add @cipherstash/stack', + envKeys: [], + schemas: [], + installedSkills: [], + generatedAt: '2026-01-01T00:00:00.000Z', + }) + const ctx = readContextFile(cwd) + expect(ctx?.integration).toBe('drizzle') + expect(ctx?.packageManager).toBe('pnpm') + }) + + it('returns undefined on malformed JSON rather than throwing', () => { + mkdirSync(join(cwd, '.cipherstash'), { recursive: true }) + writeFileSync( + join(cwd, '.cipherstash', 'context.json'), + '{ not json', + 'utf-8', + ) + expect(readContextFile(cwd)).toBeUndefined() + }) +}) diff --git a/packages/cli/src/commands/init/__tests__/how-to-proceed.test.ts b/packages/cli/src/commands/impl/__tests__/how-to-proceed.test.ts similarity index 95% rename from packages/cli/src/commands/init/__tests__/how-to-proceed.test.ts rename to packages/cli/src/commands/impl/__tests__/how-to-proceed.test.ts index 4ebfa602..df9c9082 100644 --- a/packages/cli/src/commands/init/__tests__/how-to-proceed.test.ts +++ b/packages/cli/src/commands/impl/__tests__/how-to-proceed.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' -import type { AgentEnvironment } from '../detect-agents.js' +import type { AgentEnvironment } from '../../init/detect-agents.js' +import type { InitState } from '../../init/types.js' import { buildOptions, defaultChoice } from '../steps/how-to-proceed.js' -import type { InitState } from '../types.js' function makeAgents(claudeCode: boolean, codex: boolean): AgentEnvironment { return { diff --git a/packages/cli/src/commands/impl/index.ts b/packages/cli/src/commands/impl/index.ts new file mode 100644 index 00000000..e1f26483 --- /dev/null +++ b/packages/cli/src/commands/impl/index.ts @@ -0,0 +1,170 @@ +import { existsSync, readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import * as p from '@clack/prompts' +import { type AgentEnvironment, detectAgents } from '../init/detect-agents.js' +import { PLAN_REL_PATH } from '../init/lib/setup-prompt.js' +import { + CONTEXT_REL_PATH, + type ContextFile, +} from '../init/lib/write-context.js' +import { + CancelledError, + type InitMode, + type InitProvider, + type InitState, +} from '../init/types.js' +import { detectPackageManager, runnerCommand } from '../init/utils.js' +import { howToProceedStep } from './steps/how-to-proceed.js' + +/** + * The handoff steps in `impl/steps/handoff-*.ts` accept an `InitProvider` + * but ignore it (their `run` signatures take `_provider`). The provider + * abstraction belongs to the `init` flow, where it picks intro copy and + * default next-steps. `stash impl` reads everything it needs from + * `.cipherstash/context.json` instead, so a stub keeps the type signature + * happy without pretending impl has provider-specific behaviour. + */ +const STUB_PROVIDER: InitProvider = { + name: 'impl', + introMessage: '', + getNextSteps: () => [], +} + +export function readContextFile(cwd: string): ContextFile | undefined { + const path = resolve(cwd, CONTEXT_REL_PATH) + if (!existsSync(path)) return undefined + try { + return JSON.parse(readFileSync(path, 'utf-8')) as ContextFile + } catch { + return undefined + } +} + +/** + * Derive the impl mode from disk state and flags. + * + * no `--yolo`, plan missing → `plan` (default — the safer path) + * no `--yolo`, plan exists → `implement` (the plan is the source of truth) + * `--yolo`, plan missing → `implement` after interactive confirmation + * `--yolo`, plan exists → `implement`; `--yolo` is a no-op once a plan + * exists, since the safety checkpoint already + * fired + * + * The interactive confirmation when `--yolo` is the only thing standing + * between the user and ~45–60 min of agent-driven implementation. Cheap + * to ask, expensive to skip by accident. + */ +export async function deriveMode( + cwd: string, + yolo: boolean, +): Promise { + const planExists = existsSync(resolve(cwd, PLAN_REL_PATH)) + + if (yolo) { + if (planExists) { + p.log.info( + `Plan exists at \`${PLAN_REL_PATH}\` — \`--yolo\` is a no-op when a plan is already in place.`, + ) + return 'implement' + } + const confirmed = await p.confirm({ + message: + 'Skip the planning checkpoint and go straight to implementation?', + initialValue: false, + }) + if (p.isCancel(confirmed) || !confirmed) { + throw new CancelledError() + } + return 'implement' + } + + return planExists ? 'implement' : 'plan' +} + +function buildStateFromContext( + ctx: ContextFile, + mode: InitMode, + agents: AgentEnvironment, +): InitState { + return { + integration: ctx.integration, + clientFilePath: ctx.encryptionClientPath, + schemas: ctx.schemas, + envKeys: ctx.envKeys, + // After init has run, these are true. The pre-flight context.json + // check above is the gate — if init didn't complete, context.json + // wouldn't exist and we'd have already errored. + stackInstalled: true, + cliInstalled: true, + eqlInstalled: true, + agents, + mode, + } +} + +/** + * `stash impl` — the agent handoff phase. + * + * Pre-flights `.cipherstash/context.json` (errors with a `stash init` + * pointer if missing). Derives plan-vs-implement mode from disk state and + * the `--yolo` flag, then dispatches to a handoff target via + * `howToProceedStep`. Modes: + * + * - `plan` (default when no `.cipherstash/plan.md` exists): the agent + * produces a reviewable plan file. The user reads it, then re-runs + * `stash impl` to execute. + * - `implement` (default when a plan exists): the agent executes the + * plan as the source of truth. + * - `--yolo` forces `implement` even with no plan, after an interactive + * confirmation prompt. + */ +export async function implCommand(flags: Record) { + const cwd = process.cwd() + const pm = detectPackageManager() + const cli = runnerCommand(pm, 'stash') + + const ctx = readContextFile(cwd) + if (!ctx) { + p.log.error( + `No CipherStash context found at \`${CONTEXT_REL_PATH}\`. Run \`${cli} init\` first.`, + ) + process.exit(1) + } + + p.intro('CipherStash Implementation') + + try { + const mode = await deriveMode(cwd, flags.yolo === true) + + if (mode === 'plan') { + p.log.info( + `No plan at \`${PLAN_REL_PATH}\`. The agent will draft one for you to review.`, + ) + } else { + p.log.info( + `Plan at \`${PLAN_REL_PATH}\` — the agent will execute it as the source of truth.`, + ) + } + + const agents = detectAgents(cwd, process.env) + const state = buildStateFromContext(ctx, mode, agents) + + await howToProceedStep.run(state, STUB_PROVIDER) + + if (mode === 'plan') { + p.outro( + `Plan drafted at \`${PLAN_REL_PATH}\`. Review it, then run \`${cli} impl\` again to implement.`, + ) + } else { + p.outro( + `Implementation handoff complete. Run \`${cli} db status\` to verify state.`, + ) + } + } catch (err) { + if (err instanceof CancelledError) { + p.cancel('Cancelled.') + process.exit(0) + } + throw err + } +} diff --git a/packages/cli/src/commands/init/steps/handoff-agents-md.ts b/packages/cli/src/commands/impl/steps/handoff-agents-md.ts similarity index 85% rename from packages/cli/src/commands/init/steps/handoff-agents-md.ts rename to packages/cli/src/commands/impl/steps/handoff-agents-md.ts index 281112c3..24091652 100644 --- a/packages/cli/src/commands/init/steps/handoff-agents-md.ts +++ b/packages/cli/src/commands/impl/steps/handoff-agents-md.ts @@ -1,14 +1,14 @@ import { existsSync, readFileSync, writeFileSync } from 'node:fs' import { resolve } from 'node:path' import * as p from '@clack/prompts' -import { buildAgentsMdBody } from '../lib/build-agents-md.js' -import { writeArtifacts } from '../lib/handoff-helpers.js' -import { upsertManagedBlock } from '../lib/sentinel-upsert.js' +import { buildAgentsMdBody } from '../../init/lib/build-agents-md.js' +import { writeArtifacts } from '../../init/lib/handoff-helpers.js' +import { upsertManagedBlock } from '../../init/lib/sentinel-upsert.js' import { CONTEXT_REL_PATH, SETUP_PROMPT_REL_PATH, -} from '../lib/write-context.js' -import type { InitProvider, InitState, InitStep } from '../types.js' +} from '../../init/lib/write-context.js' +import type { InitProvider, InitState, InitStep } from '../../init/types.js' const AGENTS_MD_REL_PATH = 'AGENTS.md' diff --git a/packages/cli/src/commands/init/steps/handoff-claude.ts b/packages/cli/src/commands/impl/steps/handoff-claude.ts similarity index 89% rename from packages/cli/src/commands/init/steps/handoff-claude.ts rename to packages/cli/src/commands/impl/steps/handoff-claude.ts index cdb5c47b..01ad6f1a 100644 --- a/packages/cli/src/commands/init/steps/handoff-claude.ts +++ b/packages/cli/src/commands/impl/steps/handoff-claude.ts @@ -1,11 +1,11 @@ import * as p from '@clack/prompts' -import { spawnAgent, writeArtifacts } from '../lib/handoff-helpers.js' -import { installSkills } from '../lib/install-skills.js' +import { spawnAgent, writeArtifacts } from '../../init/lib/handoff-helpers.js' +import { installSkills } from '../../init/lib/install-skills.js' import { CONTEXT_REL_PATH, SETUP_PROMPT_REL_PATH, -} from '../lib/write-context.js' -import type { InitProvider, InitState, InitStep } from '../types.js' +} from '../../init/lib/write-context.js' +import type { InitProvider, InitState, InitStep } from '../../init/types.js' const CLAUDE_SKILLS_DIR = '.claude/skills' diff --git a/packages/cli/src/commands/init/steps/handoff-codex.ts b/packages/cli/src/commands/impl/steps/handoff-codex.ts similarity index 87% rename from packages/cli/src/commands/init/steps/handoff-codex.ts rename to packages/cli/src/commands/impl/steps/handoff-codex.ts index 0dad13e1..3ccff834 100644 --- a/packages/cli/src/commands/init/steps/handoff-codex.ts +++ b/packages/cli/src/commands/impl/steps/handoff-codex.ts @@ -1,15 +1,15 @@ import { existsSync, readFileSync, writeFileSync } from 'node:fs' import { resolve } from 'node:path' import * as p from '@clack/prompts' -import { buildAgentsMdBody } from '../lib/build-agents-md.js' -import { spawnAgent, writeArtifacts } from '../lib/handoff-helpers.js' -import { installSkills } from '../lib/install-skills.js' -import { upsertManagedBlock } from '../lib/sentinel-upsert.js' +import { buildAgentsMdBody } from '../../init/lib/build-agents-md.js' +import { spawnAgent, writeArtifacts } from '../../init/lib/handoff-helpers.js' +import { installSkills } from '../../init/lib/install-skills.js' +import { upsertManagedBlock } from '../../init/lib/sentinel-upsert.js' import { CONTEXT_REL_PATH, SETUP_PROMPT_REL_PATH, -} from '../lib/write-context.js' -import type { InitProvider, InitState, InitStep } from '../types.js' +} from '../../init/lib/write-context.js' +import type { InitProvider, InitState, InitStep } from '../../init/types.js' const AGENTS_MD_REL_PATH = 'AGENTS.md' const CODEX_SKILLS_DIR = '.codex/skills' diff --git a/packages/cli/src/commands/init/steps/handoff-wizard.ts b/packages/cli/src/commands/impl/steps/handoff-wizard.ts similarity index 86% rename from packages/cli/src/commands/init/steps/handoff-wizard.ts rename to packages/cli/src/commands/impl/steps/handoff-wizard.ts index 160b24c5..2bfefa18 100644 --- a/packages/cli/src/commands/init/steps/handoff-wizard.ts +++ b/packages/cli/src/commands/impl/steps/handoff-wizard.ts @@ -1,12 +1,12 @@ import { resolve } from 'node:path' import * as p from '@clack/prompts' -import { runWizardSpawn } from '../../wizard/index.js' import { CONTEXT_REL_PATH, buildContextFile, writeContextFile, -} from '../lib/write-context.js' -import type { InitProvider, InitState, InitStep } from '../types.js' +} from '../../init/lib/write-context.js' +import type { InitProvider, InitState, InitStep } from '../../init/types.js' +import { runWizardSpawn } from '../../wizard/index.js' /** * Hand off to the CipherStash Agent (the in-house wizard package). @@ -14,8 +14,8 @@ import type { InitProvider, InitState, InitStep } from '../types.js' * Writes `.cipherstash/context.json` so the wizard has the same prepared * facts the other handoffs use, then spawns the wizard via `runWizardSpawn` * — the same path the top-level `stash wizard` subcommand takes, but with - * the exit code surfaced rather than `process.exit`-ed so init can finish - * its own outro and `next-steps` step. + * the exit code surfaced rather than `process.exit`-ed so `stash impl` can + * finish its own outro. * * No skills are installed here. The wizard fetches its own agent-side * prompt from the gateway and runs its own `maybeInstallSkills` flow. diff --git a/packages/cli/src/commands/init/steps/how-to-proceed.ts b/packages/cli/src/commands/impl/steps/how-to-proceed.ts similarity index 99% rename from packages/cli/src/commands/init/steps/how-to-proceed.ts rename to packages/cli/src/commands/impl/steps/how-to-proceed.ts index b184dbce..8f80f6f8 100644 --- a/packages/cli/src/commands/init/steps/how-to-proceed.ts +++ b/packages/cli/src/commands/impl/steps/how-to-proceed.ts @@ -6,7 +6,7 @@ import { type InitProvider, type InitState, type InitStep, -} from '../types.js' +} from '../../init/types.js' import { handoffAgentsMdStep } from './handoff-agents-md.js' import { handoffClaudeStep } from './handoff-claude.js' import { handoffCodexStep } from './handoff-codex.js' diff --git a/packages/cli/src/commands/index.ts b/packages/cli/src/commands/index.ts index 17154eec..0c06af10 100644 --- a/packages/cli/src/commands/index.ts +++ b/packages/cli/src/commands/index.ts @@ -3,6 +3,7 @@ export { statusCommand } from './db/status.js' export { testConnectionCommand } from './db/test-connection.js' export { upgradeCommand } from './db/upgrade.js' export { authCommand } from './auth/index.js' +export { implCommand } from './impl/index.js' export { initCommand } from './init/index.js' export { envCommand } from './env/index.js' export { wizardCommand } from './wizard/index.js' diff --git a/packages/cli/src/commands/init/index.ts b/packages/cli/src/commands/init/index.ts index 6624eae9..25ef5d4d 100644 --- a/packages/cli/src/commands/init/index.ts +++ b/packages/cli/src/commands/init/index.ts @@ -1,24 +1,34 @@ import * as p from '@clack/prompts' +import { implCommand } from '../impl/index.js' import { createBaseProvider } from './providers/base.js' import { createDrizzleProvider } from './providers/drizzle.js' import { createSupabaseProvider } from './providers/supabase.js' import { authenticateStep } from './steps/authenticate.js' import { buildSchemaStep } from './steps/build-schema.js' -import { chooseModeStep } from './steps/choose-mode.js' import { gatherContextStep } from './steps/gather-context.js' -import { howToProceedStep } from './steps/how-to-proceed.js' import { installDepsStep } from './steps/install-deps.js' import { installEqlStep } from './steps/install-eql.js' -import { nextStepsStep } from './steps/next-steps.js' import { resolveDatabaseStep } from './steps/resolve-database.js' import type { InitProvider, InitState } from './types.js' import { CancelledError } from './types.js' +import { detectPackageManager, runnerCommand } from './utils.js' const PROVIDER_MAP: Record InitProvider> = { supabase: createSupabaseProvider, drizzle: createDrizzleProvider, } +/** + * `stash init` does scaffold-once work only: auth, database connection, + * schema introspection, dep install, EQL install, context gathering. It + * exits at a clean checkpoint. The agent handoff (plan-or-implement) is + * the responsibility of `stash impl`, which reads `.cipherstash/context.json` + * and dispatches to the right handoff target. + * + * Splitting these gives the user a save-point between bootstrap and + * implementation — they can review what init produced before committing + * to the longer agent-driven phase. + */ const STEPS = [ authenticateStep, resolveDatabaseStep, @@ -26,9 +36,6 @@ const STEPS = [ installDepsStep, installEqlStep, gatherContextStep, - chooseModeStep, - howToProceedStep, - nextStepsStep, ] function resolveProvider(flags: Record): InitProvider { @@ -41,7 +48,10 @@ function resolveProvider(flags: Record): InitProvider { } // Use the first matched provider for UX (intro message, connection options, etc.) - const provider = PROVIDER_MAP[matchedKeys[0]]!() + // matchedKeys[0] is guaranteed by the length check above; the optional chain + // is just to satisfy biome's no-non-null-assertion rule. + const factory = PROVIDER_MAP[matchedKeys[0]] + const provider = factory ? factory() : createBaseProvider() // Combine all matched flag names for the referrer if (matchedKeys.length > 1) { @@ -63,7 +73,41 @@ export async function initCommand(flags: Record) { for (const step of STEPS) { state = await step.run(state, provider) } - p.outro('Setup complete!') + + const pm = detectPackageManager() + const cli = runnerCommand(pm, 'stash') + const checkmarks: string[] = [ + '✓ Authenticated to CipherStash', + '✓ Database connection verified', + '✓ Encryption client scaffolded', + ] + if (state.stackInstalled) { + checkmarks.push('✓ `@cipherstash/stack` installed') + } + if (state.cliInstalled) checkmarks.push('✓ `stash` CLI installed') + if (state.eqlInstalled) checkmarks.push('✓ EQL extension installed') + + p.note(checkmarks.join('\n'), 'Setup complete') + + // Offer to chain straight into `stash impl` so first-time users don't + // have to copy/paste the next command. Default-yes for low friction; + // answering N (or running non-interactively) preserves the explicit + // two-command flow. Only prompts in plan mode by definition — at this + // point the user has no plan yet, so impl will draft one (~1–3 min) + // rather than dropping them into the hour-long implementation phase. + if (process.stdout.isTTY) { + const proceed = await p.confirm({ + message: `Continue to \`${cli} impl\` now to draft your encryption plan?`, + initialValue: true, + }) + if (!p.isCancel(proceed) && proceed) { + p.outro('Setup complete — handing off to `stash impl`.') + await implCommand({}) + return + } + } + + p.outro(`Next: run \`${cli} impl\` to draft your encryption plan.`) } catch (err) { if (err instanceof CancelledError) { p.cancel('Setup cancelled.') diff --git a/packages/cli/src/commands/init/steps/choose-mode.ts b/packages/cli/src/commands/init/steps/choose-mode.ts deleted file mode 100644 index fd6d90e6..00000000 --- a/packages/cli/src/commands/init/steps/choose-mode.ts +++ /dev/null @@ -1,45 +0,0 @@ -import * as p from '@clack/prompts' -import { - CancelledError, - type InitMode, - type InitProvider, - type InitState, - type InitStep, -} from '../types.js' - -/** - * Ask the user whether the agent handoff should produce a 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 reviewable plan checkpoint is the safer default. - * - * Plan mode currently routes only to Claude Code or Codex. The next step - * (`how-to-proceed`) reads `state.mode` and filters its target list - * accordingly. - */ -export const chooseModeStep: InitStep = { - id: 'choose-mode', - name: 'Choose mode', - async run(state: InitState, _provider: InitProvider): Promise { - const mode = await p.select({ - message: 'Plan first, or go straight to implementation?', - options: [ - { - value: 'plan', - label: 'Write a plan first (recommended)', - hint: 'agent produces .cipherstash/plan.md for review — Claude Code or Codex only', - }, - { - value: 'implement', - label: 'Go straight to implementation', - hint: 'agent makes schema and code changes directly', - }, - ], - initialValue: 'plan', - }) - - if (p.isCancel(mode)) throw new CancelledError() - - return { ...state, mode } - }, -} diff --git a/packages/cli/src/commands/init/steps/next-steps.ts b/packages/cli/src/commands/init/steps/next-steps.ts deleted file mode 100644 index 4a3ef65d..00000000 --- a/packages/cli/src/commands/init/steps/next-steps.ts +++ /dev/null @@ -1,30 +0,0 @@ -import * as p from '@clack/prompts' -import { PLAN_REL_PATH } from '../lib/setup-prompt.js' -import type { InitProvider, InitState, InitStep } from '../types.js' -import { detectPackageManager, runnerCommand } from '../utils.js' - -export const nextStepsStep: InitStep = { - id: 'next-steps', - name: 'Next steps', - async run(state: InitState, provider: InitProvider): Promise { - const pm = detectPackageManager() - - if (state.mode === 'plan') { - const cli = runnerCommand(pm, 'stash') - p.note( - [ - `1. Review ${PLAN_REL_PATH} (the agent should have written it, or be writing it now).`, - `2. When the plan looks right, re-run \`${cli} init\` and pick "Go straight to implementation".`, - '3. Quickstart: https://cipherstash.com/docs/stack/quickstart', - ].join('\n'), - 'Next Steps — plan mode', - ) - return state - } - - const steps = provider.getNextSteps(state, pm) - const numbered = steps.map((s, i) => `${i + 1}. ${s}`).join('\n') - p.note(numbered, 'Next Steps') - return state - }, -} diff --git a/packages/cli/src/commands/init/types.ts b/packages/cli/src/commands/init/types.ts index def734f7..76e12d3f 100644 --- a/packages/cli/src/commands/init/types.ts +++ b/packages/cli/src/commands/init/types.ts @@ -58,8 +58,9 @@ export interface InitState { agents?: AgentEnvironment /** What the user picked at the "how to proceed" step. */ handoff?: HandoffChoice - /** Whether the handoff should plan first or implement directly. Set by - * choose-mode. Defaults to `plan` when the user accepts the recommendation. */ + /** Whether the handoff should plan first or implement directly. Derived + * from disk state by `stash impl`: if `.cipherstash/plan.md` exists, the + * mode is `implement`; otherwise `plan`. `--yolo` forces `implement`. */ mode?: InitMode } From db163e14e62e82db91cb347c57f6cc5ab38bdf0a Mon Sep 17 00:00:00 2001 From: CJ Brewer Date: Tue, 5 May 2026 12:49:40 -0600 Subject: [PATCH 3/6] feat: stash status --- .changeset/stash-impl-plan-summary.md | 5 + .changeset/stash-status-command.md | 5 + packages/cli/src/bin/stash.ts | 8 +- packages/cli/src/commands/impl/index.ts | 35 +++- packages/cli/src/commands/index.ts | 3 +- .../init/lib/__tests__/parse-plan.test.ts | 169 ++++++++++++++++ .../init/lib/__tests__/setup-prompt.test.ts | 15 ++ .../cli/src/commands/init/lib/parse-plan.ts | 113 +++++++++++ .../cli/src/commands/init/lib/setup-prompt.ts | 18 ++ .../commands/status/__tests__/status.test.ts | 181 ++++++++++++++++++ packages/cli/src/commands/status/index.ts | 144 ++++++++++++++ 11 files changed, 692 insertions(+), 4 deletions(-) create mode 100644 .changeset/stash-impl-plan-summary.md create mode 100644 .changeset/stash-status-command.md create mode 100644 packages/cli/src/commands/init/lib/__tests__/parse-plan.test.ts create mode 100644 packages/cli/src/commands/init/lib/parse-plan.ts create mode 100644 packages/cli/src/commands/status/__tests__/status.test.ts create mode 100644 packages/cli/src/commands/status/index.ts diff --git a/.changeset/stash-impl-plan-summary.md b/.changeset/stash-impl-plan-summary.md new file mode 100644 index 00000000..c4947d01 --- /dev/null +++ b/.changeset/stash-impl-plan-summary.md @@ -0,0 +1,5 @@ +--- +"stash": minor +--- + +`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 `` 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. diff --git a/.changeset/stash-status-command.md b/.changeset/stash-status-command.md new file mode 100644 index 00000000..d368c009 --- /dev/null +++ b/.changeset/stash-status-command.md @@ -0,0 +1,5 @@ +--- +"stash": minor +--- + +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. diff --git a/packages/cli/src/bin/stash.ts b/packages/cli/src/bin/stash.ts index 1c6b43d3..f3f4d52a 100644 --- a/packages/cli/src/bin/stash.ts +++ b/packages/cli/src/bin/stash.ts @@ -16,6 +16,7 @@ import * as p from '@clack/prompts' // Commands that depend on @cipherstash/stack are lazy-loaded in the switch below. import { authCommand, + dbStatusCommand, envCommand, implCommand, initCommand, @@ -76,6 +77,7 @@ ${messages.cli.usagePrefix}${STASH} [options] Commands: init Initialize CipherStash for your project impl Draft an encryption plan (or implement, if a plan exists) + status Show project lifecycle: init done? plan written? implementation engaged? auth Authenticate with CipherStash wizard AI-guided encryption setup (reads your codebase) @@ -127,6 +129,7 @@ Examples: ${STASH} init --supabase ${STASH} impl ${STASH} impl --yolo + ${STASH} status ${STASH} auth login ${STASH} wizard ${STASH} db install @@ -232,7 +235,7 @@ async function runDbCommand( break } case 'status': - await statusCommand({ databaseUrl }) + await dbStatusCommand({ databaseUrl }) break case 'test-connection': await testConnectionCommand({ databaseUrl }) @@ -378,6 +381,9 @@ async function main() { case 'impl': await implCommand(flags) break + case 'status': + await statusCommand() + break case 'auth': { const authArgs = subcommand ? [subcommand, ...commandArgs] : commandArgs await authCommand(authArgs, flags) diff --git a/packages/cli/src/commands/impl/index.ts b/packages/cli/src/commands/impl/index.ts index e1f26483..5d148a65 100644 --- a/packages/cli/src/commands/impl/index.ts +++ b/packages/cli/src/commands/impl/index.ts @@ -2,6 +2,7 @@ import { existsSync, readFileSync } from 'node:fs' import { resolve } from 'node:path' import * as p from '@clack/prompts' import { type AgentEnvironment, detectAgents } from '../init/detect-agents.js' +import { parsePlanSummary, renderPlanSummary } from '../init/lib/parse-plan.js' import { PLAN_REL_PATH } from '../init/lib/setup-prompt.js' import { CONTEXT_REL_PATH, @@ -136,14 +137,44 @@ export async function implCommand(flags: Record) { try { const mode = await deriveMode(cwd, flags.yolo === true) + const planPath = resolve(cwd, PLAN_REL_PATH) + const planExists = existsSync(planPath) + if (mode === 'plan') { p.log.info( `No plan at \`${PLAN_REL_PATH}\`. The agent will draft one for you to review.`, ) - } else { + } else if (planExists && process.stdout.isTTY) { + // Plan-summary checkpoint: the last save point before the agent + // commits to the (potentially hour-long) implementation phase. Parse + // the structured summary block the planning agent was instructed to + // emit; fall back to a soft "open it in your editor" panel if the + // block is missing (older plans, or an agent that didn't follow the + // schema). Default-yes on the confirm — the user is here to proceed. + const summary = parsePlanSummary(readFileSync(planPath, 'utf-8')) + if (summary) { + p.note(renderPlanSummary(summary), 'Plan summary') + } else { + p.note( + `Plan at \`${PLAN_REL_PATH}\` doesn't include a machine-readable summary. Open it in your editor before proceeding.`, + 'Plan ready', + ) + } + const proceed = await p.confirm({ + message: 'Proceed with implementation against this plan?', + initialValue: true, + }) + if (p.isCancel(proceed) || !proceed) { + throw new CancelledError() + } + } else if (planExists) { + // Non-TTY: skip the confirm; assume the caller (CI, pipe) wants to proceed. p.log.info( - `Plan at \`${PLAN_REL_PATH}\` — the agent will execute it as the source of truth.`, + `Plan at \`${PLAN_REL_PATH}\` — agent will execute it as the source of truth.`, ) + } else { + // Implement without a plan — `--yolo` already confirmed earlier in deriveMode. + p.log.info('No plan exists — implementing from scratch (--yolo).') } const agents = detectAgents(cwd, process.env) diff --git a/packages/cli/src/commands/index.ts b/packages/cli/src/commands/index.ts index 0c06af10..43e9a2d4 100644 --- a/packages/cli/src/commands/index.ts +++ b/packages/cli/src/commands/index.ts @@ -1,9 +1,10 @@ export { installCommand } from './db/install.js' -export { statusCommand } from './db/status.js' +export { statusCommand as dbStatusCommand } from './db/status.js' export { testConnectionCommand } from './db/test-connection.js' export { upgradeCommand } from './db/upgrade.js' export { authCommand } from './auth/index.js' export { implCommand } from './impl/index.js' export { initCommand } from './init/index.js' export { envCommand } from './env/index.js' +export { statusCommand } from './status/index.js' export { wizardCommand } from './wizard/index.js' diff --git a/packages/cli/src/commands/init/lib/__tests__/parse-plan.test.ts b/packages/cli/src/commands/init/lib/__tests__/parse-plan.test.ts new file mode 100644 index 00000000..652048f4 --- /dev/null +++ b/packages/cli/src/commands/init/lib/__tests__/parse-plan.test.ts @@ -0,0 +1,169 @@ +import { describe, expect, it } from 'vitest' +import { + type PlanSummary, + parsePlanSummary, + renderPlanSummary, +} from '../parse-plan.js' + +describe('parsePlanSummary', () => { + it('returns undefined when no summary block is present', () => { + const md = '# CipherStash Encryption Plan\n\nNo summary here.\n' + expect(parsePlanSummary(md)).toBeUndefined() + }) + + it('parses a well-formed summary block', () => { + const md = ` + +# CipherStash Encryption Plan +` + const summary = parsePlanSummary(md) + expect(summary).toBeDefined() + expect(summary?.columns).toHaveLength(2) + expect(summary?.columns[0]).toEqual({ + table: 'users', + column: 'email', + path: 'new', + }) + }) + + it('returns undefined for malformed JSON inside the block', () => { + const md = `` + expect(parsePlanSummary(md)).toBeUndefined() + }) + + it('returns undefined when shape does not match (missing columns)', () => { + const md = `` + expect(parsePlanSummary(md)).toBeUndefined() + }) + + it('rejects entries with an unknown path value', () => { + const md = `` + expect(parsePlanSummary(md)).toBeUndefined() + }) + + it('rejects entries with empty table or column strings', () => { + const empty = `` + expect(parsePlanSummary(empty)).toBeUndefined() + }) + + it('tolerates extra unknown fields without dropping the parse', () => { + // Future-proofing — agents may include estimated-deploys or other + // ancillary keys. The parser should ignore them, not fail. + const md = `` + const summary = parsePlanSummary(md) + expect(summary?.columns).toHaveLength(1) + }) + + it('finds the block even with surrounding whitespace and extra newlines', () => { + const md = ` + + + +# Plan +` + expect(parsePlanSummary(md)?.columns[0]?.path).toBe('migrate') + }) +}) + +describe('renderPlanSummary', () => { + function summary(columns: PlanSummary['columns']): PlanSummary { + return { columns } + } + + it('reports column and table counts', () => { + const out = renderPlanSummary( + summary([ + { table: 'users', column: 'email', path: 'new' }, + { table: 'users', column: 'phone', path: 'migrate' }, + { table: 'orders', column: 'notes', path: 'migrate' }, + ]), + ) + expect(out).toContain('3 columns across 2 tables') + }) + + it('uses singular forms when counts are 1', () => { + const out = renderPlanSummary( + summary([{ table: 'users', column: 'email', path: 'new' }]), + ) + expect(out).toContain('1 column across 1 table') + expect(out).not.toContain('1 columns') + expect(out).not.toContain('1 tables') + }) + + it('describes each column with its path', () => { + const out = renderPlanSummary( + summary([ + { table: 'users', column: 'email', path: 'new' }, + { table: 'users', column: 'phone', path: 'migrate' }, + ]), + ) + expect(out).toContain('users.email') + expect(out).toContain('users.phone') + expect(out).toContain('add new encrypted column') + expect(out).toContain('migrate existing column') + }) + + it('mentions the staged 4-deploy lifecycle when any column is migrate-existing', () => { + const out = renderPlanSummary( + summary([ + { table: 'users', column: 'email', path: 'new' }, + { table: 'users', column: 'phone', path: 'migrate' }, + ]), + ) + expect(out).toMatch(/staged across 4 deploys/i) + expect(out).toMatch(/schema-add → backfill → cutover → drop/) + }) + + it('reports a single-deploy implementation when all columns are additive', () => { + const out = renderPlanSummary( + summary([ + { table: 'users', column: 'email', path: 'new' }, + { table: 'users', column: 'phone', path: 'new' }, + ]), + ) + expect(out).toContain('single-deploy') + expect(out).not.toMatch(/4 deploys/) + }) + + it('does not multiply deploy count by migrate-column count (deploys batch)', () => { + // 3 migrate columns is still 4 deploys — schema-add covers all twins, + // one backfill, one cutover, one drop. The renderer must not say "12 + // deploys" or anything similar. + const out = renderPlanSummary( + summary([ + { table: 'users', column: 'a', path: 'migrate' }, + { table: 'users', column: 'b', path: 'migrate' }, + { table: 'users', column: 'c', path: 'migrate' }, + ]), + ) + expect(out).toContain('4 deploys') + expect(out).not.toContain('12 deploys') + }) +}) diff --git a/packages/cli/src/commands/init/lib/__tests__/setup-prompt.test.ts b/packages/cli/src/commands/init/lib/__tests__/setup-prompt.test.ts index c4a9f19a..fa1509b2 100644 --- a/packages/cli/src/commands/init/lib/__tests__/setup-prompt.test.ts +++ b/packages/cli/src/commands/init/lib/__tests__/setup-prompt.test.ts @@ -207,6 +207,21 @@ describe('renderSetupPrompt — plan mode', () => { expect(out).toContain('.cipherstash/context.json') }) + it('instructs the agent to begin the plan with a machine-readable summary block', () => { + // `stash impl` parses this block to render a confirmation panel before + // launching implementation. If the agent forgets it, plan-summary + // gracefully degrades — but the prompt still has to ask for it so + // most plans get a structured summary. + const out = renderSetupPrompt(planCtx) + expect(out).toContain('cipherstash:plan-summary') + expect(out).toContain('"columns"') + // The instruction shows the union form `"new" | "migrate"`; both + // values must appear so the agent knows what to choose between. + expect(out).toContain('"new"') + expect(out).toContain('"migrate"') + expect(out).toMatch(/at the very top of the file/i) + }) + it('preserves the integration + package manager header in plan mode', () => { const out = renderSetupPrompt(planCtx) expect(out).toContain('Integration: `drizzle`') diff --git a/packages/cli/src/commands/init/lib/parse-plan.ts b/packages/cli/src/commands/init/lib/parse-plan.ts new file mode 100644 index 00000000..3328a34d --- /dev/null +++ b/packages/cli/src/commands/init/lib/parse-plan.ts @@ -0,0 +1,113 @@ +/** + * Parse and render `.cipherstash/plan.md` summary blocks. + * + * The agent is instructed (in `renderPlanPrompt`) to begin the plan file + * with an HTML-comment block carrying a structured JSON summary: + * + * + * + * `stash impl` parses this block to render a confirmation panel before + * dispatching to the implementation handoff. Plans without the block (or + * with a malformed one) fall back to a soft "open the plan in your editor" + * message — never an error. Older plans pre-dating this feature are still + * usable. + */ + +export type PlanPath = 'new' | 'migrate' + +export interface PlanColumn { + table: string + column: string + path: PlanPath +} + +export interface PlanSummary { + columns: PlanColumn[] +} + +const SUMMARY_BLOCK_RE = // + +function isPlanColumn(x: unknown): x is PlanColumn { + if (!x || typeof x !== 'object') return false + const c = x as Record + return ( + typeof c.table === 'string' && + c.table.length > 0 && + typeof c.column === 'string' && + c.column.length > 0 && + (c.path === 'new' || c.path === 'migrate') + ) +} + +function isPlanSummary(x: unknown): x is PlanSummary { + if (!x || typeof x !== 'object') return false + const obj = x as Record + return Array.isArray(obj.columns) && obj.columns.every(isPlanColumn) +} + +/** + * Extract the machine-readable plan summary, or `undefined` if the plan + * has no summary block (or one that doesn't match the schema). Never + * throws — malformed input is treated as "no summary." + */ +export function parsePlanSummary(content: string): PlanSummary | undefined { + const match = content.match(SUMMARY_BLOCK_RE) + if (!match) return undefined + try { + const parsed = JSON.parse(match[1]) as unknown + if (!isPlanSummary(parsed)) return undefined + return parsed + } catch { + return undefined + } +} + +const COLUMN_LABEL_WIDTH = 20 + +/** + * Render the plan summary as the body of a `p.note` panel. + * + * 3 columns across 2 tables + * + * ◇ users.email add new encrypted column + * ◇ users.phone migrate existing column + * ◇ orders.notes migrate existing column + * + * Includes migrate-existing columns — implementation is staged across + * 4 deploys (schema-add → backfill → cutover → drop). + * + * Deploys are reported as a flat 4 (not 4 per migrate column) because the + * lifecycle batches columns: one schema-add deploy covers every twin, one + * backfill covers every column, etc. + */ +export function renderPlanSummary(summary: PlanSummary): string { + const tables = new Set(summary.columns.map((c) => c.table)) + const migrateCount = summary.columns.filter( + (c) => c.path === 'migrate', + ).length + + const colCount = summary.columns.length + const tableCount = tables.size + + const header = `${colCount} column${colCount === 1 ? '' : 's'} across ${tableCount} table${tableCount === 1 ? '' : 's'}` + + const rows = summary.columns.map((c) => { + const desc = + c.path === 'new' ? 'add new encrypted column' : 'migrate existing column' + return `◇ ${`${c.table}.${c.column}`.padEnd(COLUMN_LABEL_WIDTH)} ${desc}` + }) + + const footer = + migrateCount > 0 + ? `Includes migrate-existing column${migrateCount === 1 ? '' : 's'} — implementation is staged across 4 deploys (schema-add → backfill → cutover → drop).` + : 'All columns are additive — single-deploy implementation.' + + return [header, '', ...rows, '', footer].join('\n') +} diff --git a/packages/cli/src/commands/init/lib/setup-prompt.ts b/packages/cli/src/commands/init/lib/setup-prompt.ts index 54d9057d..57ac6193 100644 --- a/packages/cli/src/commands/init/lib/setup-prompt.ts +++ b/packages/cli/src/commands/init/lib/setup-prompt.ts @@ -358,6 +358,24 @@ export function renderPlanPrompt(ctx: SetupPromptContext): string { '', `Write \`${PLAN_REL_PATH}\` covering, for each table+column the user wants to protect:`, '', + bullet( + '**A machine-readable summary block at the very top of the file**, before any heading or prose. `stash impl` parses this to render a confirmation panel before launching implementation. Use this exact shape (valid JSON, single block, no other content inside the comment):', + ), + '', + ' ```', + ' ', + ' ```', + '', + ` \`path\` is \`"new"\` for additive columns (no plaintext predecessor) and \`"migrate"\` for columns that already exist with live data. The block must remain in sync with the prose that follows; if you revise the plan, regenerate the summary.`, + '', + 'Then, the prose plan covers:', + '', bullet( "The table and column names (extract candidates from `.cipherstash/context.json`; if the user hasn't yet said which columns matter, ask before writing the plan).", ), diff --git a/packages/cli/src/commands/status/__tests__/status.test.ts b/packages/cli/src/commands/status/__tests__/status.test.ts new file mode 100644 index 00000000..2b4d1ae0 --- /dev/null +++ b/packages/cli/src/commands/status/__tests__/status.test.ts @@ -0,0 +1,181 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { buildStages, nextAction, readProjectStatus } from '../index.js' + +let cwd: string + +beforeEach(() => { + cwd = mkdtempSync(join(tmpdir(), 'stash-status-')) +}) + +afterEach(() => { + rmSync(cwd, { recursive: true, force: true }) +}) + +function writeContext(payload: Record): void { + mkdirSync(join(cwd, '.cipherstash'), { recursive: true }) + writeFileSync( + join(cwd, '.cipherstash', 'context.json'), + JSON.stringify(payload), + 'utf-8', + ) +} + +function writePlan(): void { + mkdirSync(join(cwd, '.cipherstash'), { recursive: true }) + writeFileSync(join(cwd, '.cipherstash', 'plan.md'), '# plan\n', 'utf-8') +} + +function writeSetupPrompt(): void { + mkdirSync(join(cwd, '.cipherstash'), { recursive: true }) + writeFileSync( + join(cwd, '.cipherstash', 'setup-prompt.md'), + '# prompt\n', + 'utf-8', + ) +} + +const sampleContext = { + cliVersion: '0.0.0', + integration: 'drizzle' as const, + encryptionClientPath: './src/encryption/index.ts', + packageManager: 'pnpm' as const, + installCommand: 'pnpm add @cipherstash/stack', + envKeys: [], + schemas: [ + { tableName: 'users', columns: [] }, + { tableName: 'orders', columns: [] }, + ], + installedSkills: [], + generatedAt: '2026-01-01T00:00:00.000Z', +} + +describe('readProjectStatus', () => { + it('reports a virgin project as uninitialized', () => { + const status = readProjectStatus(cwd) + expect(status.initialized).toBe(false) + expect(status.planExists).toBe(false) + expect(status.agentEngaged).toBe(false) + }) + + it('reports init-only state when only context.json exists', () => { + writeContext(sampleContext) + const status = readProjectStatus(cwd) + expect(status.initialized).toBe(true) + expect(status.context?.integration).toBe('drizzle') + expect(status.planExists).toBe(false) + expect(status.agentEngaged).toBe(false) + }) + + it('reports plan written once plan.md exists', () => { + writeContext(sampleContext) + writePlan() + const status = readProjectStatus(cwd) + expect(status.planExists).toBe(true) + }) + + it('reports agentEngaged when setup-prompt.md exists', () => { + writeContext(sampleContext) + writeSetupPrompt() + const status = readProjectStatus(cwd) + expect(status.agentEngaged).toBe(true) + }) + + it('treats malformed context.json as not-initialized rather than throwing', () => { + mkdirSync(join(cwd, '.cipherstash'), { recursive: true }) + writeFileSync( + join(cwd, '.cipherstash', 'context.json'), + '{ not json', + 'utf-8', + ) + const status = readProjectStatus(cwd) + expect(status.initialized).toBe(false) + }) +}) + +describe('buildStages', () => { + it('marks every stage pending in a virgin project', () => { + const stages = buildStages(readProjectStatus(cwd), 'pnpm dlx stash') + expect(stages.map((s) => s.status)).toEqual([ + 'pending', + 'pending', + 'pending', + ]) + // Init detail nudges the user to begin. + expect(stages[0].detail).toMatch(/init/) + }) + + it('marks Initialized done and shows integration + table count when context exists', () => { + writeContext(sampleContext) + const stages = buildStages(readProjectStatus(cwd), 'pnpm dlx stash') + expect(stages[0].status).toBe('done') + expect(stages[0].detail).toContain('drizzle') + expect(stages[0].detail).toContain('pnpm') + expect(stages[0].detail).toContain('2 tables') + }) + + it('uses singular "table" for a one-table project', () => { + writeContext({ + ...sampleContext, + schemas: [{ tableName: 'x', columns: [] }], + }) + const stages = buildStages(readProjectStatus(cwd), 'pnpm dlx stash') + expect(stages[0].detail).toContain('1 table') + expect(stages[0].detail).not.toContain('1 tables') + }) + + it('marks Plan written done and shows the plan path when plan exists', () => { + writeContext(sampleContext) + writePlan() + const stages = buildStages(readProjectStatus(cwd), 'pnpm dlx stash') + expect(stages[1].status).toBe('done') + expect(stages[1].detail).toContain('.cipherstash/plan.md') + }) + + it('points at `impl` for next-step when init done but plan missing', () => { + writeContext(sampleContext) + const stages = buildStages(readProjectStatus(cwd), 'pnpm dlx stash') + expect(stages[1].status).toBe('pending') + expect(stages[1].detail).toMatch(/impl/) + expect(stages[2].detail).toMatch(/waiting on plan/) + }) + + it('keeps Implementation pending even after agent engagement (DB state lives in encrypt status)', () => { + writeContext(sampleContext) + writePlan() + writeSetupPrompt() + const stages = buildStages(readProjectStatus(cwd), 'pnpm dlx stash') + expect(stages[2].status).toBe('pending') + expect(stages[2].detail).toContain('encrypt status') + }) +}) + +describe('nextAction', () => { + it('points at init when uninitialized', () => { + expect(nextAction(readProjectStatus(cwd), 'pnpm dlx stash')).toMatch(/init/) + }) + + it('points at impl when initialized but no plan', () => { + writeContext(sampleContext) + expect(nextAction(readProjectStatus(cwd), 'pnpm dlx stash')).toMatch(/impl/) + }) + + it('asks the user to review the plan before implementing', () => { + writeContext(sampleContext) + writePlan() + const action = nextAction(readProjectStatus(cwd), 'pnpm dlx stash') + expect(action).toMatch(/plan\.md/) + expect(action).toMatch(/impl/) + }) + + it('routes to encrypt status once the agent has been engaged', () => { + writeContext(sampleContext) + writePlan() + writeSetupPrompt() + expect(nextAction(readProjectStatus(cwd), 'pnpm dlx stash')).toMatch( + /encrypt status/, + ) + }) +}) diff --git a/packages/cli/src/commands/status/index.ts b/packages/cli/src/commands/status/index.ts new file mode 100644 index 00000000..a0cc4c95 --- /dev/null +++ b/packages/cli/src/commands/status/index.ts @@ -0,0 +1,144 @@ +import { existsSync, readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import * as p from '@clack/prompts' +import { PLAN_REL_PATH } from '../init/lib/setup-prompt.js' +import { + CONTEXT_REL_PATH, + type ContextFile, + SETUP_PROMPT_REL_PATH, +} from '../init/lib/write-context.js' +import { detectPackageManager, runnerCommand } from '../init/utils.js' + +export type StageStatus = 'done' | 'pending' + +export interface Stage { + label: string + status: StageStatus + detail: string +} + +export interface ProjectStatus { + initialized: boolean + context?: ContextFile + planExists: boolean + /** Setup-prompt is written by every `stash impl` run, regardless of mode. + * Its presence means the user has handed off to an agent at least once; + * it does NOT mean implementation is complete. We surface it as a softer + * "agent has been engaged" signal rather than treating it as done. */ + agentEngaged: boolean +} + +export function readProjectStatus(cwd: string): ProjectStatus { + const contextPath = resolve(cwd, CONTEXT_REL_PATH) + let context: ContextFile | undefined + if (existsSync(contextPath)) { + try { + context = JSON.parse(readFileSync(contextPath, 'utf-8')) as ContextFile + } catch { + // malformed context.json — treat as not-initialized + } + } + return { + initialized: context !== undefined, + context, + planExists: existsSync(resolve(cwd, PLAN_REL_PATH)), + agentEngaged: existsSync(resolve(cwd, SETUP_PROMPT_REL_PATH)), + } +} + +export function buildStages(status: ProjectStatus, cli: string): Stage[] { + const initDetail = status.context + ? `${status.context.integration} · ${status.context.packageManager} · ${status.context.schemas.length} table${status.context.schemas.length === 1 ? '' : 's'}` + : `run \`${cli} init\` to begin` + + const planDetail = status.planExists + ? PLAN_REL_PATH + : status.initialized + ? `run \`${cli} impl\` to draft` + : 'waiting on init' + + let implLabel = 'Implementation' + let implDetail: string + const implStatus: StageStatus = 'pending' + if (!status.initialized) { + implDetail = 'waiting on init' + } else if (!status.planExists) { + implDetail = 'waiting on plan' + } else if (!status.agentEngaged) { + implDetail = `run \`${cli} impl\` to execute the plan` + } else { + // Agent has been engaged at least once. We can't tell from disk alone + // whether the implementation is complete — that requires DB inspection + // (`stash encrypt status`). Keep status as `pending` and point there. + implLabel = 'Implementation' + implDetail = `agent engaged — see \`${cli} encrypt status\` for column-level state` + } + + return [ + { + label: 'Initialized', + status: status.initialized ? 'done' : 'pending', + detail: initDetail, + }, + { + label: 'Plan written', + status: status.planExists ? 'done' : 'pending', + detail: planDetail, + }, + { + label: implLabel, + status: implStatus, + detail: implDetail, + }, + ] +} + +export function nextAction(status: ProjectStatus, cli: string): string { + if (!status.initialized) return `Run \`${cli} init\` to begin.` + if (!status.planExists) { + return `Run \`${cli} impl\` to draft your encryption plan.` + } + if (!status.agentEngaged) { + return `Review \`${PLAN_REL_PATH}\`, then run \`${cli} impl\` to implement.` + } + return `Run \`${cli} encrypt status\` to inspect per-column migration state.` +} + +const LABEL_WIDTH = 16 + +function renderStage(stage: Stage): string { + const marker = stage.status === 'done' ? '✓' : '◯' + return `${marker} ${stage.label.padEnd(LABEL_WIDTH)} ${stage.detail}` +} + +/** + * `stash status` — the lifecycle map. Reads disk state only: + * `.cipherstash/context.json` (init done?), `.cipherstash/plan.md` (plan + * written?), `.cipherstash/setup-prompt.md` (agent engaged at least once?). + * Points at `stash db status` and `stash encrypt status` for the deeper + * state that requires database connectivity. + * + * Designed to give the user a one-shot answer to "where am I?" without + * waiting on auth, DB connection, or any network round-trip. Runs in + * milliseconds. The deeper commands stay specialised. + */ +export async function statusCommand() { + const cwd = process.cwd() + const pm = detectPackageManager() + const cli = runnerCommand(pm, 'stash') + + const status = readProjectStatus(cwd) + const stages = buildStages(status, cli) + + p.intro('CipherStash project status') + + p.note(stages.map(renderStage).join('\n'), 'Lifecycle') + + const deeper = [ + `Database state: \`${cli} db status\``, + `Per-column state: \`${cli} encrypt status\``, + ].join('\n') + p.note(deeper, 'Deeper inspection') + + p.outro(nextAction(status, cli)) +} From 59b138b86bc4b8388f6d3e26327ded76642e7808 Mon Sep 17 00:00:00 2001 From: CJ Brewer Date: Tue, 5 May 2026 20:12:21 -0600 Subject: [PATCH 4/6] feat(cli): add plan command and rename yolo --- .changeset/stash-impl-command.md | 2 +- .changeset/stash-plan-command.md | 11 + packages/cli/src/bin/stash.ts | 14 +- packages/cli/src/commands/impl/index.ts | 222 ++++++++---------- packages/cli/src/commands/index.ts | 1 + packages/cli/src/commands/init/index.ts | 18 +- .../lib/__tests__/read-context.test.ts} | 32 +-- .../cli/src/commands/init/lib/read-context.ts | 21 ++ .../cli/src/commands/init/lib/setup-prompt.ts | 4 +- packages/cli/src/commands/init/types.ts | 6 +- packages/cli/src/commands/plan/index.ts | 113 +++++++++ .../commands/status/__tests__/status.test.ts | 10 +- packages/cli/src/commands/status/index.ts | 4 +- 13 files changed, 283 insertions(+), 175 deletions(-) create mode 100644 .changeset/stash-plan-command.md rename packages/cli/src/commands/{impl/__tests__/derive-mode.test.ts => init/lib/__tests__/read-context.test.ts} (58%) create mode 100644 packages/cli/src/commands/init/lib/read-context.ts create mode 100644 packages/cli/src/commands/plan/index.ts diff --git a/.changeset/stash-impl-command.md b/.changeset/stash-impl-command.md index 43bc73be..72283ff2 100644 --- a/.changeset/stash-impl-command.md +++ b/.changeset/stash-impl-command.md @@ -2,4 +2,4 @@ "stash": minor --- -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. +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. diff --git a/.changeset/stash-plan-command.md b/.changeset/stash-plan-command.md new file mode 100644 index 00000000..1fe8337c --- /dev/null +++ b/.changeset/stash-plan-command.md @@ -0,0 +1,11 @@ +--- +"stash": minor +--- + +Extract planning into its own `stash plan` command. Three commands now own the setup lifecycle: + +- `stash init` — scaffold (auth, db, deps, EQL). Ends with a chain prompt to `stash plan`. +- `stash plan` — draft a reviewable plan at `.cipherstash/plan.md`. Ends with a chain prompt to `stash impl`. +- `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. + +`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. diff --git a/packages/cli/src/bin/stash.ts b/packages/cli/src/bin/stash.ts index f3f4d52a..cc46cf05 100644 --- a/packages/cli/src/bin/stash.ts +++ b/packages/cli/src/bin/stash.ts @@ -21,6 +21,7 @@ import { implCommand, initCommand, installCommand, + planCommand, statusCommand, testConnectionCommand, upgradeCommand, @@ -76,7 +77,8 @@ ${messages.cli.usagePrefix}${STASH} [options] Commands: init Initialize CipherStash for your project - impl Draft an encryption plan (or implement, if a plan exists) + plan Draft a reviewable encryption plan at .cipherstash/plan.md + impl Execute an encryption plan (or implement without one with --continue-without-plan) status Show project lifecycle: init done? plan written? implementation engaged? auth Authenticate with CipherStash wizard AI-guided encryption setup (reads your codebase) @@ -109,8 +111,8 @@ Init Flags: --drizzle Use Drizzle-specific setup flow Impl Flags: - --yolo Skip the planning checkpoint and go straight to implementation - (interactively confirms before proceeding) + --continue-without-plan Skip the planning checkpoint and go straight to implementation + (interactively confirms before proceeding) DB Flags: --force (install) Reinstall / overwrite even if already installed @@ -127,8 +129,9 @@ DB Flags: Examples: ${STASH} init ${STASH} init --supabase + ${STASH} plan ${STASH} impl - ${STASH} impl --yolo + ${STASH} impl --continue-without-plan ${STASH} status ${STASH} auth login ${STASH} wizard @@ -378,6 +381,9 @@ async function main() { case 'init': await initCommand(flags) break + case 'plan': + await planCommand() + break case 'impl': await implCommand(flags) break diff --git a/packages/cli/src/commands/impl/index.ts b/packages/cli/src/commands/impl/index.ts index 5d148a65..14090d04 100644 --- a/packages/cli/src/commands/impl/index.ts +++ b/packages/cli/src/commands/impl/index.ts @@ -3,6 +3,7 @@ import { resolve } from 'node:path' import * as p from '@clack/prompts' import { type AgentEnvironment, detectAgents } from '../init/detect-agents.js' import { parsePlanSummary, renderPlanSummary } from '../init/lib/parse-plan.js' +import { readContextFile } from '../init/lib/read-context.js' import { PLAN_REL_PATH } from '../init/lib/setup-prompt.js' import { CONTEXT_REL_PATH, @@ -10,7 +11,6 @@ import { } from '../init/lib/write-context.js' import { CancelledError, - type InitMode, type InitProvider, type InitState, } from '../init/types.js' @@ -19,11 +19,8 @@ import { howToProceedStep } from './steps/how-to-proceed.js' /** * The handoff steps in `impl/steps/handoff-*.ts` accept an `InitProvider` - * but ignore it (their `run` signatures take `_provider`). The provider - * abstraction belongs to the `init` flow, where it picks intro copy and - * default next-steps. `stash impl` reads everything it needs from - * `.cipherstash/context.json` instead, so a stub keeps the type signature - * happy without pretending impl has provider-specific behaviour. + * but ignore it. Stub keeps the type signature happy without pretending + * impl has provider-specific behaviour. */ const STUB_PROVIDER: InitProvider = { name: 'impl', @@ -31,60 +28,8 @@ const STUB_PROVIDER: InitProvider = { getNextSteps: () => [], } -export function readContextFile(cwd: string): ContextFile | undefined { - const path = resolve(cwd, CONTEXT_REL_PATH) - if (!existsSync(path)) return undefined - try { - return JSON.parse(readFileSync(path, 'utf-8')) as ContextFile - } catch { - return undefined - } -} - -/** - * Derive the impl mode from disk state and flags. - * - * no `--yolo`, plan missing → `plan` (default — the safer path) - * no `--yolo`, plan exists → `implement` (the plan is the source of truth) - * `--yolo`, plan missing → `implement` after interactive confirmation - * `--yolo`, plan exists → `implement`; `--yolo` is a no-op once a plan - * exists, since the safety checkpoint already - * fired - * - * The interactive confirmation when `--yolo` is the only thing standing - * between the user and ~45–60 min of agent-driven implementation. Cheap - * to ask, expensive to skip by accident. - */ -export async function deriveMode( - cwd: string, - yolo: boolean, -): Promise { - const planExists = existsSync(resolve(cwd, PLAN_REL_PATH)) - - if (yolo) { - if (planExists) { - p.log.info( - `Plan exists at \`${PLAN_REL_PATH}\` — \`--yolo\` is a no-op when a plan is already in place.`, - ) - return 'implement' - } - const confirmed = await p.confirm({ - message: - 'Skip the planning checkpoint and go straight to implementation?', - initialValue: false, - }) - if (p.isCancel(confirmed) || !confirmed) { - throw new CancelledError() - } - return 'implement' - } - - return planExists ? 'implement' : 'plan' -} - function buildStateFromContext( ctx: ContextFile, - mode: InitMode, agents: AgentEnvironment, ): InitState { return { @@ -92,32 +37,46 @@ function buildStateFromContext( clientFilePath: ctx.encryptionClientPath, schemas: ctx.schemas, envKeys: ctx.envKeys, - // After init has run, these are true. The pre-flight context.json - // check above is the gate — if init didn't complete, context.json - // wouldn't exist and we'd have already errored. stackInstalled: true, cliInstalled: true, eqlInstalled: true, agents, - mode, + mode: 'implement', + } +} + +/** + * Confirm "are you sure?" before implementing without a plan. The + * default-no on the confirm is the security stance — passing through + * the planning checkpoint by accident is the failure mode we're guarding + * against. + */ +async function confirmContinueWithoutPlan(): Promise { + const confirmed = await p.confirm({ + message: + 'Implementing without a plan commits you to ~45–60 min of agent work. Continue?', + initialValue: false, + }) + if (p.isCancel(confirmed) || !confirmed) { + throw new CancelledError() } } /** - * `stash impl` — the agent handoff phase. + * `stash impl` — execute an encryption plan. * - * Pre-flights `.cipherstash/context.json` (errors with a `stash init` - * pointer if missing). Derives plan-vs-implement mode from disk state and - * the `--yolo` flag, then dispatches to a handoff target via - * `howToProceedStep`. Modes: + * Always runs in implement mode. Behaviour branches on disk state and + * flags: * - * - `plan` (default when no `.cipherstash/plan.md` exists): the agent - * produces a reviewable plan file. The user reads it, then re-runs - * `stash impl` to execute. - * - `implement` (default when a plan exists): the agent executes the - * plan as the source of truth. - * - `--yolo` forces `implement` even with no plan, after an interactive - * confirmation prompt. + * - **Plan exists** (TTY): parse the structured summary block, render + * a confirmation panel, ask the user to proceed. Default-yes. + * - **Plan exists** (non-TTY): proceed without confirmation. + * - **No plan, `--continue-without-plan`**: confirm once, then implement. + * - **No plan, TTY**: present a `p.select` — draft a plan first + * (delegates to `planCommand`) or continue without one (confirms + * once, then implements). + * - **No plan, non-TTY**: error out with a clear next-action; CI must + * pass `--continue-without-plan` or run `stash plan` first. */ export async function implCommand(flags: Record) { const cwd = process.cwd() @@ -134,63 +93,86 @@ export async function implCommand(flags: Record) { p.intro('CipherStash Implementation') - try { - const mode = await deriveMode(cwd, flags.yolo === true) + const planPath = resolve(cwd, PLAN_REL_PATH) + const planExists = existsSync(planPath) + const continueWithoutPlan = flags['continue-without-plan'] === true + const isTTY = process.stdout.isTTY - const planPath = resolve(cwd, PLAN_REL_PATH) - const planExists = existsSync(planPath) - - if (mode === 'plan') { - p.log.info( - `No plan at \`${PLAN_REL_PATH}\`. The agent will draft one for you to review.`, - ) - } else if (planExists && process.stdout.isTTY) { - // Plan-summary checkpoint: the last save point before the agent - // commits to the (potentially hour-long) implementation phase. Parse - // the structured summary block the planning agent was instructed to - // emit; fall back to a soft "open it in your editor" panel if the - // block is missing (older plans, or an agent that didn't follow the - // schema). Default-yes on the confirm — the user is here to proceed. - const summary = parsePlanSummary(readFileSync(planPath, 'utf-8')) - if (summary) { - p.note(renderPlanSummary(summary), 'Plan summary') + try { + if (planExists) { + // Plan-summary checkpoint: the last save point before launching the + // (potentially hour-long) implementation phase. + if (isTTY) { + const summary = parsePlanSummary(readFileSync(planPath, 'utf-8')) + if (summary) { + p.note(renderPlanSummary(summary), 'Plan summary') + } else { + p.note( + `Plan at \`${PLAN_REL_PATH}\` doesn't include a machine-readable summary. Open it in your editor before proceeding.`, + 'Plan ready', + ) + } + const proceed = await p.confirm({ + message: 'Proceed with implementation against this plan?', + initialValue: true, + }) + if (p.isCancel(proceed) || !proceed) { + throw new CancelledError() + } } else { - p.note( - `Plan at \`${PLAN_REL_PATH}\` doesn't include a machine-readable summary. Open it in your editor before proceeding.`, - 'Plan ready', + p.log.info( + `Plan at \`${PLAN_REL_PATH}\` — agent will execute it as the source of truth.`, ) } - const proceed = await p.confirm({ - message: 'Proceed with implementation against this plan?', - initialValue: true, - }) - if (p.isCancel(proceed) || !proceed) { - throw new CancelledError() - } - } else if (planExists) { - // Non-TTY: skip the confirm; assume the caller (CI, pipe) wants to proceed. - p.log.info( - `Plan at \`${PLAN_REL_PATH}\` — agent will execute it as the source of truth.`, - ) } else { - // Implement without a plan — `--yolo` already confirmed earlier in deriveMode. - p.log.info('No plan exists — implementing from scratch (--yolo).') + // No plan on disk. Branch on flag / TTY / interactive. + if (continueWithoutPlan) { + await confirmContinueWithoutPlan() + } else if (!isTTY) { + p.log.error( + `No plan at \`${PLAN_REL_PATH}\`. Run \`${cli} plan\` first, or pass --continue-without-plan to skip planning.`, + ) + process.exit(1) + } else { + const choice = await p.select<'plan' | 'continue'>({ + message: 'No plan found. What would you like to do?', + options: [ + { + value: 'plan', + label: 'Draft a plan first (recommended)', + hint: `runs \`${cli} plan\` — usually 1–3 min`, + }, + { + value: 'continue', + label: 'Continue without a plan', + hint: 'skip the planning checkpoint', + }, + ], + initialValue: 'plan', + }) + if (p.isCancel(choice)) throw new CancelledError() + + if (choice === 'plan') { + // Lazy import avoids a circular module load between plan ↔ impl. + const { planCommand } = await import('../plan/index.js') + // Close the current intro frame before plan opens its own. + p.outro('Handing off to `stash plan`.') + await planCommand() + return + } + + await confirmContinueWithoutPlan() + } } const agents = detectAgents(cwd, process.env) - const state = buildStateFromContext(ctx, mode, agents) + const state = buildStateFromContext(ctx, agents) await howToProceedStep.run(state, STUB_PROVIDER) - if (mode === 'plan') { - p.outro( - `Plan drafted at \`${PLAN_REL_PATH}\`. Review it, then run \`${cli} impl\` again to implement.`, - ) - } else { - p.outro( - `Implementation handoff complete. Run \`${cli} db status\` to verify state.`, - ) - } + p.outro( + `Implementation handoff complete. Run \`${cli} db status\` to verify state.`, + ) } catch (err) { if (err instanceof CancelledError) { p.cancel('Cancelled.') diff --git a/packages/cli/src/commands/index.ts b/packages/cli/src/commands/index.ts index 43e9a2d4..ab699d84 100644 --- a/packages/cli/src/commands/index.ts +++ b/packages/cli/src/commands/index.ts @@ -6,5 +6,6 @@ export { authCommand } from './auth/index.js' export { implCommand } from './impl/index.js' export { initCommand } from './init/index.js' export { envCommand } from './env/index.js' +export { planCommand } from './plan/index.js' export { statusCommand } from './status/index.js' export { wizardCommand } from './wizard/index.js' diff --git a/packages/cli/src/commands/init/index.ts b/packages/cli/src/commands/init/index.ts index 25ef5d4d..ee15b3cf 100644 --- a/packages/cli/src/commands/init/index.ts +++ b/packages/cli/src/commands/init/index.ts @@ -1,5 +1,5 @@ import * as p from '@clack/prompts' -import { implCommand } from '../impl/index.js' +import { planCommand } from '../plan/index.js' import { createBaseProvider } from './providers/base.js' import { createDrizzleProvider } from './providers/drizzle.js' import { createSupabaseProvider } from './providers/supabase.js' @@ -89,25 +89,25 @@ export async function initCommand(flags: Record) { p.note(checkmarks.join('\n'), 'Setup complete') - // Offer to chain straight into `stash impl` so first-time users don't + // Offer to chain straight into `stash plan` so first-time users don't // have to copy/paste the next command. Default-yes for low friction; // answering N (or running non-interactively) preserves the explicit - // two-command flow. Only prompts in plan mode by definition — at this - // point the user has no plan yet, so impl will draft one (~1–3 min) - // rather than dropping them into the hour-long implementation phase. + // multi-command flow. Drafting a plan is fast (~1–3 min of agent + // thinking) and produces a reviewable artifact — `stash impl` is the + // separate, slower verb that actually mutates code. if (process.stdout.isTTY) { const proceed = await p.confirm({ - message: `Continue to \`${cli} impl\` now to draft your encryption plan?`, + message: `Continue to \`${cli} plan\` now to draft your encryption plan?`, initialValue: true, }) if (!p.isCancel(proceed) && proceed) { - p.outro('Setup complete — handing off to `stash impl`.') - await implCommand({}) + p.outro('Setup complete — handing off to `stash plan`.') + await planCommand() return } } - p.outro(`Next: run \`${cli} impl\` to draft your encryption plan.`) + p.outro(`Next: run \`${cli} plan\` to draft your encryption plan.`) } catch (err) { if (err instanceof CancelledError) { p.cancel('Setup cancelled.') diff --git a/packages/cli/src/commands/impl/__tests__/derive-mode.test.ts b/packages/cli/src/commands/init/lib/__tests__/read-context.test.ts similarity index 58% rename from packages/cli/src/commands/impl/__tests__/derive-mode.test.ts rename to packages/cli/src/commands/init/lib/__tests__/read-context.test.ts index 0dc2614e..70bd56a7 100644 --- a/packages/cli/src/commands/impl/__tests__/derive-mode.test.ts +++ b/packages/cli/src/commands/init/lib/__tests__/read-context.test.ts @@ -2,23 +2,18 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { deriveMode, readContextFile } from '../index.js' +import { readContextFile } from '../read-context.js' let cwd: string beforeEach(() => { - cwd = mkdtempSync(join(tmpdir(), 'stash-impl-')) + cwd = mkdtempSync(join(tmpdir(), 'stash-context-')) }) afterEach(() => { rmSync(cwd, { recursive: true, force: true }) }) -function writePlan(): void { - mkdirSync(join(cwd, '.cipherstash'), { recursive: true }) - writeFileSync(join(cwd, '.cipherstash', 'plan.md'), '# plan\n', 'utf-8') -} - function writeContext(payload: Record): void { mkdirSync(join(cwd, '.cipherstash'), { recursive: true }) writeFileSync( @@ -28,29 +23,6 @@ function writeContext(payload: Record): void { ) } -describe('deriveMode (no --yolo)', () => { - it('returns plan when no plan file exists', async () => { - expect(await deriveMode(cwd, false)).toBe('plan') - }) - - it('returns implement when plan file exists', async () => { - writePlan() - expect(await deriveMode(cwd, false)).toBe('implement') - }) -}) - -describe('deriveMode (--yolo)', () => { - it('is a no-op when a plan already exists — no prompt, returns implement', async () => { - // The interactive confirmation must NOT fire when a plan exists, since - // the safety checkpoint (the plan itself) has already happened. - writePlan() - expect(await deriveMode(cwd, true)).toBe('implement') - }) - - // The `--yolo + no plan` path is interactive (p.confirm). Covered by - // manual smoke tests; mocking @clack/prompts isn't worth the churn here. -}) - describe('readContextFile', () => { it('returns undefined when context.json is missing', () => { expect(readContextFile(cwd)).toBeUndefined() diff --git a/packages/cli/src/commands/init/lib/read-context.ts b/packages/cli/src/commands/init/lib/read-context.ts new file mode 100644 index 00000000..38edb61a --- /dev/null +++ b/packages/cli/src/commands/init/lib/read-context.ts @@ -0,0 +1,21 @@ +import { existsSync, readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { CONTEXT_REL_PATH, type ContextFile } from './write-context.js' + +/** + * Read the `.cipherstash/context.json` file written by `stash init`. + * Returns `undefined` when the file is missing or malformed — both `stash + * plan` and `stash impl` use that signal to point the user back at + * `stash init` rather than crashing. + * + * Never throws on bad input. Malformed JSON is treated as "no context." + */ +export function readContextFile(cwd: string): ContextFile | undefined { + const path = resolve(cwd, CONTEXT_REL_PATH) + if (!existsSync(path)) return undefined + try { + return JSON.parse(readFileSync(path, 'utf-8')) as ContextFile + } catch { + return undefined + } +} diff --git a/packages/cli/src/commands/init/lib/setup-prompt.ts b/packages/cli/src/commands/init/lib/setup-prompt.ts index 57ac6193..238dfc93 100644 --- a/packages/cli/src/commands/init/lib/setup-prompt.ts +++ b/packages/cli/src/commands/init/lib/setup-prompt.ts @@ -331,7 +331,7 @@ export function renderPlanPrompt(ctx: SetupPromptContext): string { '', `Integration: \`${ctx.integration}\` · Package manager: \`${ctx.packageManager}\``, '', - `\`stash init\` has finished its mechanical setup. The user picked **plan-first** — your job is to produce a reviewable plan at \`${PLAN_REL_PATH}\`, **not** to make schema or code changes. Read-only inspection (\`${cli} db status\`, reading schema files, grepping the codebase) is encouraged. Schema edits, migrations, \`${cli} db push\`, and any \`${cli} encrypt *\` mutations are deferred to the implementation phase that runs after the user reviews and approves the plan.`, + `\`stash plan\` runs the planning phase — your job is to produce a reviewable plan at \`${PLAN_REL_PATH}\`, **not** to make schema or code changes. Read-only inspection (\`${cli} db status\`, reading schema files, grepping the codebase) is encouraged. Schema edits, migrations, \`${cli} db push\`, and any \`${cli} encrypt *\` mutations are deferred to \`${cli} impl\`, which the user will run after reviewing and approving the plan.`, '', '## What `stash init` already did', '', @@ -418,7 +418,7 @@ export function renderPlanPrompt(ctx: SetupPromptContext): string { '', `Send the user a short orientation message before writing anything. Confirm setup is complete, list the skills loaded with one-line purposes, summarise the two options in your own words, and end with a clear question — *"Which table(s) and column(s) would you like the plan to cover? You can name them or describe what you're trying to protect."* Reference concrete tables/columns from \`.cipherstash/context.json\` when it helps.`, '', - `Once the user answers, write \`${PLAN_REL_PATH}\`. Show the plan in chat as well so the user can react inline. After the plan is approved, tell the user how to proceed to implementation — re-run \`${cli} init\` and pick **Go straight to implementation**, or paste the implementation prompt manually.`, + `Once the user answers, write \`${PLAN_REL_PATH}\`. Show the plan in chat as well so the user can react inline. After the plan is approved, tell the user to run \`${cli} impl\` to execute it.`, '', '## Stop and ask the user when', '', diff --git a/packages/cli/src/commands/init/types.ts b/packages/cli/src/commands/init/types.ts index 76e12d3f..d6725af0 100644 --- a/packages/cli/src/commands/init/types.ts +++ b/packages/cli/src/commands/init/types.ts @@ -58,9 +58,9 @@ export interface InitState { agents?: AgentEnvironment /** What the user picked at the "how to proceed" step. */ handoff?: HandoffChoice - /** Whether the handoff should plan first or implement directly. Derived - * from disk state by `stash impl`: if `.cipherstash/plan.md` exists, the - * mode is `implement`; otherwise `plan`. `--yolo` forces `implement`. */ + /** Whether the handoff is producing a plan or executing one. Set by the + * command itself: `stash plan` always sets `'plan'`, `stash impl` always + * sets `'implement'`. */ mode?: InitMode } diff --git a/packages/cli/src/commands/plan/index.ts b/packages/cli/src/commands/plan/index.ts new file mode 100644 index 00000000..43b0b9f5 --- /dev/null +++ b/packages/cli/src/commands/plan/index.ts @@ -0,0 +1,113 @@ +import { existsSync } from 'node:fs' +import { resolve } from 'node:path' +import * as p from '@clack/prompts' +import { howToProceedStep } from '../impl/steps/how-to-proceed.js' +import { type AgentEnvironment, detectAgents } from '../init/detect-agents.js' +import { readContextFile } from '../init/lib/read-context.js' +import { PLAN_REL_PATH } from '../init/lib/setup-prompt.js' +import { + CONTEXT_REL_PATH, + type ContextFile, +} from '../init/lib/write-context.js' +import { + CancelledError, + type InitProvider, + type InitState, +} from '../init/types.js' +import { detectPackageManager, runnerCommand } from '../init/utils.js' + +/** + * `stash plan` borrows the same handoff machinery as `stash impl`. The + * handoff steps don't actually use the provider (their `run` signatures + * take `_provider`); the abstraction is an init-time concept for intro + * copy. A stub satisfies the type. + */ +const STUB_PROVIDER: InitProvider = { + name: 'plan', + introMessage: '', + getNextSteps: () => [], +} + +function buildStateFromContext( + ctx: ContextFile, + agents: AgentEnvironment, +): InitState { + return { + integration: ctx.integration, + clientFilePath: ctx.encryptionClientPath, + schemas: ctx.schemas, + envKeys: ctx.envKeys, + stackInstalled: true, + cliInstalled: true, + eqlInstalled: true, + agents, + mode: 'plan', + } +} + +/** + * `stash plan` — draft a reviewable encryption plan. + * + * Pre-flights `.cipherstash/context.json` (errors with a `stash init` + * pointer if missing). Always sets `mode='plan'`, dispatches to a handoff + * target via `howToProceedStep`, and ends with a chain prompt offering to + * continue into `stash impl`. + * + * The deliverable is `.cipherstash/plan.md` with a machine-readable + * summary block at the top — `stash impl` parses that block to render a + * confirmation panel before launching implementation. + */ +export async function planCommand() { + const cwd = process.cwd() + const pm = detectPackageManager() + const cli = runnerCommand(pm, 'stash') + + const ctx = readContextFile(cwd) + if (!ctx) { + p.log.error( + `No CipherStash context found at \`${CONTEXT_REL_PATH}\`. Run \`${cli} init\` first.`, + ) + process.exit(1) + } + + p.intro('CipherStash Plan') + + try { + if (existsSync(resolve(cwd, PLAN_REL_PATH))) { + p.log.warn( + `Plan already exists at \`${PLAN_REL_PATH}\`. The agent will be told to revise it; delete the file first if you want to start fresh.`, + ) + } + + const agents = detectAgents(cwd, process.env) + const state = buildStateFromContext(ctx, agents) + + await howToProceedStep.run(state, STUB_PROVIDER) + + // Chain into `stash impl` so the user doesn't have to copy/paste. Lazy + // import avoids a circular module load — plan and impl both pull from + // init/lib/ and need to be importable independently. + if (process.stdout.isTTY) { + const proceed = await p.confirm({ + message: `Plan drafted at \`${PLAN_REL_PATH}\`. Continue to \`${cli} impl\` now?`, + initialValue: true, + }) + if (!p.isCancel(proceed) && proceed) { + p.outro('Plan complete — handing off to `stash impl`.') + const { implCommand } = await import('../impl/index.js') + await implCommand({}) + return + } + } + + p.outro( + `Plan drafted at \`${PLAN_REL_PATH}\`. Review it, then run \`${cli} impl\` to implement.`, + ) + } catch (err) { + if (err instanceof CancelledError) { + p.cancel('Cancelled.') + process.exit(0) + } + throw err + } +} diff --git a/packages/cli/src/commands/status/__tests__/status.test.ts b/packages/cli/src/commands/status/__tests__/status.test.ts index 2b4d1ae0..b4db3ef4 100644 --- a/packages/cli/src/commands/status/__tests__/status.test.ts +++ b/packages/cli/src/commands/status/__tests__/status.test.ts @@ -134,11 +134,11 @@ describe('buildStages', () => { expect(stages[1].detail).toContain('.cipherstash/plan.md') }) - it('points at `impl` for next-step when init done but plan missing', () => { + it('points at `plan` for next-step when init done but plan missing', () => { writeContext(sampleContext) const stages = buildStages(readProjectStatus(cwd), 'pnpm dlx stash') expect(stages[1].status).toBe('pending') - expect(stages[1].detail).toMatch(/impl/) + expect(stages[1].detail).toMatch(/plan/) expect(stages[2].detail).toMatch(/waiting on plan/) }) @@ -157,9 +157,11 @@ describe('nextAction', () => { expect(nextAction(readProjectStatus(cwd), 'pnpm dlx stash')).toMatch(/init/) }) - it('points at impl when initialized but no plan', () => { + it('points at `plan` when initialized but no plan exists', () => { writeContext(sampleContext) - expect(nextAction(readProjectStatus(cwd), 'pnpm dlx stash')).toMatch(/impl/) + expect(nextAction(readProjectStatus(cwd), 'pnpm dlx stash')).toMatch( + /\bplan\b/, + ) }) it('asks the user to review the plan before implementing', () => { diff --git a/packages/cli/src/commands/status/index.ts b/packages/cli/src/commands/status/index.ts index a0cc4c95..32272eb8 100644 --- a/packages/cli/src/commands/status/index.ts +++ b/packages/cli/src/commands/status/index.ts @@ -54,7 +54,7 @@ export function buildStages(status: ProjectStatus, cli: string): Stage[] { const planDetail = status.planExists ? PLAN_REL_PATH : status.initialized - ? `run \`${cli} impl\` to draft` + ? `run \`${cli} plan\` to draft` : 'waiting on init' let implLabel = 'Implementation' @@ -96,7 +96,7 @@ export function buildStages(status: ProjectStatus, cli: string): Stage[] { export function nextAction(status: ProjectStatus, cli: string): string { if (!status.initialized) return `Run \`${cli} init\` to begin.` if (!status.planExists) { - return `Run \`${cli} impl\` to draft your encryption plan.` + return `Run \`${cli} plan\` to draft your encryption plan.` } if (!status.agentEngaged) { return `Review \`${PLAN_REL_PATH}\`, then run \`${cli} impl\` to implement.` From a7de5cc78d3ab036cd69af2e7a9c85936f1a89c7 Mon Sep 17 00:00:00 2001 From: CJ Brewer Date: Wed, 6 May 2026 08:02:06 -0600 Subject: [PATCH 5/6] chore: address feedback --- packages/cli/src/bin/stash.ts | 6 ++-- packages/cli/src/commands/impl/index.ts | 30 +++++-------------- .../commands/impl/steps/handoff-agents-md.ts | 2 +- .../src/commands/impl/steps/handoff-claude.ts | 2 +- .../src/commands/impl/steps/handoff-codex.ts | 2 +- .../src/commands/impl/steps/handoff-wizard.ts | 2 +- .../src/commands/impl/steps/how-to-proceed.ts | 10 +++---- packages/cli/src/commands/init/types.ts | 6 +++- packages/cli/src/commands/plan/index.ts | 20 ++----------- 9 files changed, 26 insertions(+), 54 deletions(-) diff --git a/packages/cli/src/bin/stash.ts b/packages/cli/src/bin/stash.ts index cc46cf05..e920f218 100644 --- a/packages/cli/src/bin/stash.ts +++ b/packages/cli/src/bin/stash.ts @@ -78,8 +78,8 @@ ${messages.cli.usagePrefix}${STASH} [options] Commands: init Initialize CipherStash for your project plan Draft a reviewable encryption plan at .cipherstash/plan.md - impl Execute an encryption plan (or implement without one with --continue-without-plan) - status Show project lifecycle: init done? plan written? implementation engaged? + impl Execute the plan with a local agent + status Displays implementation status auth Authenticate with CipherStash wizard AI-guided encryption setup (reads your codebase) @@ -111,7 +111,7 @@ Init Flags: --drizzle Use Drizzle-specific setup flow Impl Flags: - --continue-without-plan Skip the planning checkpoint and go straight to implementation + --continue-without-plan Skip planning and go straight to implementation (interactively confirms before proceeding) DB Flags: diff --git a/packages/cli/src/commands/impl/index.ts b/packages/cli/src/commands/impl/index.ts index 14090d04..4b5ad386 100644 --- a/packages/cli/src/commands/impl/index.ts +++ b/packages/cli/src/commands/impl/index.ts @@ -9,25 +9,10 @@ import { CONTEXT_REL_PATH, type ContextFile, } from '../init/lib/write-context.js' -import { - CancelledError, - type InitProvider, - type InitState, -} from '../init/types.js' +import { CancelledError, type InitState } from '../init/types.js' import { detectPackageManager, runnerCommand } from '../init/utils.js' import { howToProceedStep } from './steps/how-to-proceed.js' -/** - * The handoff steps in `impl/steps/handoff-*.ts` accept an `InitProvider` - * but ignore it. Stub keeps the type signature happy without pretending - * impl has provider-specific behaviour. - */ -const STUB_PROVIDER: InitProvider = { - name: 'impl', - introMessage: '', - getNextSteps: () => [], -} - function buildStateFromContext( ctx: ContextFile, agents: AgentEnvironment, @@ -46,15 +31,14 @@ function buildStateFromContext( } /** - * Confirm "are you sure?" before implementing without a plan. The - * default-no on the confirm is the security stance — passing through - * the planning checkpoint by accident is the failure mode we're guarding - * against. + * Confirm before launching implementation when the user has chosen to + * skip the planning checkpoint. Default-no is the security stance — + * passing through this prompt by accident is the failure mode we're + * guarding against. */ async function confirmContinueWithoutPlan(): Promise { const confirmed = await p.confirm({ - message: - 'Implementing without a plan commits you to ~45–60 min of agent work. Continue?', + message: 'Implementation can take some time. Continue?', initialValue: false, }) if (p.isCancel(confirmed) || !confirmed) { @@ -168,7 +152,7 @@ export async function implCommand(flags: Record) { const agents = detectAgents(cwd, process.env) const state = buildStateFromContext(ctx, agents) - await howToProceedStep.run(state, STUB_PROVIDER) + await howToProceedStep.run(state) p.outro( `Implementation handoff complete. Run \`${cli} db status\` to verify state.`, diff --git a/packages/cli/src/commands/impl/steps/handoff-agents-md.ts b/packages/cli/src/commands/impl/steps/handoff-agents-md.ts index 24091652..5f5178f2 100644 --- a/packages/cli/src/commands/impl/steps/handoff-agents-md.ts +++ b/packages/cli/src/commands/impl/steps/handoff-agents-md.ts @@ -28,7 +28,7 @@ const AGENTS_MD_REL_PATH = 'AGENTS.md' export const handoffAgentsMdStep: InitStep = { id: 'handoff-agents-md', name: 'Write AGENTS.md', - async run(state: InitState, _provider: InitProvider): Promise { + async run(state: InitState, _provider?: InitProvider): Promise { const cwd = process.cwd() const integration = state.integration ?? 'postgresql' diff --git a/packages/cli/src/commands/impl/steps/handoff-claude.ts b/packages/cli/src/commands/impl/steps/handoff-claude.ts index 01ad6f1a..e270b0f7 100644 --- a/packages/cli/src/commands/impl/steps/handoff-claude.ts +++ b/packages/cli/src/commands/impl/steps/handoff-claude.ts @@ -21,7 +21,7 @@ const CLAUDE_INSTALL_URL = 'https://code.claude.com/docs/en/quickstart' export const handoffClaudeStep: InitStep = { id: 'handoff-claude', name: 'Hand off to Claude Code', - async run(state: InitState, _provider: InitProvider): Promise { + async run(state: InitState, _provider?: InitProvider): Promise { const cwd = process.cwd() const integration = state.integration ?? 'postgresql' diff --git a/packages/cli/src/commands/impl/steps/handoff-codex.ts b/packages/cli/src/commands/impl/steps/handoff-codex.ts index 3ccff834..7344ae84 100644 --- a/packages/cli/src/commands/impl/steps/handoff-codex.ts +++ b/packages/cli/src/commands/impl/steps/handoff-codex.ts @@ -28,7 +28,7 @@ const CODEX_INSTALL_URL = 'https://github.com/openai/codex' export const handoffCodexStep: InitStep = { id: 'handoff-codex', name: 'Hand off to Codex', - async run(state: InitState, _provider: InitProvider): Promise { + async run(state: InitState, _provider?: InitProvider): Promise { const cwd = process.cwd() const integration = state.integration ?? 'postgresql' diff --git a/packages/cli/src/commands/impl/steps/handoff-wizard.ts b/packages/cli/src/commands/impl/steps/handoff-wizard.ts index 2bfefa18..8d2d5c7f 100644 --- a/packages/cli/src/commands/impl/steps/handoff-wizard.ts +++ b/packages/cli/src/commands/impl/steps/handoff-wizard.ts @@ -23,7 +23,7 @@ import { runWizardSpawn } from '../../wizard/index.js' export const handoffWizardStep: InitStep = { id: 'handoff-wizard', name: 'Use the CipherStash Agent', - async run(state: InitState, _provider: InitProvider): Promise { + async run(state: InitState, _provider?: InitProvider): Promise { const cwd = process.cwd() const envKeys = state.envKeys ?? [] diff --git a/packages/cli/src/commands/impl/steps/how-to-proceed.ts b/packages/cli/src/commands/impl/steps/how-to-proceed.ts index 8f80f6f8..932952d5 100644 --- a/packages/cli/src/commands/impl/steps/how-to-proceed.ts +++ b/packages/cli/src/commands/impl/steps/how-to-proceed.ts @@ -82,7 +82,7 @@ export function buildOptions( export const howToProceedStep: InitStep = { id: 'how-to-proceed', name: 'How to proceed', - async run(state: InitState, provider: InitProvider): Promise { + async run(state: InitState, _provider?: InitProvider): Promise { const mode: InitMode = state.mode ?? 'implement' const message = mode === 'plan' @@ -101,13 +101,13 @@ export const howToProceedStep: InitStep = { switch (choice) { case 'claude-code': - return handoffClaudeStep.run(next, provider) + return handoffClaudeStep.run(next) case 'codex': - return handoffCodexStep.run(next, provider) + return handoffCodexStep.run(next) case 'agents-md': - return handoffAgentsMdStep.run(next, provider) + return handoffAgentsMdStep.run(next) case 'wizard': - return handoffWizardStep.run(next, provider) + return handoffWizardStep.run(next) } }, } diff --git a/packages/cli/src/commands/init/types.ts b/packages/cli/src/commands/init/types.ts index d6725af0..da72b3f4 100644 --- a/packages/cli/src/commands/init/types.ts +++ b/packages/cli/src/commands/init/types.ts @@ -67,7 +67,11 @@ export interface InitState { export interface InitStep { id: string name: string - run(state: InitState, provider: InitProvider): Promise + /** `provider` is optional. The init pipeline passes one (it owns + * intro copy and provider-specific defaults); the post-init handoff + * steps invoked by `stash plan` / `stash impl` don't have a provider + * to give and don't use one. */ + run(state: InitState, provider?: InitProvider): Promise } export interface InitProvider { diff --git a/packages/cli/src/commands/plan/index.ts b/packages/cli/src/commands/plan/index.ts index 43b0b9f5..da3f11dc 100644 --- a/packages/cli/src/commands/plan/index.ts +++ b/packages/cli/src/commands/plan/index.ts @@ -9,25 +9,9 @@ import { CONTEXT_REL_PATH, type ContextFile, } from '../init/lib/write-context.js' -import { - CancelledError, - type InitProvider, - type InitState, -} from '../init/types.js' +import { CancelledError, type InitState } from '../init/types.js' import { detectPackageManager, runnerCommand } from '../init/utils.js' -/** - * `stash plan` borrows the same handoff machinery as `stash impl`. The - * handoff steps don't actually use the provider (their `run` signatures - * take `_provider`); the abstraction is an init-time concept for intro - * copy. A stub satisfies the type. - */ -const STUB_PROVIDER: InitProvider = { - name: 'plan', - introMessage: '', - getNextSteps: () => [], -} - function buildStateFromContext( ctx: ContextFile, agents: AgentEnvironment, @@ -82,7 +66,7 @@ export async function planCommand() { const agents = detectAgents(cwd, process.env) const state = buildStateFromContext(ctx, agents) - await howToProceedStep.run(state, STUB_PROVIDER) + await howToProceedStep.run(state) // Chain into `stash impl` so the user doesn't have to copy/paste. Lazy // import avoids a circular module load — plan and impl both pull from From 8266859a54b195caa5336cfa80f4895445668e7a Mon Sep 17 00:00:00 2001 From: CJ Brewer Date: Wed, 6 May 2026 08:54:59 -0600 Subject: [PATCH 6/6] chore: address coderabbit feedback --- .../commands/impl/steps/handoff-agents-md.ts | 6 ++-- .../src/commands/impl/steps/handoff-claude.ts | 6 ++-- .../src/commands/impl/steps/handoff-codex.ts | 6 ++-- .../src/commands/impl/steps/handoff-wizard.ts | 6 ++-- .../src/commands/impl/steps/how-to-proceed.ts | 7 ++-- .../init/lib/__tests__/parse-plan.test.ts | 11 ++++++ .../init/lib/__tests__/read-context.test.ts | 36 +++++++++++++++++++ .../cli/src/commands/init/lib/parse-plan.ts | 10 +++++- .../cli/src/commands/init/lib/read-context.ts | 33 ++++++++++++++--- packages/cli/src/commands/init/types.ts | 28 ++++++++++++--- packages/cli/src/commands/status/index.ts | 14 ++------ 11 files changed, 125 insertions(+), 38 deletions(-) diff --git a/packages/cli/src/commands/impl/steps/handoff-agents-md.ts b/packages/cli/src/commands/impl/steps/handoff-agents-md.ts index 5f5178f2..958eadb3 100644 --- a/packages/cli/src/commands/impl/steps/handoff-agents-md.ts +++ b/packages/cli/src/commands/impl/steps/handoff-agents-md.ts @@ -8,7 +8,7 @@ import { CONTEXT_REL_PATH, SETUP_PROMPT_REL_PATH, } from '../../init/lib/write-context.js' -import type { InitProvider, InitState, InitStep } from '../../init/types.js' +import type { HandoffStep, InitState } from '../../init/types.js' const AGENTS_MD_REL_PATH = 'AGENTS.md' @@ -25,10 +25,10 @@ const AGENTS_MD_REL_PATH = 'AGENTS.md' * tools wouldn't know to look there. Re-runs replace only the sentinel * region in AGENTS.md. */ -export const handoffAgentsMdStep: InitStep = { +export const handoffAgentsMdStep: HandoffStep = { id: 'handoff-agents-md', name: 'Write AGENTS.md', - async run(state: InitState, _provider?: InitProvider): Promise { + async run(state: InitState): Promise { const cwd = process.cwd() const integration = state.integration ?? 'postgresql' diff --git a/packages/cli/src/commands/impl/steps/handoff-claude.ts b/packages/cli/src/commands/impl/steps/handoff-claude.ts index e270b0f7..bfb5f610 100644 --- a/packages/cli/src/commands/impl/steps/handoff-claude.ts +++ b/packages/cli/src/commands/impl/steps/handoff-claude.ts @@ -5,7 +5,7 @@ import { CONTEXT_REL_PATH, SETUP_PROMPT_REL_PATH, } from '../../init/lib/write-context.js' -import type { InitProvider, InitState, InitStep } from '../../init/types.js' +import type { HandoffStep, InitState } from '../../init/types.js' const CLAUDE_SKILLS_DIR = '.claude/skills' @@ -18,10 +18,10 @@ const CLAUDE_INSTALL_URL = 'https://code.claude.com/docs/en/quickstart' * on PATH we still write the artifacts and print install + manual-launch * instructions. */ -export const handoffClaudeStep: InitStep = { +export const handoffClaudeStep: HandoffStep = { id: 'handoff-claude', name: 'Hand off to Claude Code', - async run(state: InitState, _provider?: InitProvider): Promise { + async run(state: InitState): Promise { const cwd = process.cwd() const integration = state.integration ?? 'postgresql' diff --git a/packages/cli/src/commands/impl/steps/handoff-codex.ts b/packages/cli/src/commands/impl/steps/handoff-codex.ts index 7344ae84..662fa7b2 100644 --- a/packages/cli/src/commands/impl/steps/handoff-codex.ts +++ b/packages/cli/src/commands/impl/steps/handoff-codex.ts @@ -9,7 +9,7 @@ import { CONTEXT_REL_PATH, SETUP_PROMPT_REL_PATH, } from '../../init/lib/write-context.js' -import type { InitProvider, InitState, InitStep } from '../../init/types.js' +import type { HandoffStep, InitState } from '../../init/types.js' const AGENTS_MD_REL_PATH = 'AGENTS.md' const CODEX_SKILLS_DIR = '.codex/skills' @@ -25,10 +25,10 @@ const CODEX_INSTALL_URL = 'https://github.com/openai/codex' * AGENTS.md is sentinel-upserted so re-runs replace only our region and * any user content outside it survives. */ -export const handoffCodexStep: InitStep = { +export const handoffCodexStep: HandoffStep = { id: 'handoff-codex', name: 'Hand off to Codex', - async run(state: InitState, _provider?: InitProvider): Promise { + async run(state: InitState): Promise { const cwd = process.cwd() const integration = state.integration ?? 'postgresql' diff --git a/packages/cli/src/commands/impl/steps/handoff-wizard.ts b/packages/cli/src/commands/impl/steps/handoff-wizard.ts index 8d2d5c7f..0d2d57d3 100644 --- a/packages/cli/src/commands/impl/steps/handoff-wizard.ts +++ b/packages/cli/src/commands/impl/steps/handoff-wizard.ts @@ -5,7 +5,7 @@ import { buildContextFile, writeContextFile, } from '../../init/lib/write-context.js' -import type { InitProvider, InitState, InitStep } from '../../init/types.js' +import type { HandoffStep, InitState } from '../../init/types.js' import { runWizardSpawn } from '../../wizard/index.js' /** @@ -20,10 +20,10 @@ import { runWizardSpawn } from '../../wizard/index.js' * No skills are installed here. The wizard fetches its own agent-side * prompt from the gateway and runs its own `maybeInstallSkills` flow. */ -export const handoffWizardStep: InitStep = { +export const handoffWizardStep: HandoffStep = { id: 'handoff-wizard', name: 'Use the CipherStash Agent', - async run(state: InitState, _provider?: InitProvider): Promise { + async run(state: InitState): Promise { const cwd = process.cwd() const envKeys = state.envKeys ?? [] diff --git a/packages/cli/src/commands/impl/steps/how-to-proceed.ts b/packages/cli/src/commands/impl/steps/how-to-proceed.ts index 932952d5..d5263974 100644 --- a/packages/cli/src/commands/impl/steps/how-to-proceed.ts +++ b/packages/cli/src/commands/impl/steps/how-to-proceed.ts @@ -2,10 +2,9 @@ import * as p from '@clack/prompts' import { CancelledError, type HandoffChoice, + type HandoffStep, type InitMode, - type InitProvider, type InitState, - type InitStep, } from '../../init/types.js' import { handoffAgentsMdStep } from './handoff-agents-md.js' import { handoffClaudeStep } from './handoff-claude.js' @@ -79,10 +78,10 @@ export function buildOptions( return options } -export const howToProceedStep: InitStep = { +export const howToProceedStep: HandoffStep = { id: 'how-to-proceed', name: 'How to proceed', - async run(state: InitState, _provider?: InitProvider): Promise { + async run(state: InitState): Promise { const mode: InitMode = state.mode ?? 'implement' const message = mode === 'plan' diff --git a/packages/cli/src/commands/init/lib/__tests__/parse-plan.test.ts b/packages/cli/src/commands/init/lib/__tests__/parse-plan.test.ts index 652048f4..63f0ef1a 100644 --- a/packages/cli/src/commands/init/lib/__tests__/parse-plan.test.ts +++ b/packages/cli/src/commands/init/lib/__tests__/parse-plan.test.ts @@ -61,6 +61,17 @@ describe('parsePlanSummary', () => { expect(parsePlanSummary(empty)).toBeUndefined() }) + it('rejects an empty columns array — falls back to soft summary', () => { + // An agent that writes `{"columns": []}` (genuinely empty plan, + // truncated write, or chat cutoff) would otherwise render as + // "0 columns across 0 tables — single-deploy", which is misleading. + // `stash impl` falls back to the "open in your editor" panel instead. + const md = `` + expect(parsePlanSummary(md)).toBeUndefined() + }) + it('tolerates extra unknown fields without dropping the parse', () => { // Future-proofing — agents may include estimated-deploys or other // ancillary keys. The parser should ignore them, not fail. diff --git a/packages/cli/src/commands/init/lib/__tests__/read-context.test.ts b/packages/cli/src/commands/init/lib/__tests__/read-context.test.ts index 70bd56a7..eaced0e3 100644 --- a/packages/cli/src/commands/init/lib/__tests__/read-context.test.ts +++ b/packages/cli/src/commands/init/lib/__tests__/read-context.test.ts @@ -54,4 +54,40 @@ describe('readContextFile', () => { ) expect(readContextFile(cwd)).toBeUndefined() }) + + it('returns undefined for an empty object — wrong shape, not "initialized"', () => { + // A `{}` file parses fine but doesn't carry `schemas`/`integration`/ + // `packageManager`. Downstream code (status, plan summary, etc.) + // dereferences those without guarding, so accepting `{}` would mean + // a hand-edited or partial-write file crashes the CLI. + writeContext({}) + expect(readContextFile(cwd)).toBeUndefined() + }) + + it('returns undefined when schemas is missing', () => { + writeContext({ + integration: 'drizzle', + packageManager: 'pnpm', + // schemas absent + }) + expect(readContextFile(cwd)).toBeUndefined() + }) + + it('returns undefined when integration is the wrong type', () => { + writeContext({ + integration: 42, + packageManager: 'pnpm', + schemas: [], + }) + expect(readContextFile(cwd)).toBeUndefined() + }) + + it('returns undefined when schemas is not an array', () => { + writeContext({ + integration: 'drizzle', + packageManager: 'pnpm', + schemas: 'oops', + }) + expect(readContextFile(cwd)).toBeUndefined() + }) }) diff --git a/packages/cli/src/commands/init/lib/parse-plan.ts b/packages/cli/src/commands/init/lib/parse-plan.ts index 3328a34d..9e810daa 100644 --- a/packages/cli/src/commands/init/lib/parse-plan.ts +++ b/packages/cli/src/commands/init/lib/parse-plan.ts @@ -49,7 +49,15 @@ function isPlanColumn(x: unknown): x is PlanColumn { function isPlanSummary(x: unknown): x is PlanSummary { if (!x || typeof x !== 'object') return false const obj = x as Record - return Array.isArray(obj.columns) && obj.columns.every(isPlanColumn) + // Empty `columns` is rejected: downstream `renderPlanSummary` would + // produce "0 columns across 0 tables — single-deploy", which is + // misleading. Treating empty as invalid lets `stash impl` fall back + // to the soft "open it in your editor" panel. + return ( + Array.isArray(obj.columns) && + obj.columns.length > 0 && + obj.columns.every(isPlanColumn) + ) } /** diff --git a/packages/cli/src/commands/init/lib/read-context.ts b/packages/cli/src/commands/init/lib/read-context.ts index 38edb61a..bee0d042 100644 --- a/packages/cli/src/commands/init/lib/read-context.ts +++ b/packages/cli/src/commands/init/lib/read-context.ts @@ -2,19 +2,42 @@ import { existsSync, readFileSync } from 'node:fs' import { resolve } from 'node:path' import { CONTEXT_REL_PATH, type ContextFile } from './write-context.js' +/** + * Validate that a parsed JSON value has the minimum shape callers rely + * on. We only check the fields downstream code dereferences without + * a guard — `integration`, `packageManager`, and `schemas`. Other + * fields (cliVersion, generatedAt, etc.) are informational and absent + * values won't crash anything. + * + * A wider schema check would belong in a runtime validator (zod, etc.); + * this is the minimum to keep `stash status`, `stash plan`, and `stash + * impl` from hard-failing on a hand-edited or partial-write file. + */ +function isContextFile(x: unknown): x is ContextFile { + if (!x || typeof x !== 'object') return false + const obj = x as Record + return ( + typeof obj.integration === 'string' && + typeof obj.packageManager === 'string' && + Array.isArray(obj.schemas) + ) +} + /** * Read the `.cipherstash/context.json` file written by `stash init`. - * Returns `undefined` when the file is missing or malformed — both `stash - * plan` and `stash impl` use that signal to point the user back at - * `stash init` rather than crashing. + * Returns `undefined` when the file is missing, unparseable, or doesn't + * have the expected shape — both `stash plan` and `stash impl` use that + * signal to point the user back at `stash init` rather than crashing. * - * Never throws on bad input. Malformed JSON is treated as "no context." + * Never throws on bad input. Malformed JSON and wrong-shape objects are + * both treated as "no context." */ export function readContextFile(cwd: string): ContextFile | undefined { const path = resolve(cwd, CONTEXT_REL_PATH) if (!existsSync(path)) return undefined try { - return JSON.parse(readFileSync(path, 'utf-8')) as ContextFile + const parsed = JSON.parse(readFileSync(path, 'utf-8')) as unknown + return isContextFile(parsed) ? parsed : undefined } catch { return undefined } diff --git a/packages/cli/src/commands/init/types.ts b/packages/cli/src/commands/init/types.ts index da72b3f4..438a6be7 100644 --- a/packages/cli/src/commands/init/types.ts +++ b/packages/cli/src/commands/init/types.ts @@ -64,14 +64,32 @@ export interface InitState { mode?: InitMode } +/** + * A step that runs as part of the `stash init` pipeline. The init + * pipeline owns the `InitProvider` (intro copy, provider-specific + * defaults) and threads it into every step. Some init steps consult it + * (e.g. `authenticateStep` reads `provider.name` for telemetry) so the + * argument is required at the type level — calling + * `authenticateStep.run(state)` without a provider would crash. + */ export interface InitStep { id: string name: string - /** `provider` is optional. The init pipeline passes one (it owns - * intro copy and provider-specific defaults); the post-init handoff - * steps invoked by `stash plan` / `stash impl` don't have a provider - * to give and don't use one. */ - run(state: InitState, provider?: InitProvider): Promise + run(state: InitState, provider: InitProvider): Promise +} + +/** + * A step that runs after init has finished — invoked by `stash plan` and + * `stash impl` to drive the agent handoff. These steps don't have an + * `InitProvider` available (init owns that abstraction) and don't need + * one, so the type intentionally omits it. Keeping `InitStep` and + * `HandoffStep` distinct prevents callers from accidentally invoking + * init-only steps from the post-init flow. + */ +export interface HandoffStep { + id: string + name: string + run(state: InitState): Promise } export interface InitProvider { diff --git a/packages/cli/src/commands/status/index.ts b/packages/cli/src/commands/status/index.ts index 32272eb8..3c8e5013 100644 --- a/packages/cli/src/commands/status/index.ts +++ b/packages/cli/src/commands/status/index.ts @@ -1,9 +1,9 @@ -import { existsSync, readFileSync } from 'node:fs' +import { existsSync } from 'node:fs' import { resolve } from 'node:path' import * as p from '@clack/prompts' +import { readContextFile } from '../init/lib/read-context.js' import { PLAN_REL_PATH } from '../init/lib/setup-prompt.js' import { - CONTEXT_REL_PATH, type ContextFile, SETUP_PROMPT_REL_PATH, } from '../init/lib/write-context.js' @@ -29,15 +29,7 @@ export interface ProjectStatus { } export function readProjectStatus(cwd: string): ProjectStatus { - const contextPath = resolve(cwd, CONTEXT_REL_PATH) - let context: ContextFile | undefined - if (existsSync(contextPath)) { - try { - context = JSON.parse(readFileSync(contextPath, 'utf-8')) as ContextFile - } catch { - // malformed context.json — treat as not-initialized - } - } + const context = readContextFile(cwd) return { initialized: context !== undefined, context,