-
Notifications
You must be signed in to change notification settings - Fork 3
Dan/init plan or implement 1 #427
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
calvinbrewer
wants to merge
1
commit into
dan/init-plan-or-implement
Choose a base branch
from
dan/init-plan-or-implement-1
base: dan/init-plan-or-implement
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+350
−113
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file was deleted.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
85 changes: 85 additions & 0 deletions
85
packages/cli/src/commands/impl/__tests__/derive-mode.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<string, unknown>): 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() | ||
| }) | ||
| }) |
4 changes: 2 additions & 2 deletions
4
...nds/init/__tests__/how-to-proceed.test.ts → ...nds/impl/__tests__/how-to-proceed.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<InitMode> { | ||
| 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<string, boolean>) { | ||
| 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 | ||
| } | ||
| } |
10 changes: 5 additions & 5 deletions
10
.../commands/init/steps/handoff-agents-md.ts → .../commands/impl/steps/handoff-agents-md.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
8 changes: 4 additions & 4 deletions
8
...src/commands/init/steps/handoff-claude.ts → ...src/commands/impl/steps/handoff-claude.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
12 changes: 6 additions & 6 deletions
12
.../src/commands/init/steps/handoff-codex.ts → .../src/commands/impl/steps/handoff-codex.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think the wording is the wrong way around. The command performs implementation but will generate a plan first if one exists?