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-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-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/.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..e920f218 100644 --- a/packages/cli/src/bin/stash.ts +++ b/packages/cli/src/bin/stash.ts @@ -16,10 +16,12 @@ 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, installCommand, + planCommand, statusCommand, testConnectionCommand, upgradeCommand, @@ -75,7 +77,9 @@ ${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 the plan with a local agent + status Displays implementation status auth Authenticate with CipherStash wizard AI-guided encryption setup (reads your codebase) @@ -107,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 planning and go straight to implementation + (interactively confirms before proceeding) DB Flags: --force (install) Reinstall / overwrite even if already installed @@ -125,8 +129,10 @@ 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 ${STASH} db install @@ -232,7 +238,7 @@ async function runDbCommand( break } case 'status': - await statusCommand({ databaseUrl }) + await dbStatusCommand({ databaseUrl }) break case 'test-connection': await testConnectionCommand({ databaseUrl }) @@ -375,9 +381,15 @@ async function main() { case 'init': await initCommand(flags) break + case 'plan': + await planCommand() + break 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..4b5ad386 100644 --- a/packages/cli/src/commands/impl/index.ts +++ b/packages/cli/src/commands/impl/index.ts @@ -2,88 +2,19 @@ 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 { 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 InitMode, - 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 (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 { @@ -91,32 +22,45 @@ 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 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: 'Implementation can take some time. 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() @@ -133,33 +77,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 - if (mode === 'plan') { - p.log.info( - `No plan at \`${PLAN_REL_PATH}\`. The agent will draft one for you to review.`, - ) + 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.log.info( + `Plan at \`${PLAN_REL_PATH}\` — agent will execute it as the source of truth.`, + ) + } } else { - p.log.info( - `Plan at \`${PLAN_REL_PATH}\` — the agent will execute it as the source of truth.`, - ) + // 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) + await howToProceedStep.run(state) - 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/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/index.ts b/packages/cli/src/commands/index.ts index 0c06af10..ab699d84 100644 --- a/packages/cli/src/commands/index.ts +++ b/packages/cli/src/commands/index.ts @@ -1,9 +1,11 @@ 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 { 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/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/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/__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/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 54d9057d..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', '', @@ -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).", ), @@ -400,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..da72b3f4 100644 --- a/packages/cli/src/commands/init/types.ts +++ b/packages/cli/src/commands/init/types.ts @@ -58,16 +58,20 @@ 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 } 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 new file mode 100644 index 00000000..da3f11dc --- /dev/null +++ b/packages/cli/src/commands/plan/index.ts @@ -0,0 +1,97 @@ +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 InitState } from '../init/types.js' +import { detectPackageManager, runnerCommand } from '../init/utils.js' + +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) + + // 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 new file mode 100644 index 00000000..b4db3ef4 --- /dev/null +++ b/packages/cli/src/commands/status/__tests__/status.test.ts @@ -0,0 +1,183 @@ +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 `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(/plan/) + 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 `plan` when initialized but no plan exists', () => { + writeContext(sampleContext) + expect(nextAction(readProjectStatus(cwd), 'pnpm dlx stash')).toMatch( + /\bplan\b/, + ) + }) + + 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..32272eb8 --- /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} plan\` 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} plan\` 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)) +}