From b9771e17e28ccf0dd74daaca8cc9d5821c1e920b Mon Sep 17 00:00:00 2001 From: CJ Brewer Date: Mon, 11 May 2026 13:41:31 -0600 Subject: [PATCH 1/2] fix(cli): detect plan before impl commences --- .../commands/status/__tests__/status.test.ts | 67 ++++++++++++++++++- packages/cli/src/commands/status/index.ts | 9 ++- packages/cli/src/commands/status/quest.ts | 22 ++++-- packages/cli/src/commands/status/render.ts | 50 ++++++++++---- 4 files changed, 128 insertions(+), 20 deletions(-) diff --git a/packages/cli/src/commands/status/__tests__/status.test.ts b/packages/cli/src/commands/status/__tests__/status.test.ts index 9c3973b6..921b4b91 100644 --- a/packages/cli/src/commands/status/__tests__/status.test.ts +++ b/packages/cli/src/commands/status/__tests__/status.test.ts @@ -284,6 +284,7 @@ describe('buildQuestLog', () => { it('separates active and completed quests', () => { const log = buildQuestLog({ initialized: true, + planExists: false, observedFromDb: true, observations: [ { table: 'users', column: 'email', phase: 'dropped' }, @@ -300,6 +301,7 @@ describe('buildQuestLog', () => { it('reports observedFromDb=false when DB couldn’t be reached', () => { const log = buildQuestLog({ initialized: true, + planExists: false, observedFromDb: false, observations: [{ table: 'users', column: 'email' }], cli: CLI, @@ -310,6 +312,7 @@ describe('buildQuestLog', () => { it('an empty observations list with initialized=true means the user has not declared columns yet', () => { const log = buildQuestLog({ initialized: true, + planExists: false, observedFromDb: true, observations: [], cli: CLI, @@ -324,6 +327,7 @@ describe('renderQuestLogTTY', () => { const out = renderQuestLogTTY( buildQuestLog({ initialized: false, + planExists: false, observedFromDb: false, observations: [], cli: CLI, @@ -339,6 +343,7 @@ describe('renderQuestLogTTY', () => { it('renders the active-quest section with progress bar, objectives, and next-move hint', () => { const log = buildQuestLog({ initialized: true, + planExists: false, observedFromDb: true, observations: [ { table: 'users', column: 'email', phase: 'dual-writing' }, @@ -362,6 +367,7 @@ describe('renderQuestLogTTY', () => { it('shows a πŸ† line per completed quest', () => { const log = buildQuestLog({ initialized: true, + planExists: false, observedFromDb: true, observations: [{ table: 'users', column: 'ssn', phase: 'dropped' }], cli: CLI, @@ -374,6 +380,7 @@ describe('renderQuestLogTTY', () => { it('appends a DB-unreachable note when observedFromDb is false', () => { const log = buildQuestLog({ initialized: true, + planExists: false, observedFromDb: false, observations: [{ table: 'users', column: 'email' }], cli: CLI, @@ -381,12 +388,34 @@ describe('renderQuestLogTTY', () => { const out = renderQuestLogTTY(log, CLI) expect(out).toMatch(/could not reach the database/i) }) + + it('points the user at impl when a plan is drafted but no quests exist yet', () => { + // Regression guard for the gap between `stash plan` (writes plan.md) + // and the manifest filling in (only happens when migrations actually + // run via `stash impl`). The empty-state must NOT tell the user to + // re-run plan when they have already drafted one. + const log = buildQuestLog({ + initialized: true, + planExists: true, + observedFromDb: false, + observations: [], + cli: CLI, + }) + const out = renderQuestLogTTY(log, CLI) + expect(out).toContain('Plan drafted') + expect(out).toContain('.cipherstash/plan.md') + expect(out).toContain(`${CLI} impl`) + expect(out).not.toMatch( + new RegExp(`${CLI.replace(/ /g, '\\s')}\\s+plan\\b`), + ) + }) }) describe('renderQuestLogPlain', () => { it('emits no emoji or progress-bar glyphs', () => { const log = buildQuestLog({ initialized: true, + planExists: false, observedFromDb: true, observations: [ { table: 'users', column: 'email', phase: 'dual-writing' }, @@ -409,6 +438,7 @@ describe('renderQuestLogPlain', () => { it('reports completed rollouts cleanly', () => { const log = buildQuestLog({ initialized: true, + planExists: false, observedFromDb: true, observations: [{ table: 'users', column: 'ssn', phase: 'dropped' }], cli: CLI, @@ -423,6 +453,7 @@ describe('renderQuestLogPlain', () => { const out = renderQuestLogPlain( buildQuestLog({ initialized: false, + planExists: false, observedFromDb: false, observations: [], cli: CLI, @@ -433,12 +464,26 @@ describe('renderQuestLogPlain', () => { expect(out).toContain(`${CLI} plan`) expect(out).not.toContain('npx stash') }) + + it('points the user at impl when a plan is drafted but no quests exist yet', () => { + const log = buildQuestLog({ + initialized: true, + planExists: true, + observedFromDb: false, + observations: [], + cli: CLI, + }) + const out = renderQuestLogPlain(log, CLI) + expect(out).toContain('.cipherstash/plan.md') + expect(out).toContain(`${CLI} impl`) + }) }) describe('renderQuestLogJSON', () => { it('emits a stable JSON shape with all fields a script needs', () => { const log = buildQuestLog({ initialized: true, + planExists: false, observedFromDb: true, observations: [ { table: 'users', column: 'email', phase: 'dual-writing' }, @@ -449,6 +494,9 @@ describe('renderQuestLogJSON', () => { const json = renderQuestLogJSON(log) const parsed = JSON.parse(json) expect(parsed.initialized).toBe(true) + // `planExists` is part of the stable JSON shape β€” scripts use it to + // tell "fresh project" from "plan drafted, awaiting execution". + expect(parsed.planExists).toBe(false) expect(parsed.observedFromDb).toBe(true) expect(parsed.active).toHaveLength(1) expect(parsed.completed).toHaveLength(1) @@ -470,6 +518,7 @@ describe('nextMoveHint', () => { it('points at init when uninitialized', () => { const log = buildQuestLog({ initialized: false, + planExists: false, observedFromDb: false, observations: [], cli: CLI, @@ -477,9 +526,10 @@ describe('nextMoveHint', () => { expect(nextMoveHint(log, CLI)).toMatch(/init/) }) - it('points at plan when initialized but no quests', () => { + it('points at plan when initialized but no plan and no quests', () => { const log = buildQuestLog({ initialized: true, + planExists: false, observedFromDb: true, observations: [], cli: CLI, @@ -487,9 +537,23 @@ describe('nextMoveHint', () => { expect(nextMoveHint(log, CLI)).toMatch(/plan/) }) + it('points at impl when a plan is drafted but no quests exist yet', () => { + const log = buildQuestLog({ + initialized: true, + planExists: true, + observedFromDb: false, + observations: [], + cli: CLI, + }) + const hint = nextMoveHint(log, CLI) + expect(hint).toContain(`${CLI} impl`) + expect(hint).toContain('.cipherstash/plan.md') + }) + it('uses the first active quest’s nextMove when one exists', () => { const log = buildQuestLog({ initialized: true, + planExists: false, observedFromDb: true, observations: [ { table: 'users', column: 'email', phase: 'dual-writing' }, @@ -502,6 +566,7 @@ describe('nextMoveHint', () => { it('reports complete when every quest is done', () => { const log = buildQuestLog({ initialized: true, + planExists: false, observedFromDb: true, observations: [{ table: 'users', column: 'ssn', phase: 'dropped' }], cli: CLI, diff --git a/packages/cli/src/commands/status/index.ts b/packages/cli/src/commands/status/index.ts index f11621a3..061493e5 100644 --- a/packages/cli/src/commands/status/index.ts +++ b/packages/cli/src/commands/status/index.ts @@ -196,6 +196,7 @@ async function buildLog( : { observedFromDb: false, observations: [] } return buildQuestLog({ initialized: project.initialized, + planExists: project.planExists, observedFromDb, observations, cli, @@ -205,12 +206,16 @@ async function buildLog( /** * One-line prompt for what to do next, used as the outro of both rendering * paths. Encodes the same routing the legacy `nextAction` helper used to: - * "haven't init'd β†’ init", "no quests β†’ plan", "active quest β†’ impl or - * follow the per-quest hint", "everything done β†’ relax". + * "haven't init'd β†’ init", "no plan β†’ plan", "plan drafted but no manifest + * entries β†’ impl", "active quest β†’ follow the per-quest hint", + * "everything done β†’ relax". */ export function nextMoveHint(log: QuestLog, cli: string): string { if (!log.initialized) return `Run \`${cli} init\` to begin.` if (log.active.length === 0 && log.completed.length === 0) { + if (log.planExists) { + return `Plan drafted at \`${PLAN_REL_PATH}\` β€” run \`${cli} impl\` to execute it.` + } return `Run \`${cli} plan\` to draft your encryption rollout.` } if (log.active.length === 0) { diff --git a/packages/cli/src/commands/status/quest.ts b/packages/cli/src/commands/status/quest.ts index 477d703b..57811d9f 100644 --- a/packages/cli/src/commands/status/quest.ts +++ b/packages/cli/src/commands/status/quest.ts @@ -33,6 +33,13 @@ export interface QuestLog { * user has not run `stash init`. The renderer surfaces an empty-state * message in that case. */ initialized: boolean + /** True when `.cipherstash/plan.md` exists. The plan is a markdown + * document drafted by the planning agent β€” it is *not* the same as the + * manifest (`migrations.json`), which only fills in once migrations + * actually run. When `planExists` is true but no quests have been + * derived from the manifest, the empty-state message points the user + * at `stash impl` instead of `stash plan`. */ + planExists: boolean /** Whether DB observability succeeded. When false, column quests are * still surfaced (from `migrations.json`) but objective state defaults * to "locked" because we can't tell what's been done. The renderer @@ -240,14 +247,18 @@ function nextMoveFor( * Compose a quest log from per-column observations. Pure; no I/O. * * `initialized` is true when the project has run `stash init` (we have a - * `context.json`). The renderer uses this to decide whether to show an - * empty-state quest log. `observedFromDb` is true when at least one - * observation has live DB data; false if the DB query failed and we're - * working from manifest alone. `cli` is the package-manager-aware - * command prefix passed through to per-quest `nextMove` hints. + * `context.json`). `planExists` is true when `.cipherstash/plan.md` has + * been drafted by the planning agent β€” together with an empty quest list, + * this is what disambiguates "user hasn't planned yet" (point at `plan`) + * from "plan is drafted but not yet executed" (point at `impl`). + * `observedFromDb` is true when at least one observation has live DB + * data; false if the DB query failed and we're working from manifest + * alone. `cli` is the package-manager-aware command prefix passed + * through to per-quest `nextMove` hints. */ export function buildQuestLog(input: { initialized: boolean + planExists: boolean observedFromDb: boolean observations: ColumnObservation[] cli: string @@ -263,6 +274,7 @@ export function buildQuestLog(input: { } return { initialized: input.initialized, + planExists: input.planExists, observedFromDb: input.observedFromDb, active, completed, diff --git a/packages/cli/src/commands/status/render.ts b/packages/cli/src/commands/status/render.ts index 732e0aac..d882a1e3 100644 --- a/packages/cli/src/commands/status/render.ts +++ b/packages/cli/src/commands/status/render.ts @@ -1,4 +1,10 @@ -import { type ColumnQuest, type Objective, type QuestLog, isComplete } from './quest.js' +import { PLAN_REL_PATH } from '../init/lib/setup-prompt.js' +import { + type ColumnQuest, + type Objective, + type QuestLog, + isComplete, +} from './quest.js' const PROGRESS_BAR_WIDTH = 6 @@ -26,12 +32,21 @@ export function renderQuestLogTTY(log: QuestLog, cli: string): string { const lines: string[] = ['βš”οΈ CipherStash Quest Log', ''] if (log.active.length === 0 && log.completed.length === 0) { - lines.push( - ' No active quests yet β€” your encryption rollout has not begun.', - '', - ` First move: run \`${cli} plan\` to draft a rollout for the`, - ' columns you want to protect.', - ) + if (log.planExists) { + lines.push( + ' Plan drafted β€” your encryption rollout is ready to execute.', + '', + ` Plan: \`${PLAN_REL_PATH}\``, + ` Next move: run \`${cli} impl\` to execute the plan.`, + ) + } else { + lines.push( + ' No active quests yet β€” your encryption rollout has not begun.', + '', + ` First move: run \`${cli} plan\` to draft a rollout for the`, + ' columns you want to protect.', + ) + } return lines.join('\n') } @@ -41,7 +56,9 @@ export function renderQuestLogTTY(log: QuestLog, cli: string): string { } for (const quest of log.completed) { - lines.push(` πŸ† COMPLETED Β· ${quest.table}.${quest.column} β€” fully encrypted`) + lines.push( + ` πŸ† COMPLETED Β· ${quest.table}.${quest.column} β€” fully encrypted`, + ) } if (!log.observedFromDb) { @@ -118,10 +135,18 @@ export function renderQuestLogPlain(log: QuestLog, cli: string): string { const lines: string[] = ['CipherStash status β€” encryption rollout', ''] if (log.active.length === 0 && log.completed.length === 0) { - lines.push( - ' No active quests.', - ` First move: run \`${cli} plan\` to draft a rollout.`, - ) + if (log.planExists) { + lines.push( + ' No active rollouts β€” plan is drafted but not executed.', + ` Plan: ${PLAN_REL_PATH}`, + ` Next move: run \`${cli} impl\` to execute the plan.`, + ) + } else { + lines.push( + ' No active quests.', + ` First move: run \`${cli} plan\` to draft a rollout.`, + ) + } return lines.join('\n') } @@ -189,6 +214,7 @@ export function renderQuestLogJSON(log: QuestLog): string { return `${JSON.stringify( { initialized: log.initialized, + planExists: log.planExists, observedFromDb: log.observedFromDb, active: log.active.map(serializeQuest), completed: log.completed.map(serializeQuest), From 3a38f1a3f4b8b8ec4e68a1fe66655a93058b6b77 Mon Sep 17 00:00:00 2001 From: CJ Brewer Date: Mon, 11 May 2026 13:43:36 -0600 Subject: [PATCH 2/2] chore: changeset --- .changeset/ten-deserts-hammer.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/ten-deserts-hammer.md diff --git a/.changeset/ten-deserts-hammer.md b/.changeset/ten-deserts-hammer.md new file mode 100644 index 00000000..1b681b9c --- /dev/null +++ b/.changeset/ten-deserts-hammer.md @@ -0,0 +1,5 @@ +--- +"stash": patch +--- + +`stash status` now detects when a plan has been drafted but the rollout hasn't started yet. Previously, with no `cs_migrations` activity, status reported "your encryption rollout has not begun" and pointed the user at `stash plan` β€” even when `.cipherstash/plan.md` already existed. It now recognises that case and points the user at `stash impl` to execute the plan instead.