Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/ten-deserts-hammer.md
Original file line number Diff line number Diff line change
@@ -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.
67 changes: 66 additions & 1 deletion packages/cli/src/commands/status/__tests__/status.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -324,6 +327,7 @@ describe('renderQuestLogTTY', () => {
const out = renderQuestLogTTY(
buildQuestLog({
initialized: false,
planExists: false,
observedFromDb: false,
observations: [],
cli: CLI,
Expand All @@ -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' },
Expand All @@ -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,
Expand All @@ -374,19 +380,42 @@ 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,
})
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' },
Expand All @@ -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,
Expand All @@ -423,6 +453,7 @@ describe('renderQuestLogPlain', () => {
const out = renderQuestLogPlain(
buildQuestLog({
initialized: false,
planExists: false,
observedFromDb: false,
observations: [],
cli: CLI,
Expand All @@ -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' },
Expand All @@ -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)
Expand All @@ -470,26 +518,42 @@ describe('nextMoveHint', () => {
it('points at init when uninitialized', () => {
const log = buildQuestLog({
initialized: false,
planExists: false,
observedFromDb: false,
observations: [],
cli: CLI,
})
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,
})
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' },
Expand All @@ -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,
Expand Down
9 changes: 7 additions & 2 deletions packages/cli/src/commands/status/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,7 @@ async function buildLog(
: { observedFromDb: false, observations: [] }
return buildQuestLog({
initialized: project.initialized,
planExists: project.planExists,
observedFromDb,
observations,
cli,
Expand All @@ -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) {
Expand Down
22 changes: 17 additions & 5 deletions packages/cli/src/commands/status/quest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -263,6 +274,7 @@ export function buildQuestLog(input: {
}
return {
initialized: input.initialized,
planExists: input.planExists,
observedFromDb: input.observedFromDb,
active,
completed,
Expand Down
50 changes: 38 additions & 12 deletions packages/cli/src/commands/status/render.ts
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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')
}

Expand All @@ -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) {
Expand Down Expand Up @@ -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')
}

Expand Down Expand Up @@ -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),
Expand Down