Skip to content

fix(cli): report stash plan's real outcome — no 'Plan drafted' without the file (rc.3 M2)#766

Merged
coderdan merged 3 commits into
mainfrom
fix/plan-honest-outcome
Jul 23, 2026
Merged

fix(cli): report stash plan's real outcome — no 'Plan drafted' without the file (rc.3 M2)#766
coderdan merged 3 commits into
mainfrom
fix/plan-honest-outcome

Conversation

@coderdan

@coderdan coderdan commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Closes #738.

Problem

stash plan printed Plan drafted at .cipherstash/plan.md and exited 0 unconditionally. The plan file is written by the handed-off agent, not by the CLI — so a failed or deferred handoff produced a false success, and a downstream agent following the documented handoff read a file that was never created, with no signal anything went wrong (rc.3 skilltester finding M2). Same class of defect as #714 (non-interactive init false success); this applies the #687/#714 honesty treatment to plan.

Fix

planCommand now stats .cipherstash/plan.md before and after the handoff and reports the outcome that actually occurred:

Post-handoff state Behaviour
File written this run (new or revised) Plan drafted at … — unchanged happy path, including the interactive chain into stash impl
Agent launched (claude / codex / wizard) but file absent error + exit 1 — exit 0 can never again mean "a plan exists" when none was drafted
Deferred handoff (--target agents-md, or a CLI target that isn't installed) honest No plan drafted yet outro, exit 0 — the files-and-instructions contract was delivered; the plan lands when the user drives their agent
Pre-existing plan the run didn't modify reported as left unchanged by this run, not "drafted"

To distinguish "agent ran but wrote no plan" (an error) from "plan expected later" (deferred), the handoff steps now record whether they actually spawned an agent via a new optional InitState.agentLaunched field. init/impl ignore the field, so their behaviour is unchanged.

The exit-0-for-deferred choice mirrors the existing exit-0 "No handoff performed." hint path: those targets succeed at their documented contract (write files + instructions), and the outro no longer makes a false claim automation could act on.

Changes

  • packages/cli/src/commands/plan/index.ts — the outcome verification (core fix)
  • packages/cli/src/commands/impl/steps/handoff-{claude,codex,wizard}.ts — report agentLaunched: true after a spawn (regardless of exit code); the not-installed branches and agents-md leave it unset
  • packages/cli/src/commands/init/types.ts — the new optional InitState.agentLaunched field
  • packages/cli/src/messages.ts — stable outcome leaders under messages.plan (the e2e suite asserts on them, per the messages.ts convention)
  • packages/cli/tests/e2e/plan-outcome.e2e.test.ts — five piped-stdio e2e cases driving the built CLI with a fake claude on PATH (no DB, no network): agent-wrote-no-plan → exit 1; agent-wrote-plan → drafted; pre-existing-unchanged; agents-md deferral; claude-not-on-PATH deferral
  • skills/stash-cli/SKILL.md — documents the outcome contract in the plan section (skills ship in the stash tarball)
  • .changeset/plan-honest-outcome.mdstash patch

Testing

  • pnpm --filter stash test — 822 unit tests pass
  • pnpm --filter stash build && vitest run --config vitest.integration.config.ts — full e2e suite, 73 tests pass (incl. the 5 new ones)
  • pnpm -w run code:check — clean (exit 0)

Out of scope

  • stash init/plan rough edges surfaced by the rc.1 skilltester run (umbrella) #665 item 3 (nested claude spawn hanging inside an agent session) — different defect, loud rather than silent; unchanged here.
  • stash impl's outro wording — it doesn't claim a file exists, and its deliverable isn't a single on-disk artifact; left as is.
  • stash status's "Plan drafted" lines — already gated on the file existing.

Summary by CodeRabbit

  • Bug Fixes
    • stash plan now verifies that a plan was actually created before reporting success.
    • Reports when an existing plan remains unchanged.
    • Provides clear errors for missing plans or filesystem read failures.
    • Handles deferred handoffs with a “No plan drafted yet” message.
  • Documentation
    • Updated stash plan guidance to explain plan outcome messages and follow-up actions.
  • Tests
    • Added end-to-end coverage for successful, unchanged, deferred, missing, and unreadable plan scenarios.

Summary by CodeRabbit

  • Improvements
    • stash plan now verifies that a plan was actually created or updated before reporting success.
    • Clearly distinguishes newly drafted plans, unchanged existing plans, deferred handoffs, and failed plan creation.
    • Provides actionable error messages and appropriate exit statuses for automation.
    • Clarifies that a target must be specified when continuing with stash impl.
  • Documentation
    • Updated CLI guidance to explain plan verification and possible outcomes.

…t the file (rc.3 M2)

`stash plan` printed `Plan drafted at .cipherstash/plan.md` and exited 0
unconditionally. The plan file is written by the handed-off agent, not by
the CLI, so a failed or deferred handoff produced a false success — and
sent `stash impl` (and any automation reading the outro) after a file
that never existed. Same class of defect as #714's non-interactive init
false success; this applies the #687/#714 honesty treatment to `plan`.

The command now stats the plan file before and after the handoff and
reports what actually happened:

- File exists and was written this run → `Plan drafted at …` (unchanged
  happy path, including the interactive chain into `stash impl`).
- An agent was launched (claude / codex / wizard) but the file is absent
  → error + exit 1, so exit 0 can never mean "a plan exists" when none
  was drafted.
- Deferred handoff (`--target agents-md`, or a CLI target that isn't
  installed) → honest `No plan drafted yet` outro, exit 0 — the
  files-and-instructions contract was delivered; the plan lands when the
  user drives their agent.
- Pre-existing plan the run didn't modify → reported as `left unchanged
  by this run`, not "drafted".

The handoff steps now record whether they actually spawned an agent
(`InitState.agentLaunched`) so `plan` can tell "the agent ran but wrote
no plan" from "the plan is expected later". Outcome leaders live in
`messages.plan` per the e2e-string convention; five new pty-less e2e
cases drive the built CLI with a fake `claude` on PATH.

Closes #738
@coderdan
coderdan requested a review from a team as a code owner July 22, 2026 13:11
@changeset-bot

changeset-bot Bot commented Jul 22, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 5fafc09

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 11 packages
Name Type
stash Patch
@cipherstash/basic-example Patch
@cipherstash/e2e Patch
@cipherstash/stack Patch
@cipherstash/stack-drizzle Patch
@cipherstash/stack-supabase Patch
@cipherstash/prisma-next Patch
@cipherstash/wizard Patch
@cipherstash/bench Patch
@cipherstash/test-kit Patch
@cipherstash/prisma-next-example Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@coderabbitai

coderabbitai Bot commented Jul 22, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

stash plan now verifies .cipherstash/plan.md after handoff, distinguishes drafted and unchanged plans, handles deferred handoffs, reports missing writes as failures, and converts filesystem read errors into controlled non-zero exits.

Changes

Plan outcome verification

Layer / File(s) Summary
Record agent launch state
packages/cli/src/commands/init/types.ts, packages/cli/src/commands/impl/steps/handoff-*.ts
Handoff steps mark InitState.agentLaunched after launching Claude, Codex, or the wizard.
Verify and report plan outcomes
packages/cli/src/commands/plan/index.ts, packages/cli/src/messages.ts
The command compares plan metadata before and after handoff and reports drafted, unchanged, not-written, deferred, or unreadable-file outcomes.
Validate documented outcomes
packages/cli/tests/e2e/plan-outcome.e2e.test.ts, skills/stash-cli/SKILL.md, .changeset/plan-honest-outcome.md
End-to-end coverage and documentation describe plan results across handoff scenarios.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant User
  participant StashPlan
  participant Handoff
  participant Agent
  participant PlanFile

  User->>StashPlan: run stash plan
  StashPlan->>PlanFile: stat before handoff
  StashPlan->>Handoff: run selected target
  Handoff->>Agent: launch when available
  Agent->>PlanFile: write plan.md
  StashPlan->>PlanFile: stat after handoff
  StashPlan-->>User: report actual plan outcome
Loading

Possibly related PRs

Suggested reviewers: auxesis, calvinbrewer

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The PR matches #738 by verifying a usable plan file exists before reporting success and by failing or deferring honestly otherwise.
Out of Scope Changes check ✅ Passed The added state, messages, tests, and docs all support the plan-outcome fix and do not appear unrelated.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: stash plan now reports the real outcome instead of claiming success without a plan file.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/plan-honest-outcome

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes a correctness gap in the stash CLI where stash plan could claim success (“Plan drafted…”) and exit 0 even when .cipherstash/plan.md was never created, by verifying the plan file on disk after the handoff and reporting the real outcome.

Changes:

  • Add post-handoff plan file verification in stash plan, including distinguishing “drafted”, “unchanged”, “deferred”, and “agent ran but wrote nothing” outcomes.
  • Thread a new optional InitState.agentLaunched signal from handoff steps so plan can tell deferred handoffs from failed “agent wrote nothing” runs.
  • Add e2e coverage + stable message leaders for the new outcome contract; document the contract in the shipped stash-cli skill and add a patch changeset.

Reviewed changes

Copilot reviewed 9 out of 9 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
skills/stash-cli/SKILL.md Documents the new “outro reflects real outcome” contract for stash plan.
packages/cli/tests/e2e/plan-outcome.e2e.test.ts Adds piped-stdio e2e cases covering drafted/unchanged/deferred/error outcomes.
packages/cli/src/messages.ts Centralizes stable messages.plan.* leaders asserted by e2e tests.
packages/cli/src/commands/plan/index.ts Implements pre/post-handoff stat checks and outcome-specific messaging/exit codes.
packages/cli/src/commands/init/types.ts Adds optional agentLaunched?: boolean to InitState.
packages/cli/src/commands/impl/steps/handoff-wizard.ts Marks agentLaunched: true after spawning the wizard.
packages/cli/src/commands/impl/steps/handoff-codex.ts Marks agentLaunched: true after spawning Codex.
packages/cli/src/commands/impl/steps/handoff-claude.ts Marks agentLaunched: true after spawning Claude Code.
.changeset/plan-honest-outcome.md Patch changeset for the stash package describing the behavior change.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread packages/cli/src/commands/plan/index.ts Outdated
Comment thread packages/cli/src/commands/plan/index.ts Outdated
Copilot review on #766: the pre-handoff `statSync` sat outside the try block
and both stat calls can throw for non-ENOENT filesystem errors (ENOTDIR when
`.cipherstash` is a file, EACCES on a locked path, ELOOP on a symlink loop),
surfacing as a generic "Fatal error" instead of the reliable outcome signal
this command exists to give (#738).

Wrap both stats in a `statPlan()` helper: `throwIfNoEntry: false` keeps ENOENT
as `undefined`, any other fs error becomes a `CliExit(1)` with an actionable
"Could not read `.cipherstash/plan.md`: <reason>" message. The pre-handoff stat
also moves inside the try so it can't bypass the command's error handling.

Adds an e2e case that makes plan.md a self-referential symlink (ELOOP) and
asserts the controlled non-zero exit + clear message.
@coderdan

Copy link
Copy Markdown
Contributor Author

Addressed Copilot's two statSync comments in c483b3bc.

Both were valid: the pre-handoff stat sat outside the try, and statSync(…, { throwIfNoEntry: false }) only turns ENOENT into undefined — a non-ENOENT fs error (ENOTDIR if .cipherstash is a file, EACCES on a locked path, ELOOP on a symlink loop) still throws, and would have surfaced as a generic "Fatal error" rather than the reliable outcome signal this command exists to give.

Fix: both stats now go through a statPlan() helper — ENOENT stays undefined, any other fs error becomes a controlled CliExit(1) with an actionable Could not read .cipherstash/plan.md: <reason> message. The pre-handoff stat also moved inside the try so it can't bypass the command's error handling.

Added an e2e case that makes plan.md a self-referential symlink (→ ELOOP) and asserts the controlled non-zero exit + clear message. Full CLI unit suite (822) and the plan e2e suite (6) pass; code:check clean.

Worth noting for context: the trigger is unlikely in practice — the earlier readContextFile reads .cipherstash/context.json, so by the time we stat, .cipherstash is already known to be a readable directory — but converting an opaque crash into a clean signal is exactly the honest-outcome principle of the PR, so it's worth doing.

@coderdan
coderdan requested a review from freshtonic July 23, 2026 01:38

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/cli/src/commands/plan/index.ts`:
- Around line 38-44: Update statPlan to return a valid result only when
statSync(planAbs, ...) yields stats.isFile(); treat directories and other
non-file paths as absent or unreadable so pre-existing-plan and post-handoff
unchanged flows cannot succeed. Add an E2E case covering a plan.md directory and
verify the expected rejection behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 0bc9ba69-fd55-4537-b4b1-995196cd64f8

📥 Commits

Reviewing files that changed from the base of the PR and between 2aeba94 and c483b3b.

📒 Files selected for processing (9)
  • .changeset/plan-honest-outcome.md
  • packages/cli/src/commands/impl/steps/handoff-claude.ts
  • packages/cli/src/commands/impl/steps/handoff-codex.ts
  • packages/cli/src/commands/impl/steps/handoff-wizard.ts
  • packages/cli/src/commands/init/types.ts
  • packages/cli/src/commands/plan/index.ts
  • packages/cli/src/messages.ts
  • packages/cli/tests/e2e/plan-outcome.e2e.test.ts
  • skills/stash-cli/SKILL.md

Comment thread packages/cli/src/commands/plan/index.ts

@freshtonic freshtonic left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed by Claude Code on behalf of James Sadler.

Verdict: APPROVE

This is a correct, well-scoped honesty fix. stash plan no longer claims "Plan drafted" unless the file is actually on disk after the handoff, and it distinguishes the four real outcomes (drafted / launched-but-missing -> exit 1 / deferred -> exit 0 / pre-existing-unchanged). I traced every branch against the head-of-branch source, not just the diff, and the logic holds up.

What I verified (beyond the diff)

  • Propagation of agentLaunched is the load-bearing part of the fix, and it's sound. howToProceedStep.run returns the handoff step's result directly (return handoffClaudeStep.run(next) etc.), so the new field reaches handedOff in planCommand. Had it returned a locally-merged state, the "agent ran but wrote nothing" case would have silently fallen into the deferred exit-0 branch and defeated the whole PR. It doesn't.
  • The launch-vs-defer split is set at the right seams. handoff-claude/-codex set agentLaunched: true only after spawnAgent(...); their not-installed branches return bare state. handoff-agents-md never sets it. handoff-wizard always sets it (it always spawns runWizardSpawn). So !planAfter && agentLaunched fires exactly when an agent process actually ran and produced nothing.
  • Error routing is correct. statPlan converts non-ENOENT fs errors (ELOOP/EACCES/ENOTDIR) into a controlled CliExit(1) with a clear message; the catch in planCommand only special-cases CancelledError and re-throws everything else, so both the statPlan exit and the notWritten CliExit(1) reach the top-level exit handler. E2E confirms the exit codes.
  • impl is genuinely unaffected - it calls await howToProceedStep.run(...) and discards the result, so the new optional field is ignored.
  • Message composition and the .not.toContain(messages.plan.drafted) assertions don't collide - "No plan drafted yet" / "No plan was drafted." don't contain the exact leader "Plan drafted at" (case + wording differ), so the negative assertions are meaningful, not accidentally-passing.

Test coverage is strong: five piped-stdio e2e cases plus the ELOOP fs-error case, driving the built CLI with a fake claude on PATH and no DB/network. This mirrors the #714 treatment.

Non-blocking observations

  1. Change detection is a mtimeMs-or-size heuristic (plan/index.ts). An in-place revision that keeps the same byte size and lands within the same mtime tick would be misreported as "left unchanged by this run". In practice agent writes almost always bump mtime, and the file is still usable either way, so the blast radius is a cosmetic wording error - not worth a content hash. Worth a comment acknowledging the heuristic's limit if you touch this again.
  2. The revise-and-drafted path isn't covered by e2e - you test pre-existing->unchanged (agent writes nothing) and no-plan->drafted (agent writes fresh), but not pre-existing + agent-rewrites -> "drafted". That's the one combination exercising the mtimeMs !== ... || size !== ... limb specifically. Cheap to add (fake claude that appends to an existing plan.md).
  3. Behavior change for a broken/looping symlink at plan.md: previously existsSync returned false (treated as "no plan"); now statPlan errors and exits 1. This is intentional and defensible ("opaque fs state must not read as success"), and it's called out in the changeset - flagging only so it's a conscious call.
  4. Wizard always errors-on-no-plan even when the wizard couldn't really start (gateway/network failure still returns a non-zero exit code but agentLaunched: true). Consistent with the stated "regardless of exit code" intent, so acceptable, but it means a wizard that never got off the ground reports as "agent ran, wrote nothing" rather than a deferral. Minor.

None of these block. Good, honest fix with the right test discipline.

@tobyhede

Copy link
Copy Markdown
Contributor

Verified against c483b3bc with a built CLI. All reproductions below are real runs, not code reading.

1. Deferred handoff + pre-existing plan loses the deferral signal

--target agents-md (or claude-code with no claude on PATH) in a fixture that already has .cipherstash/plan.md: exit 0, outro Plan at .cipherstash/plan.md left unchanged by this run. Review it, then run npx stash impl …. noPlanYet never prints — it lives inside if (!planAfter), so once the file exists handedOff.agentLaunched is never read.

Under a PTY, a bare Enter on the default-yes confirm chains straight through:

◆  Plan at `.cipherstash/plan.md` left unchanged by this run. Continue to `pnpm dlx stash impl` now?
│  ● Yes / ○ No
└  Plan complete — handing off to `stash impl`.
┌  CipherStash Implementation
◆  Proceed with implementation against this plan?

The AGENTS.md handoff hasn't been driven yet. The "pre-existing plan" test uses a fake claude that runs, so this cross is untested.

2. wrote reports "Plan drafted" when this run drafted nothing

Two reproductions:

  • Fake agent that only touches the file — same 11 bytes, same md5 — outro Plan drafted at .cipherstash/plan.md, exit 0.
  • Dangling symlink .cipherstash/plan.md -> setup-prompt.md with --target agents-md. statSync(…, {throwIfNoEntry: false}) returns undefined for a dangling link (verified), so planBefore is undefined; the handoff writes setup-prompt.md, the link resolves, !planBefore short-circuits wrote to truePlan drafted, exit 0, pointing at the setup prompt. That's stash plan reports "Plan drafted at .cipherstash/plan.md" and exits 0 without writing the file (rc.3 M2) #738 reintroduced through the !planBefore arm.

The fixture is contrived; the root cause isn't. The command verifies something is stat-able at the path, never that a plan appeared — and it writes two sibling files into .cipherstash/ during the window it's measuring.

3. stash init can now exit 1, undocumented and untested

planCommand's catch rethrows non-CancelledError → init's identical catch → main.ts's init arm (no try/catch) → run()'s CliExit handler → process.exit(1). Verified: init-command.test.ts's mock set with planCommand throwing CliExit(1) makes initCommand({}, {}) reject with it.

Unreachable pre-PR — plan's only CliExit(1) was confirmCompleteRollout, and init calls planCommand() with no flags. init-command.test.ts:46 mocks planCommand as always-resolving, so the accept-arm test at :261 passes trivially. Neither the changeset nor SKILL.md:39 (which does enumerate init's non-zero exits) mentions it.

4. The ELOOP test doesn't cover the branch it's placed for

Full output is the intro plus Could not read …: ELOOP. No AGENTS.md, no setup-prompt.md, no "Drafting an encryption-rollout plan" line — it dies at the pre-handoff stat, before howToProceedStep.run. The catch does execute, so this is a call-site gap, not dead code: the post-handoff statPlan the docblock argues for is uncovered, and not.toContain(drafted) is trivially true.

5. Wizard failures are misattributed

handoff-wizard.ts:47 returns agentLaunched: true unconditionally, with no installed-agent gate unlike the claude/codex steps. plan --target wizard on a broken PATH ends with The agent may have been interrupted before saving it — it was never launched.

Not silent, though: runWizardSpawn logs Failed to launch wizard: spawn npx ENOENT and the step warns Wizard exited with code 127. Message accuracy, not a missing signal. Same for spawnAgent's -1 — the helper is silent but every caller warns (Claude Code exited with code -1).

Adjacent: isOnPath (detect-agents.ts:46) checks only existsSync, not the executable bit, which is what makes that -1 path reachable.

6. Non-zero agent exit with a plan on disk reads as success

Fake claude writes the plan then exit 3▲ Claude Code exited with code 3. then └ Plan drafted at .cipherstash/plan.md, exit 0. The human sees the warning; the outro line and exit code — what automation reads — say success. A partially written plan from a crashed agent is presented as complete. Untested.

7. Test gaps

packages/cli/src/commands/plan/ has no __tests__ at all. impl/steps/__tests__/ has only handoff-claude.test.ts. No --target wizard or --target codex anywhere in e2e. No deferred × pre-existing case, no init→failing-plan case. The 6 e2e cases are the entire coverage of this logic.

Notes

SKILL.md:266 states the deferred rule categorically, then the next sentence describes the left unchanged case without saying which wins when both hold. Finding 1 makes the categorical sentence wrong, not just underspecified.

plan-outcome.e2e.test.ts is the first file under packages/cli/tests/ to hardcode #!/bin/sh, chmod 0o755, and a literal PATH=/usr/bin:/bin. Harmless today — no Windows leg exists.

runPiped's env merging and the {...state, agentLaunched: true} change both hold up: two callers of howToProceedStep.run (impl discards, plan reads one field), no identity comparisons, and the flag is excluded from both buildContextFile and buildSetupPromptContext — they construct field-by-field with no state spread, so it can't leak into serialized state.

… feedback)

Addresses CodeRabbit + reviewer feedback on #766.

CodeRabbit (Major): `statSync` succeeds for a DIRECTORY at `.cipherstash/plan.md`,
so `statPlan` returned a truthy Stats for it — a plan.md directory read as a
pre-existing plan (false "already exists" warning) and, with an agent that wrote
nothing, as a false "unchanged" exit 0 against something no agent can consume.
`statPlan` now gates on `stats.isFile()`, treating any non-file as absent, so a
plan.md directory routes to the "no plan written" branch (exit 1 when an agent
ran). New e2e covers it.

Reviewer non-blocking notes also addressed:
- Documents the change-detection heuristic's limit (an in-place rewrite keeping
  both byte size and the mtime tick would misreport as "unchanged"; acceptable,
  not worth hashing a large file per run).
- Adds the missing e2e for pre-existing -> agent-revises -> "drafted", which is
  the case that specifically exercises the mtime/size change-detection limb.

Full CLI unit suite (822) and plan e2e (8) green; code:check clean.
@coderdan

Copy link
Copy Markdown
Contributor Author

Addressed the review feedback in 5fafc09a.

CodeRabbit — reject non-file plan paths (Major): valid. statSync succeeds for a directory at .cipherstash/plan.md, so statPlan returned a truthy Stats for it — a plan.md directory read as a pre-existing plan (false "already exists" warning) and, with an agent that wrote nothing, as a false "unchanged" exit 0 against something no agent can consume. statPlan now gates on stats.isFile(), treating any non-file as absent, so a plan.md directory routes to the "no plan written" branch (exit 1 when an agent ran). Added an e2e case (treats a plan.md directory as no plan, not a false "unchanged").

@freshtonic — thanks for the thorough trace. On the four non-blocking notes:

  1. mtime/size heuristic — added a comment at the wrote computation acknowledging the limit (an in-place rewrite preserving both byte size and the mtime tick misreports as "unchanged"; cosmetic, the plan is usable either way, not worth hashing a large file per run).
  2. no e2e for pre-existing → revised → drafted — added it (reports "drafted" when the agent revises a pre-existing plan); the append changes size, so it exercises the change-detection limb.
  3. symlink-at-plan.md behavior change — left as-is; intentional and called out in the changeset, as you noted.
  4. wizard always errors-on-no-plan even if it never started — left as-is; consistent with the stated "regardless of exit code" intent.

Full CLI unit suite (822) and the plan e2e suite (8) pass; code:check clean.

@coderdan
coderdan merged commit d6bc9e9 into main Jul 23, 2026
9 of 10 checks passed
@coderdan
coderdan deleted the fix/plan-honest-outcome branch July 23, 2026 05:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

stash plan reports "Plan drafted at .cipherstash/plan.md" and exits 0 without writing the file (rc.3 M2)

4 participants