From 6575a2c14fec0f05ff569ded554d6ca8d78efc09 Mon Sep 17 00:00:00 2001 From: Lanh Hoang Date: Fri, 24 Jul 2026 23:37:43 -0400 Subject: [PATCH 01/10] docs: add implementation plan for Save Plan menu action and Pi 0.82 dependency upgrade --- .../plans/2026-07-24-save-plan-menu.md | 129 ++++++++++++++++++ 1 file changed, 129 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-24-save-plan-menu.md diff --git a/docs/superpowers/plans/2026-07-24-save-plan-menu.md b/docs/superpowers/plans/2026-07-24-save-plan-menu.md new file mode 100644 index 0000000..61a4a05 --- /dev/null +++ b/docs/superpowers/plans/2026-07-24-save-plan-menu.md @@ -0,0 +1,129 @@ +# Save Plan Menu Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add an idle-only `Save plan` menu action that gives the agent a one-turn, workspace-scoped permission to write the current plan to a new Markdown file, then returns to Plan mode with the plan retained. + +**Architecture:** Keep writing agent-mediated. The extension never writes the file directly and adds no persistent state or custom tool. A short-lived save context narrows active tools with public `pi.setActiveTools` to safe built-ins plus the existing built-in `write`; `tool_call` preflight validates exact content and a safe destination, freezes the approved input, and reserves one write call ID. Pi 0.82's `agent_settled` event ends the authorization after retries and queued follow-ups. + +**Tech Stack:** TypeScript, Pi extension APIs (`setActiveTools`, `agent_settled`, `tool_execution_end`), Node `fs`/`path`, Vitest, pnpm. + +--- + +The branch already has Pi coding-agent and Pi TUI dev dependencies at `^0.82.0`, with the lockfile resolving 0.82.0. Only the peer ranges remain at `*`. The existing baseline passes 256 tests and the package dry-run; verification currently emits the package's Node 24.15+ engine warning because the shell is running Node 23.11. + +## 1. Update Pi peer dependency floor + +**Files:** `package.json` + +- [ ] Change only the two peer dependency ranges in `package.json` to `>=0.82.0`; do not rewrite already-correct dev dependency or unrelated lockfile entries. +- [ ] Run `pnpm check`; expect Biome, TypeScript, and all existing tests to pass, with only the unsupported-Node engine warning when running under Node 23.11. + +## 2. Add the menu action and idle gating + +**Files:** `src/tui/menus.ts`, `src/index.ts`, `tests/tui/menus.test.ts`, `tests/helpers.ts` + +- [ ] Extend `PlanMenuAction` with `"save"` and add the label `Save plan`. +- [ ] In both the plan-ready menu and the management menu, show Save only when a plan exists and `ctx.isIdle()` is true. Keep ordering as `Implement`, `Save plan`, `Stay in Plan`, `Exit` for the ready menu and `Show plan`, `Implement`, `Save plan`, `Tools`, `Stay in Plan`, `Exit` for the management menu. +- [ ] Return a warning/no-op if a stale save action is received without a plan or while busy. +- [ ] Extend `createMockContext` with an optional `cwd` (defaulting to `process.cwd()`). Preserve the existing `isIdle` option and default it to `true` so menu tests can exercise busy suppression. +- [ ] Add menu tests for labels, ordering, missing-plan behavior, and busy suppression; update existing option-count assertions. + +**Verification:** `pnpm exec vitest run tests/tui/menus.test.ts tests/index.test.ts -t "menu|Save|stale"` + +## 3. Implement the transient save turn + +**Files:** `src/index.ts`, `tests/index.test.ts` + +- [ ] Add closure state: + ```ts + let planToSave: string | undefined; + let planWriteCallId: string | undefined; + let planSaveSucceeded = false; + ``` +- [ ] Add a `clearPlanSaveState()` helper that sets all three values to `undefined`/`false`. Add `activatePlanSaveTools()` using the public API: start with `SAFE_BUILTIN_PLAN_TOOLS`, append `write` only while no write is reserved or completed, and call `pi.setActiveTools(tools)`. Do not capture `previousTools` from this temporary tool set. +- [ ] On `save`, require an idle context, capture the exact current plan, reset the transient flags, activate strict tools, and call `sendPlanModeMessage` with instructions to choose a new `.md` path in an existing directory inside `ctx.cwd`, write the exact plan only, and make no other edits or implementation changes. +- [ ] In `before_agent_start`, branch before normal Plan-mode tool reapplication: while saving, call `activatePlanSaveTools()`, preserve `latestPlan` and `awaitingAction`, and append a narrow `[PLAN SAVE TURN]` exception to the system prompt. Leave normal-turn stale-plan clearing unchanged. +- [ ] In `agent_end`, return early during a save turn so save chatter cannot replace the stored plan or schedule another menu. +- [ ] Register `agent_settled` (Pi 0.82) to end the save turn after retries and queued follow-ups: retain the plan, clear transient state, restore normal selected tools directly with `pi.setActiveTools(planModeToolNamesWithSelections(state.selectedToolNames))`, and warn if no successful write occurred. +- [ ] Clear transient authorization in `doExit` and `session_shutdown`; restore normal tools on exit. + +**Verification:** Add integration tests that prove the save message contains the full plan, save prompts preserve it, `agent_end` does not extract/retrigger, `agent_settled` cleans up on success and failure, optional extension tools are suppressed, and ordinary Plan turns still block `write`. + +## 4. Add write preflight and race-safe call tracking + +**Files:** `src/index.ts`, `tests/index.test.ts` + +- [ ] Handle `write` in `tool_call` before the generic blocked-tool branch. Authorize only when a save is active, no write is reserved/completed, the path and content are strings, and `content === planToSave`. +- [ ] Accept only ordinary relative lowercase `.md` paths. Reject absolute paths, every `..` path segment, leading `~`, leading `@`, `file:` URLs, and Unicode-space variants. Resolve with `resolve(realpathSync(ctx.cwd), path)`. +- [ ] Require the parent directory to exist and be a directory, resolve workspace and parent with `realpathSync`, ensure containment with `relative` (not a string prefix), and reject any target directory entry with `lstatSync(target, { throwIfNoEntry: false })`, including broken symlinks. +- [ ] On valid preflight, freeze `event.input` so later extension handlers cannot mutate the approved path/content, reserve `event.toolCallId`, remove `write` from active tools immediately, and allow the built-in write to execute. On invalid input, block with a user-facing reason and default-deny filesystem exceptions. +- [ ] Handle the matching call ID in `tool_execution_end`, not only `tool_result`: clear and re-enable `write` after an execution error; mark success and keep `write` disabled after success. This also releases reservations for calls blocked by a later `tool_call` handler, because Pi emits `tool_execution_end` for those calls but no `tool_result`. +- [ ] Keep ordinary `edit`/`write` blocking and unsafe-bash behavior unchanged. Add `// ponytail: preflight existence check is not atomic; use an exclusive-create save tool if concurrent no-clobber becomes required.` +- [ ] Add boundary tests for exact content, altered content, existing targets, broken target symlinks, non-Markdown paths, missing/file parents, absolute/traversal/home/`@`/`file:`/Unicode-space paths, symlinked parents outside the workspace, frozen input, sibling write calls, later-handler blocking, retries after failure, and successful completion. + +**Verification:** `pnpm exec vitest run tests/index.test.ts -t "write|path|symlink|content|retry|execution_end"` + +## 5. Document the workflow + +**Files:** `README.md`, `CHANGELOG.md` + +- [ ] Document that Save plan is available only while idle, asks the agent to choose the filename, retains the plan in Plan mode, and grants temporary write permission only for that save turn. +- [ ] State that the preflight refuses existing directory entries, broken symlinks, traversal, and paths outside the workspace; do not claim atomic protection against a concurrent filesystem race. +- [ ] Remove the old `/plan:exit` plus next-prompt workaround. +- [ ] In Unreleased changelog entries, add explicit Save plan behavior and the Pi 0.82 minimum; retain the existing note about removing automatic exit-time prompts. + +## 6. Verify the complete change + +- [ ] Run `pnpm exec vitest run tests/tui/menus.test.ts tests/index.test.ts`. +- [ ] Run `pnpm check`. +- [ ] Run `pnpm run pack:dry-run`. +- [ ] Run the checks under Node 24.15+ so the declared engine requirement is satisfied; if only Node 23.11 is available, record the engine warning as an environment limitation. +- [ ] Manually smoke-test from a temporary directory with: + `/Users/lanh/Developer/pi-packages/pi/pi-test.sh -e /Users/lanh/Developer/pi-vault/pi-plan/src/index.ts --plan` + (enter a plan, choose Save plan, and inspect the newly created file). Record that this check requires Pi credentials. + +## Commit checkpoints + +- [ ] Commit dependency/API-floor changes. +- [ ] Commit menu and save-turn lifecycle changes. +- [ ] Commit write validation, tests, and documentation. + +## Required implementation contracts + +The implementation must preserve these exact interfaces and state transitions; do not introduce a new persistent state field: + +```ts +function clearPlanSaveState(): void { + planToSave = undefined; + planWriteCallId = undefined; + planSaveSucceeded = false; +} + +function activatePlanSaveTools(): void { + const tools = [...SAFE_BUILTIN_PLAN_TOOLS]; + if (planWriteCallId === undefined && !planSaveSucceeded) tools.push("write"); + pi.setActiveTools(tools); +} +``` + +The save branch in `before_agent_start` must run before the existing normal Plan-mode branch: + +```ts +if (state.enabled && planToSave !== undefined) { + activatePlanSaveTools(); + return { + systemPrompt: `${event.systemPrompt}\n\n${buildPlanModePrompt()}\n\n[PLAN SAVE TURN]\nUse only one approved write call for the exact captured plan. Do not edit, implement, or modify any other file.`, + }; +} +``` + +The write preflight must use this validation order: exact captured content, string path, relative-path restrictions, lowercase `.md` extension, existing parent directory, workspace containment, target `lstat`, freeze, then call-ID reservation. Any failed check returns `{ block: true, reason }` and leaves the reservation unset. A matching `tool_execution_end` with `isError: true` clears the reservation and re-enables `write`; a matching successful event sets `planSaveSucceeded` and leaves `write` disabled. + +The tests must drive the extension through `createMockPi().fireEvent(...)` and verify active-tool arrays, notifications, persisted plan state, and returned block objects. Filesystem boundary tests must use a temporary workspace supplied through `createMockContext({ cwd })`; they must not write into the repository. + +## Self-review + +- [ ] Confirm the implementation uses only public `pi.setActiveTools` and the Pi 0.82 `agent_settled`/`tool_execution_end` events. +- [ ] Confirm no task relies on a persistent save-state field, custom tool, direct extension file write, or an atomicity guarantee the design does not provide. +- [ ] Confirm tests cover the broken-symlink target case and mutation by later tool-call handlers, not only ordinary path traversal. From 838d0550a7c2fd7a45bc1587acbb7c46bafe0114 Mon Sep 17 00:00:00 2001 From: Lanh Hoang Date: Fri, 24 Jul 2026 23:41:24 -0400 Subject: [PATCH 02/10] deps: update dependencies --- package.json | 8 +- pnpm-lock.yaml | 218 +++++++++++++++++++++----------------------- pnpm-workspace.yaml | 5 + 3 files changed, 115 insertions(+), 116 deletions(-) diff --git a/package.json b/package.json index ab627d2..121a3b3 100644 --- a/package.json +++ b/package.json @@ -45,13 +45,13 @@ "README.md" ], "devDependencies": { - "@biomejs/biome": "^2.5.2", - "@earendil-works/pi-coding-agent": "^0.80.3", - "@earendil-works/pi-tui": "^0.80.3", + "@biomejs/biome": "^2.5.5", + "@earendil-works/pi-coding-agent": "^0.82.0", + "@earendil-works/pi-tui": "^0.82.0", "@types/node": "^26.1.0", "typebox": "^1.3.3", "typescript": "^6.0.3", - "vitest": "^4.1.9" + "vitest": "^4.1.10" }, "peerDependencies": { "@earendil-works/pi-coding-agent": "*", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3b94de9..ed12e84 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,14 +9,14 @@ importers: .: devDependencies: '@biomejs/biome': - specifier: ^2.5.2 - version: 2.5.2 + specifier: ^2.5.5 + version: 2.5.5 '@earendil-works/pi-coding-agent': - specifier: ^0.80.3 - version: 0.80.3(ws@8.21.0)(zod@4.4.3) + specifier: ^0.82.0 + version: 0.82.0(ws@8.21.0)(zod@4.4.3) '@earendil-works/pi-tui': - specifier: ^0.80.3 - version: 0.80.3 + specifier: ^0.82.0 + version: 0.82.0 '@types/node': specifier: ^26.1.0 version: 26.1.0 @@ -27,8 +27,8 @@ importers: specifier: ^6.0.3 version: 6.0.3 vitest: - specifier: ^4.1.9 - version: 4.1.9(@opentelemetry/api@1.9.0)(@types/node@26.1.0)(vite@8.0.16(@types/node@26.1.0)(jiti@2.7.0)(yaml@2.9.0)) + specifier: ^4.1.10 + version: 4.1.10(@opentelemetry/api@1.9.0)(@types/node@26.1.0)(vite@8.0.16(@types/node@26.1.0)(jiti@2.7.0)(yaml@2.9.0)) packages: @@ -146,79 +146,79 @@ packages: resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} engines: {node: '>=6.9.0'} - '@biomejs/biome@2.5.2': - resolution: {integrity: sha512-VQ3RCqr7JmDIX+w6stWYl+g/3bYofN3q2wDBHUKKc/c7i5QWrFKFBZYCYPWTE6agsUPMIZZe6/CMmVUfUAhkKA==} + '@biomejs/biome@2.5.5': + resolution: {integrity: sha512-r1S8nFsAG1MY+vJFZALzIvwXAJv6ejDQ0mxP21Tgr9YK3ZFtjrvbBwDdNhx1rUqvccEIeNg20cYCNzl6Cr69pQ==} engines: {node: '>=14.21.3'} hasBin: true - '@biomejs/cli-darwin-arm64@2.5.2': - resolution: {integrity: sha512-e7P3P7EkwFc/KiX2AHw4YDLIBOMfG9CPCAwy52k5Bp0dfhkozx9hf6wCmIr2QeXy2XeccJ3V/Sg+hDmzYEqxSg==} + '@biomejs/cli-darwin-arm64@2.5.5': + resolution: {integrity: sha512-kUrAhXVWUrwmAUnV2iXSK7umxKFysTwvqK+Ty6ptUcLY/7T3SnCAjUowE4uvwaEej6nXZ7hu/dTtbokKdsPeag==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [darwin] - '@biomejs/cli-darwin-x64@2.5.2': - resolution: {integrity: sha512-ymzMvjC1Jg0b9K0D26ZdARqFQXs7MocfLC5FOCGfkC0Ss+ACUJkX5364ZM5nT4NLZanHRZNVrZEy+Ibwcvux/g==} + '@biomejs/cli-darwin-x64@2.5.5': + resolution: {integrity: sha512-DamiYc5bUYZ2uxlfc+RLEPtz1Abb6PO5eTbOkufLpSGwd/7AMQAdxhFYiXmwwkJL8IsT8S7GvdgwDHqaMFAvKw==} engines: {node: '>=14.21.3'} cpu: [x64] os: [darwin] - '@biomejs/cli-linux-arm64-musl@2.5.2': - resolution: {integrity: sha512-w+ANG0ZvTu9IeEg9QnstoOnk6L0fpwJifW6aHR18+cb5Z39bkANItYjAfMrnvce5tmMK+IQ6nPX7/kQFdam5iw==} + '@biomejs/cli-linux-arm64-musl@2.5.5': + resolution: {integrity: sha512-U4WMl/sy/E/Q73vf15VspakLRRs2LDFcCeBxJnQfXzssb88zpV6PJPaQ3ezhQ7H6Ht2/8bvuZeHgJWzmoxllZg==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] libc: [musl] - '@biomejs/cli-linux-arm64@2.5.2': - resolution: {integrity: sha512-t7sseOmqND57uUWTwlawU6BYj+J06T/9EkydzBhkrgw/FK3QVhjU2wsJR0frljrKZ0/I8A/rYw7284QgqjQfIQ==} + '@biomejs/cli-linux-arm64@2.5.5': + resolution: {integrity: sha512-lRKF/pH/1RiYiBKExi3TCZVAtvzEm77aifrvcNiDFrR9WxeAnDUjDnseb6y2XV85mjitLs6SILGm2XG77cHtSQ==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] libc: [glibc] - '@biomejs/cli-linux-x64-musl@2.5.2': - resolution: {integrity: sha512-VArNLAzND063tF+XY0yPyM+DyahpzOMzOAvb7qs259nhjJWRjvjZdssuA+Rfl+l07+NOesKZ0Xu2yFrXyBMtzw==} + '@biomejs/cli-linux-x64-musl@2.5.5': + resolution: {integrity: sha512-m7wC7tjX5Lrmo69dc4md8FeKpPU1NTCY1v7xUoQQ2vadWwNnBS0KZOG8471otFPHrTHihQJAjQPgMObpLvDe6A==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] libc: [musl] - '@biomejs/cli-linux-x64@2.5.2': - resolution: {integrity: sha512-M/lOZrewzTCRDINbjhQ1gYYru37KlD3kJBQwwKCG0ckz5E9IZwIoJ3X0wBwRXA+yBDIwWUuPBHS67HzJY4dTfA==} + '@biomejs/cli-linux-x64@2.5.5': + resolution: {integrity: sha512-H/O39nJEw/2Zm/fm7hrmxxoF8kK/aU1uCoPp70ruXVbomaAdLpJJnCmL11Q2JotT8QVHH06So04Oq53lCSwSwQ==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] libc: [glibc] - '@biomejs/cli-win32-arm64@2.5.2': - resolution: {integrity: sha512-kbjFFKyZlzYnAuw7sRy5qDoFG6zrP40UK08oPQsWK0ct3NMnGSt+Bs1iviEEyEIP57N5MrykGXdO/wRiaR4lww==} + '@biomejs/cli-win32-arm64@2.5.5': + resolution: {integrity: sha512-7BryINPuYypLUAH3o/o5ZdgomJ4zn3EDR0ChZJst7n32S6ZhKbgHXuYydLu+YAnx59ehGFR0z/MG6qnzQi3Yyw==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [win32] - '@biomejs/cli-win32-x64@2.5.2': - resolution: {integrity: sha512-4InchVpdVmdkkkgjQqKpgvyu+VPnoF/7RPSw5YATgEVpt2j72wcCAeV5TwaE9ZGJUZWZn7v2CwSAj6CrMJEx8A==} + '@biomejs/cli-win32-x64@2.5.5': + resolution: {integrity: sha512-bIBFo+n6MIxdNcVFy5CrurbKiZQiUciK3bt8+O9I4wjFZNTfXLpi+giq47522eXqW5NBc9ulx7dR1SlZKi2J5g==} engines: {node: '>=14.21.3'} cpu: [x64] os: [win32] - '@earendil-works/pi-agent-core@0.80.3': - resolution: {integrity: sha512-3qw0/GeRQBU/nlGjDe5Yb7ePKTmoxefx2YxyKMFAviFUMXpFexBG/hS7mBtwFahFvzrrTPPoRT6sFIDjwoDWPQ==} + '@earendil-works/pi-agent-core@0.82.0': + resolution: {integrity: sha512-bnS9DpOKK5T/F/gQkaOnYdMsuuciWiScfAHHWC+k5OQ0HxjSqMFQvp8keurULLoT4+v8NHv4V14pNvd4hsfC0Q==} engines: {node: '>=22.19.0'} - '@earendil-works/pi-ai@0.80.3': - resolution: {integrity: sha512-jPZLMeGL5kkMSEAwAklfXTMHqZvfhsJtCCpKGIr5Duk7mc0n4skjB1dugk7y0z3z8ZHIUCmPAWHdyDqgUz5vdA==} + '@earendil-works/pi-ai@0.82.0': + resolution: {integrity: sha512-8MvW9+zno13sXDuT2kFMnWeTNUufUhPeZDRVO+igGoBRCDWgn7Xh2FkRQI1mRuet6QhF4ENQuLYdIAOyG6BhNw==} engines: {node: '>=22.19.0'} hasBin: true - '@earendil-works/pi-coding-agent@0.80.3': - resolution: {integrity: sha512-TIggw9gCXpA+Ph7OjdTA7ka2NPwTVuPmy39KDSyUzaKq8VvHfMGR7vtRz4JB7Um/RMRblmzhu4p9tUCk6MTgGA==} + '@earendil-works/pi-coding-agent@0.82.0': + resolution: {integrity: sha512-Qnqgn9zhJFQ2HZ8R4iNuGhyCk93XX6+eUw9i+TjTuo47amzCy93ft3bB6yaUCleCrNO58dJDHYSGNHv/GAPWKg==} engines: {node: '>=22.19.0'} hasBin: true - '@earendil-works/pi-tui@0.80.3': - resolution: {integrity: sha512-2BJI6qwRQfnM0Q7seL1+SbacU/jRRjBnN7Hu3n9BjAn7/s5FaBNnvdD1qBQYRsFTHfjqMaDsjYqanPyqwXj99w==} + '@earendil-works/pi-tui@0.82.0': + resolution: {integrity: sha512-9IDjQOXne7t9l2s2YcjnIBxsVNVPE7qScVSB3YmFlXsBW4pfo2gOElTxggV84KrRiGqABnlFPBWbf0k54hszHQ==} engines: {node: '>=22.19.0'} '@emnapi/core@1.10.0': @@ -521,20 +521,17 @@ packages: '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} - '@types/node@26.0.0': - resolution: {integrity: sha512-vf2YFi1iY9lHGwNJMs01biZFbKJkrZR1T6/MlzjhJLPdntOHLhTrDSnSVcdtvjihi4VQNlrFRIxLsDBlQpAipA==} - '@types/node@26.1.0': resolution: {integrity: sha512-O0A1G3xPGy4w7AgQdAQYUlQ+BKk2Oovw8eRpofyp5KdBZULnbe+WqaOVNrm705SHphCiG4XHsACrSmPu1f+Kgw==} '@types/retry@0.12.0': resolution: {integrity: sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==} - '@vitest/expect@4.1.9': - resolution: {integrity: sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==} + '@vitest/expect@4.1.10': + resolution: {integrity: sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==} - '@vitest/mocker@4.1.9': - resolution: {integrity: sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==} + '@vitest/mocker@4.1.10': + resolution: {integrity: sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==} peerDependencies: msw: ^2.4.9 vite: ^6.0.0 || ^7.0.0 || ^8.0.0 @@ -544,20 +541,20 @@ packages: vite: optional: true - '@vitest/pretty-format@4.1.9': - resolution: {integrity: sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==} + '@vitest/pretty-format@4.1.10': + resolution: {integrity: sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==} - '@vitest/runner@4.1.9': - resolution: {integrity: sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==} + '@vitest/runner@4.1.10': + resolution: {integrity: sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==} - '@vitest/snapshot@4.1.9': - resolution: {integrity: sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==} + '@vitest/snapshot@4.1.10': + resolution: {integrity: sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==} - '@vitest/spy@4.1.9': - resolution: {integrity: sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==} + '@vitest/spy@4.1.10': + resolution: {integrity: sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==} - '@vitest/utils@4.1.9': - resolution: {integrity: sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==} + '@vitest/utils@4.1.10': + resolution: {integrity: sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==} agent-base@7.1.4: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} @@ -1037,20 +1034,20 @@ packages: yaml: optional: true - vitest@4.1.9: - resolution: {integrity: sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==} + vitest@4.1.10: + resolution: {integrity: sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==} engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} hasBin: true peerDependencies: '@edge-runtime/vm': '*' '@opentelemetry/api': ^1.9.0 '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 - '@vitest/browser-playwright': 4.1.9 - '@vitest/browser-preview': 4.1.9 - '@vitest/browser-webdriverio': 4.1.9 - '@vitest/coverage-istanbul': 4.1.9 - '@vitest/coverage-v8': 4.1.9 - '@vitest/ui': 4.1.9 + '@vitest/browser-playwright': 4.1.10 + '@vitest/browser-preview': 4.1.10 + '@vitest/browser-webdriverio': 4.1.10 + '@vitest/coverage-istanbul': 4.1.10 + '@vitest/coverage-v8': 4.1.10 + '@vitest/ui': 4.1.10 happy-dom: '*' jsdom: '*' vite: ^6.0.0 || ^7.0.0 || ^8.0.0 @@ -1354,44 +1351,45 @@ snapshots: '@babel/runtime@7.29.7': {} - '@biomejs/biome@2.5.2': + '@biomejs/biome@2.5.5': optionalDependencies: - '@biomejs/cli-darwin-arm64': 2.5.2 - '@biomejs/cli-darwin-x64': 2.5.2 - '@biomejs/cli-linux-arm64': 2.5.2 - '@biomejs/cli-linux-arm64-musl': 2.5.2 - '@biomejs/cli-linux-x64': 2.5.2 - '@biomejs/cli-linux-x64-musl': 2.5.2 - '@biomejs/cli-win32-arm64': 2.5.2 - '@biomejs/cli-win32-x64': 2.5.2 - - '@biomejs/cli-darwin-arm64@2.5.2': + '@biomejs/cli-darwin-arm64': 2.5.5 + '@biomejs/cli-darwin-x64': 2.5.5 + '@biomejs/cli-linux-arm64': 2.5.5 + '@biomejs/cli-linux-arm64-musl': 2.5.5 + '@biomejs/cli-linux-x64': 2.5.5 + '@biomejs/cli-linux-x64-musl': 2.5.5 + '@biomejs/cli-win32-arm64': 2.5.5 + '@biomejs/cli-win32-x64': 2.5.5 + + '@biomejs/cli-darwin-arm64@2.5.5': optional: true - '@biomejs/cli-darwin-x64@2.5.2': + '@biomejs/cli-darwin-x64@2.5.5': optional: true - '@biomejs/cli-linux-arm64-musl@2.5.2': + '@biomejs/cli-linux-arm64-musl@2.5.5': optional: true - '@biomejs/cli-linux-arm64@2.5.2': + '@biomejs/cli-linux-arm64@2.5.5': optional: true - '@biomejs/cli-linux-x64-musl@2.5.2': + '@biomejs/cli-linux-x64-musl@2.5.5': optional: true - '@biomejs/cli-linux-x64@2.5.2': + '@biomejs/cli-linux-x64@2.5.5': optional: true - '@biomejs/cli-win32-arm64@2.5.2': + '@biomejs/cli-win32-arm64@2.5.5': optional: true - '@biomejs/cli-win32-x64@2.5.2': + '@biomejs/cli-win32-x64@2.5.5': optional: true - '@earendil-works/pi-agent-core@0.80.3(ws@8.21.0)(zod@4.4.3)': + '@earendil-works/pi-agent-core@0.82.0(ws@8.21.0)(zod@4.4.3)': dependencies: - '@earendil-works/pi-ai': 0.80.3(ws@8.21.0)(zod@4.4.3) + '@earendil-works/pi-ai': 0.82.0(ws@8.21.0)(zod@4.4.3) + diff: 8.0.4 ignore: 7.0.5 typebox: 1.1.38 yaml: 2.9.0 @@ -1403,7 +1401,7 @@ snapshots: - ws - zod - '@earendil-works/pi-ai@0.80.3(ws@8.21.0)(zod@4.4.3)': + '@earendil-works/pi-ai@0.82.0(ws@8.21.0)(zod@4.4.3)': dependencies: '@anthropic-ai/sdk': 0.91.1(zod@4.4.3) '@aws-sdk/client-bedrock-runtime': 3.1048.0 @@ -1424,11 +1422,11 @@ snapshots: - ws - zod - '@earendil-works/pi-coding-agent@0.80.3(ws@8.21.0)(zod@4.4.3)': + '@earendil-works/pi-coding-agent@0.82.0(ws@8.21.0)(zod@4.4.3)': dependencies: - '@earendil-works/pi-agent-core': 0.80.3(ws@8.21.0)(zod@4.4.3) - '@earendil-works/pi-ai': 0.80.3(ws@8.21.0)(zod@4.4.3) - '@earendil-works/pi-tui': 0.80.3 + '@earendil-works/pi-agent-core': 0.82.0(ws@8.21.0)(zod@4.4.3) + '@earendil-works/pi-ai': 0.82.0(ws@8.21.0)(zod@4.4.3) + '@earendil-works/pi-tui': 0.82.0 '@silvia-odwyer/photon-node': 0.3.4 chalk: 5.6.2 cross-spawn: 7.0.6 @@ -1454,7 +1452,7 @@ snapshots: - ws - zod - '@earendil-works/pi-tui@0.80.3': + '@earendil-works/pi-tui@0.82.0': dependencies: get-east-asian-width: 1.6.0 marked: 18.0.5 @@ -1702,54 +1700,50 @@ snapshots: '@types/estree@1.0.9': {} - '@types/node@26.0.0': - dependencies: - undici-types: 8.3.0 - '@types/node@26.1.0': dependencies: undici-types: 8.3.0 '@types/retry@0.12.0': {} - '@vitest/expect@4.1.9': + '@vitest/expect@4.1.10': dependencies: '@standard-schema/spec': 1.1.0 '@types/chai': 5.2.3 - '@vitest/spy': 4.1.9 - '@vitest/utils': 4.1.9 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.9(vite@8.0.16(@types/node@26.1.0)(jiti@2.7.0)(yaml@2.9.0))': + '@vitest/mocker@4.1.10(vite@8.0.16(@types/node@26.1.0)(jiti@2.7.0)(yaml@2.9.0))': dependencies: - '@vitest/spy': 4.1.9 + '@vitest/spy': 4.1.10 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: vite: 8.0.16(@types/node@26.1.0)(jiti@2.7.0)(yaml@2.9.0) - '@vitest/pretty-format@4.1.9': + '@vitest/pretty-format@4.1.10': dependencies: tinyrainbow: 3.1.0 - '@vitest/runner@4.1.9': + '@vitest/runner@4.1.10': dependencies: - '@vitest/utils': 4.1.9 + '@vitest/utils': 4.1.10 pathe: 2.0.3 - '@vitest/snapshot@4.1.9': + '@vitest/snapshot@4.1.10': dependencies: - '@vitest/pretty-format': 4.1.9 - '@vitest/utils': 4.1.9 + '@vitest/pretty-format': 4.1.10 + '@vitest/utils': 4.1.10 magic-string: 0.30.21 pathe: 2.0.3 - '@vitest/spy@4.1.9': {} + '@vitest/spy@4.1.10': {} - '@vitest/utils@4.1.9': + '@vitest/utils@4.1.10': dependencies: - '@vitest/pretty-format': 4.1.9 + '@vitest/pretty-format': 4.1.10 convert-source-map: 2.0.0 tinyrainbow: 3.1.0 @@ -2051,7 +2045,7 @@ snapshots: '@protobufjs/path': 1.1.2 '@protobufjs/pool': 1.1.0 '@protobufjs/utf8': 1.1.1 - '@types/node': 26.0.0 + '@types/node': 26.1.0 long: 5.3.2 retry@0.12.0: {} @@ -2141,15 +2135,15 @@ snapshots: jiti: 2.7.0 yaml: 2.9.0 - vitest@4.1.9(@opentelemetry/api@1.9.0)(@types/node@26.1.0)(vite@8.0.16(@types/node@26.1.0)(jiti@2.7.0)(yaml@2.9.0)): + vitest@4.1.10(@opentelemetry/api@1.9.0)(@types/node@26.1.0)(vite@8.0.16(@types/node@26.1.0)(jiti@2.7.0)(yaml@2.9.0)): dependencies: - '@vitest/expect': 4.1.9 - '@vitest/mocker': 4.1.9(vite@8.0.16(@types/node@26.1.0)(jiti@2.7.0)(yaml@2.9.0)) - '@vitest/pretty-format': 4.1.9 - '@vitest/runner': 4.1.9 - '@vitest/snapshot': 4.1.9 - '@vitest/spy': 4.1.9 - '@vitest/utils': 4.1.9 + '@vitest/expect': 4.1.10 + '@vitest/mocker': 4.1.10(vite@8.0.16(@types/node@26.1.0)(jiti@2.7.0)(yaml@2.9.0)) + '@vitest/pretty-format': 4.1.10 + '@vitest/runner': 4.1.10 + '@vitest/snapshot': 4.1.10 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 es-module-lexer: 2.1.0 expect-type: 1.3.0 magic-string: 0.30.21 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 1a6b9d1..ec76e6d 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,3 +1,8 @@ allowBuilds: "@google/genai": true protobufjs: true +minimumReleaseAgeExclude: + - '@earendil-works/pi-agent-core@0.82.0' + - '@earendil-works/pi-ai@0.82.0' + - '@earendil-works/pi-coding-agent@0.82.0' + - '@earendil-works/pi-tui@0.82.0' From b12749afb08ac7c771358b54518d85365345a6f2 Mon Sep 17 00:00:00 2001 From: Lanh Hoang Date: Fri, 24 Jul 2026 23:41:30 -0400 Subject: [PATCH 03/10] chore: update biome schema version to 2.5.5 --- biome.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/biome.json b/biome.json index 9eb76f0..2c7e084 100644 --- a/biome.json +++ b/biome.json @@ -1,5 +1,5 @@ { - "$schema": "https://biomejs.dev/schemas/2.5.2/schema.json", + "$schema": "https://biomejs.dev/schemas/2.5.5/schema.json", "vcs": { "enabled": true, "clientKind": "git", From 0e4618bd995196399fa110813ffbc34a8ba85da0 Mon Sep 17 00:00:00 2001 From: Lanh Hoang Date: Sat, 25 Jul 2026 00:28:51 -0400 Subject: [PATCH 04/10] chore: require Pi 0.82 --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 121a3b3..d774caa 100644 --- a/package.json +++ b/package.json @@ -54,7 +54,7 @@ "vitest": "^4.1.10" }, "peerDependencies": { - "@earendil-works/pi-coding-agent": "*", - "@earendil-works/pi-tui": "*" + "@earendil-works/pi-coding-agent": ">=0.82.0", + "@earendil-works/pi-tui": ">=0.82.0" } } From 054e1899d3691a730709e3e38c6e46f43a1c2f5d Mon Sep 17 00:00:00 2001 From: Lanh Hoang Date: Sat, 25 Jul 2026 00:44:43 -0400 Subject: [PATCH 05/10] feat: add plan save menu lifecycle --- src/index.ts | 65 ++++++++- src/tui/menus.ts | 33 ++++- tests/helpers.ts | 2 + tests/index.test.ts | 294 ++++++++++++++++++++++++++++++++++++++++ tests/tui/menus.test.ts | 101 +++++++++++--- 5 files changed, 468 insertions(+), 27 deletions(-) diff --git a/src/index.ts b/src/index.ts index aeca1d0..ec4dd9f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -20,6 +20,7 @@ import { import { BLOCKED_BUILTIN_TOOLS, PROPOSED_PLAN_MESSAGE_TYPE, + SAFE_BUILTIN_PLAN_TOOLS, STATE_ENTRY_TYPE, STATUS_KEY, WIDGET_KEY, @@ -34,6 +35,9 @@ export default function createExtension(pi: ExtensionAPI): void { let state: PlanModeState = createInitialState(); let previousTools: string[] | undefined; let pendingMenuTimer: ReturnType | undefined; + let planToSave: string | undefined; + let planWriteCallId: string | undefined; + let planSaveSucceeded = false; pi.registerFlag("plan", { description: "Start in plan mode (read-only exploration)", @@ -74,6 +78,20 @@ export default function createExtension(pi: ExtensionAPI): void { } } + function clearPlanSaveState(): void { + planToSave = undefined; + planWriteCallId = undefined; + planSaveSucceeded = false; + } + + function activatePlanSaveTools(): void { + const toolNames = [...SAFE_BUILTIN_PLAN_TOOLS]; + if (planWriteCallId === undefined && !planSaveSucceeded) { + toolNames.push("write"); + } + pi.setActiveTools(toolNames); + } + function doEnter(ctx: ExtensionContext): void { state = enterPlanMode(state); activatePlanModeTools(); @@ -83,6 +101,7 @@ export default function createExtension(pi: ExtensionAPI): void { function doExit(ctx: ExtensionContext): void { clearPendingMenu(); + clearPlanSaveState(); state = exitPlanMode(state); restoreTools(); persist(); @@ -148,6 +167,24 @@ export default function createExtension(pi: ExtensionAPI): void { ctx.ui.notify("Implementing plan. Full access restored.", "info"); break; } + case "save": + if (!state.latestPlan) { + ctx.ui.notify("No latest plan is available to save.", "warning"); + break; + } + if (!ctx.isIdle()) { + ctx.ui.notify("Save plan is unavailable while the agent is busy.", "warning"); + break; + } + planToSave = state.latestPlan; + planWriteCallId = undefined; + planSaveSucceeded = false; + activatePlanSaveTools(); + sendPlanModeMessage( + `Save the current proposed plan. choose a new lowercase .md file in an existing directory within ${ctx.cwd}. Write exactly the plan below to that file. Make no other changes.\n\n${planToSave}`, + ctx, + ); + break; case "exit": doExit(ctx); ctx.ui.notify("Plan mode disabled.", "info"); @@ -237,6 +274,14 @@ export default function createExtension(pi: ExtensionAPI): void { }); pi.on("before_agent_start", async (event, ctx) => { + if (state.enabled && planToSave !== undefined) { + activatePlanSaveTools(); + updateUi(ctx); + return { + systemPrompt: `${event.systemPrompt}\n\n${buildPlanModePrompt()}\n\n[PLAN SAVE TURN]\nUse only one approved write call for the exact captured plan. Do not edit, implement, or modify any other file.\n\n${planToSave}`, + }; + } + if (!state.enabled) { const plan = state.latestPlan; if (!plan) return; @@ -257,6 +302,7 @@ export default function createExtension(pi: ExtensionAPI): void { pi.on("agent_end", async (event, ctx) => { if (!state.enabled) return; + if (planToSave !== undefined) return; const messages = (event.messages as unknown as Array>) ?? []; const lastAssistant = [...messages].reverse().find((m) => m.role === "assistant"); if (!lastAssistant) return; @@ -269,13 +315,26 @@ export default function createExtension(pi: ExtensionAPI): void { clearPendingMenu(); pendingMenuTimer = setTimeout( () => - void showPlanReadyMenu(ctx) + void showPlanReadyMenu(ctx, state) .then((action) => handleMenuAction(action, ctx)) .catch(() => {}), 0, ); }); + pi.on("agent_settled", async (_event, ctx) => { + if (planToSave === undefined) return; + const didSave = planSaveSucceeded; + clearPlanSaveState(); + if (state.enabled) { + pi.setActiveTools(planModeToolNamesWithSelections(state.selectedToolNames)); + updateUi(ctx); + } + if (!didSave) { + ctx.ui.notify("Save plan failed or was not completed.", "warning"); + } + }); + pi.on("context", async (event) => { const messages = (event.messages as unknown as Array>) ?? []; const planMessageType = state.enabled ? undefined : PROPOSED_PLAN_MESSAGE_TYPE; @@ -310,6 +369,10 @@ export default function createExtension(pi: ExtensionAPI): void { pi.on("session_shutdown", async (_event, ctx) => { clearPendingMenu(); + if (planToSave !== undefined && state.enabled) { + pi.setActiveTools(planModeToolNamesWithSelections(state.selectedToolNames)); + } + clearPlanSaveState(); persist(); clearUi(ctx); }); diff --git a/src/tui/menus.ts b/src/tui/menus.ts index 4479375..03d5b41 100644 --- a/src/tui/menus.ts +++ b/src/tui/menus.ts @@ -1,10 +1,17 @@ import type { ExtensionContext } from "@earendil-works/pi-coding-agent"; import type { PlanModeState } from "../shared/types.ts"; -export type PlanMenuAction = "implement" | "stay" | "exit" | "show-plan" | "tools"; +export type PlanMenuAction = + | "implement" + | "save" + | "stay" + | "exit" + | "show-plan" + | "tools"; export const PLAN_MENU_LABELS: Record = { implement: "Implement this plan", + save: "Save plan", stay: "Stay in Plan mode", exit: "Exit Plan mode", "show-plan": "Show latest proposed plan", @@ -20,12 +27,21 @@ function resolveAction(choice: string | undefined): PlanMenuAction { return LABEL_TO_ACTION.get(choice) ?? "stay"; } -export async function showPlanReadyMenu(ctx: ExtensionContext): Promise { - const choice = await ctx.ui.select("Plan ready", [ - PLAN_MENU_LABELS.implement, - PLAN_MENU_LABELS.stay, - PLAN_MENU_LABELS.exit, - ]); +function shouldShowSave(ctx: ExtensionContext, state: Pick): boolean { + return Boolean(state.latestPlan) && ctx.isIdle(); +} + +export async function showPlanReadyMenu( + ctx: ExtensionContext, + state: Pick, +): Promise { + const options = [PLAN_MENU_LABELS.implement]; + if (shouldShowSave(ctx, state)) { + options.push(PLAN_MENU_LABELS.save); + } + options.push(PLAN_MENU_LABELS.stay, PLAN_MENU_LABELS.exit); + + const choice = await ctx.ui.select("Plan ready", options); return resolveAction(choice); } @@ -38,6 +54,9 @@ export async function showPlanMenu( if (state.latestPlan) { options.push(PLAN_MENU_LABELS["show-plan"]); options.push(PLAN_MENU_LABELS.implement); + if (shouldShowSave(ctx, state)) { + options.push(PLAN_MENU_LABELS.save); + } } options.push(PLAN_MENU_LABELS.tools); diff --git a/tests/helpers.ts b/tests/helpers.ts index 3929c62..72e6e18 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -128,6 +128,7 @@ export function createMockContext(options?: { entries?: SessionEntry[]; hasUI?: boolean; isIdle?: boolean; + cwd?: string; selectResponses?: string[]; customResult?: unknown; }): MockContext { @@ -186,6 +187,7 @@ export function createMockContext(options?: { }, hasUI: options?.hasUI ?? true, isIdle: () => options?.isIdle ?? true, + cwd: options?.cwd ?? process.cwd(), sessionManager: { getEntries: () => sessionEntries, }, diff --git a/tests/index.test.ts b/tests/index.test.ts index d8f50d0..974cca4 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -3,6 +3,7 @@ import { join } from "node:path"; import { tmpdir } from "node:os"; import { afterEach, describe, expect, it, vi } from "vitest"; import createExtension from "../src/index.ts"; +import { SAFE_BUILTIN_PLAN_TOOLS } from "../src/shared/constants.ts"; import { PLAN_MENU_LABELS } from "../src/tui/menus.ts"; import { createMockContext, createMockPi } from "./helpers.ts"; @@ -58,6 +59,55 @@ describe("/plan command", () => { expect(ctx.selectCalls).toHaveLength(1); }); + it("warns and does nothing for stale Save plan menu action without a latest plan", async () => { + const mock = createMockPi(); + createExtension(mock.pi); + const ctx = createMockContext({ selectResponses: [PLAN_MENU_LABELS.save] }); + + const handler = mock.commands.get("plan")!.handler; + await handler("", ctx.ctx); // on + await handler("", ctx.ctx); // menu + + expect(mock.userMessages).toHaveLength(0); + expect(ctx.statuses.get("pi-plan")).toBe("plan active"); + expect( + ctx.notifications.some( + (n) => n.type === "warning" && n.message.includes("latest plan"), + ), + ).toBe(true); + }); + + it("warns and does nothing for stale Save plan menu action while busy", async () => { + const mock = createMockPi(); + createExtension(mock.pi); + const ctx = createMockContext({ isIdle: false, selectResponses: [PLAN_MENU_LABELS.save] }); + + const handler = mock.commands.get("plan")!.handler; + await handler("", ctx.ctx); // on + + await mock.fireEvent( + "agent_end", + { + type: "agent_end", + messages: [ + { role: "assistant", content: "\n# Plan\n" }, + ], + }, + ctx, + ); + + await handler("", ctx.ctx); // stale save action + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(mock.userMessages).toHaveLength(0); + expect(ctx.statuses.get("pi-plan")).toBe("plan ready"); + expect( + ctx.notifications.some((n) => n.type === "warning" && n.message.includes("busy")), + ).toBe( + true, + ); + }); + it("treats any args as a prompt", async () => { const mock = createMockPi(); createExtension(mock.pi); @@ -1258,3 +1308,247 @@ describe("/plan:tools command", () => { expect(persistedState.selectedToolNames).toContain("my-tool"); }); }); + +describe("plan save lifecycle", () => { + it("sends the full captured plan and restricts tools for a save turn", async () => { + const mock = createMockPi({ + allTools: [ + { name: "my-search", description: "Search", sourceInfo: { source: "my-ext" } }, + ], + }); + createExtension(mock.pi); + const ctx = createMockContext({ + cwd: "/repo", + customResult: ["my-search"], + selectResponses: [PLAN_MENU_LABELS.save], + }); + + await mock.commands.get("plan")!.handler("", ctx.ctx); + await mock.commands.get("plan:tools")!.handler("", ctx.ctx); + await mock.fireEvent( + "agent_end", + { + type: "agent_end", + messages: [ + { + role: "assistant", + content: "\n# Ship It\n\nDetails\n", + }, + ], + }, + ctx, + ); + + await mock.commands.get("plan")!.handler("", ctx.ctx); + + expect(mock.userMessages).toHaveLength(1); + expect(mock.userMessages[0].content).toContain("# Ship It\n\nDetails"); + expect(mock.userMessages[0].content).toContain("choose a new lowercase .md file"); + expect(mock.userMessages[0].content).toContain("/repo"); + expect(mock.userMessages[0].options).toBeUndefined(); + expect(mock.activeTools).toEqual(expect.arrayContaining([...SAFE_BUILTIN_PLAN_TOOLS, "write"])); + expect(mock.activeTools).not.toContain("my-search"); + expect(ctx.statuses.get("pi-plan")).toBe("plan ready"); + }); + + it("keeps the captured plan for the save turn prompt and suppresses optional tools", async () => { + const mock = createMockPi({ + allTools: [ + { name: "my-search", description: "Search", sourceInfo: { source: "my-ext" } }, + ], + }); + createExtension(mock.pi); + const ctx = createMockContext({ + customResult: ["my-search"], + selectResponses: [PLAN_MENU_LABELS.save], + }); + + await mock.commands.get("plan")!.handler("", ctx.ctx); + await mock.commands.get("plan:tools")!.handler("", ctx.ctx); + await mock.fireEvent( + "agent_end", + { + type: "agent_end", + messages: [ + { + role: "assistant", + content: "\n# Saved Plan\n", + }, + ], + }, + ctx, + ); + await mock.commands.get("plan")!.handler("", ctx.ctx); + + const result = await mock.fireEvent( + "before_agent_start", + { type: "before_agent_start", systemPrompt: "base prompt" }, + ctx, + ); + + expect((result as { systemPrompt: string }).systemPrompt).toContain("[PLAN SAVE TURN]"); + expect((result as { systemPrompt: string }).systemPrompt).toContain("[PLAN MODE ACTIVE]"); + expect((result as { systemPrompt: string }).systemPrompt).toContain("# Saved Plan"); + expect(ctx.statuses.get("pi-plan")).toBe("plan ready"); + expect(mock.activeTools).toEqual(expect.arrayContaining([...SAFE_BUILTIN_PLAN_TOOLS, "write"])); + expect(mock.activeTools).not.toContain("my-search"); + }); + + it("ignores agent_end plan extraction while a save turn is active", async () => { + const mock = createMockPi(); + createExtension(mock.pi); + const ctx = createMockContext({ selectResponses: [PLAN_MENU_LABELS.save] }); + + await mock.commands.get("plan")!.handler("", ctx.ctx); + await mock.fireEvent( + "agent_end", + { + type: "agent_end", + messages: [ + { + role: "assistant", + content: "\n# Original Plan\n", + }, + ], + }, + ctx, + ); + await mock.commands.get("plan")!.handler("", ctx.ctx); + + await mock.fireEvent( + "agent_end", + { + type: "agent_end", + messages: [ + { + role: "assistant", + content: "\n# Replacement Plan\n", + }, + ], + }, + ctx, + ); + + const result = await mock.fireEvent( + "before_agent_start", + { type: "before_agent_start", systemPrompt: "base prompt" }, + ctx, + ); + + expect((result as { systemPrompt: string }).systemPrompt).toContain("# Original Plan"); + expect((result as { systemPrompt: string }).systemPrompt).not.toContain("# Replacement Plan"); + expect(ctx.selectCalls).toHaveLength(1); + }); + + it("warns and restores ordinary plan tools when the save turn settles without success", async () => { + const mock = createMockPi({ + allTools: [ + { name: "my-search", description: "Search", sourceInfo: { source: "my-ext" } }, + ], + }); + createExtension(mock.pi); + const ctx = createMockContext({ + customResult: ["my-search"], + selectResponses: [PLAN_MENU_LABELS.save], + }); + + await mock.commands.get("plan")!.handler("", ctx.ctx); + await mock.commands.get("plan:tools")!.handler("", ctx.ctx); + await mock.fireEvent( + "agent_end", + { + type: "agent_end", + messages: [ + { + role: "assistant", + content: "\n# Save Me\n", + }, + ], + }, + ctx, + ); + await mock.commands.get("plan")!.handler("", ctx.ctx); + + await mock.fireEvent("agent_settled", { type: "agent_settled" }, ctx); + + expect(mock.activeTools).toContain("my-search"); + expect(mock.activeTools).not.toContain("write"); + expect(ctx.statuses.get("pi-plan")).toBe("plan ready"); + expect( + ctx.notifications.some((n) => n.type === "warning" && n.message.includes("Save plan failed")), + ).toBe(true); + const persistedEntries = mock.entries.filter((entry) => entry.customType === "plan-mode-state"); + expect(persistedEntries.at(-1)?.data).toMatchObject({ + latestPlan: "# Save Me", + awaitingAction: true, + }); + }); + + it("restores full previous tools and clears save-turn state on exit", async () => { + const mock = createMockPi({ + activeTools: ["read", "bash", "edit", "write", "custom-before"], + allTools: [ + { name: "my-search", description: "Search", sourceInfo: { source: "my-ext" } }, + ], + }); + createExtension(mock.pi); + const ctx = createMockContext({ + customResult: ["my-search"], + selectResponses: [PLAN_MENU_LABELS.save], + }); + + await mock.commands.get("plan")!.handler("", ctx.ctx); + await mock.commands.get("plan:tools")!.handler("", ctx.ctx); + await mock.fireEvent( + "agent_end", + { + type: "agent_end", + messages: [{ role: "assistant", content: "\n# Save Me\n" }], + }, + ctx, + ); + await mock.commands.get("plan")!.handler("", ctx.ctx); + + await mock.commands.get("plan:exit")!.handler("", ctx.ctx); + + expect(mock.activeTools).toEqual(expect.arrayContaining(["edit", "write", "custom-before"])); + const result = await mock.fireEvent( + "before_agent_start", + { type: "before_agent_start", systemPrompt: "base prompt" }, + ctx, + ); + expect((result as { systemPrompt: string }).systemPrompt).toContain("[PLAN HANDOFF]"); + expect((result as { systemPrompt: string }).systemPrompt).not.toContain("[PLAN SAVE TURN]"); + }); + + it("restores ordinary plan tools and clears UI on session shutdown during save turn", async () => { + const mock = createMockPi({ + allTools: [ + { name: "my-search", description: "Search", sourceInfo: { source: "my-ext" } }, + ], + }); + createExtension(mock.pi); + const ctx = createMockContext({ + customResult: ["my-search"], + selectResponses: [PLAN_MENU_LABELS.save], + }); + + await mock.commands.get("plan")!.handler("", ctx.ctx); + await mock.commands.get("plan:tools")!.handler("", ctx.ctx); + await mock.fireEvent( + "agent_end", + { + type: "agent_end", + messages: [{ role: "assistant", content: "\n# Save Me\n" }], + }, + ctx, + ); + await mock.commands.get("plan")!.handler("", ctx.ctx); + + await mock.fireEvent("session_shutdown", { type: "session_shutdown", reason: "quit" }, ctx); + + expect(mock.activeTools).toEqual(expect.arrayContaining([...SAFE_BUILTIN_PLAN_TOOLS, "my-search"])); + expect(mock.activeTools).not.toContain("write"); + expect(ctx.statuses.get("pi-plan")).toBeUndefined(); + }); +}); diff --git a/tests/tui/menus.test.ts b/tests/tui/menus.test.ts index d270ec9..b7514a8 100644 --- a/tests/tui/menus.test.ts +++ b/tests/tui/menus.test.ts @@ -4,59 +4,122 @@ import { PLAN_MENU_LABELS, showPlanMenu, showPlanReadyMenu } from "../../src/tui import { createMockContext } from "../helpers.ts"; describe("showPlanReadyMenu", () => { + it("includes Save plan label", () => { + expect(PLAN_MENU_LABELS.save).toBe("Save plan"); + }); + it("returns implement when user selects implement label", async () => { const ctx = createMockContext({ selectResponses: [PLAN_MENU_LABELS.implement] }); - const action = await showPlanReadyMenu(ctx.ctx); + const action = await showPlanReadyMenu(ctx.ctx, { + ...createInitialState(), + latestPlan: "# Plan", + }); expect(action).toBe("implement"); }); it("returns stay when user selects stay label", async () => { const ctx = createMockContext({ selectResponses: [PLAN_MENU_LABELS.stay] }); - const action = await showPlanReadyMenu(ctx.ctx); + const action = await showPlanReadyMenu(ctx.ctx, { + ...createInitialState(), + latestPlan: "# Plan", + }); expect(action).toBe("stay"); }); it("returns exit when user selects exit label", async () => { const ctx = createMockContext({ selectResponses: [PLAN_MENU_LABELS.exit] }); - const action = await showPlanReadyMenu(ctx.ctx); + const action = await showPlanReadyMenu(ctx.ctx, { + ...createInitialState(), + latestPlan: "# Plan", + }); expect(action).toBe("exit"); }); it("defaults to stay when selection is cancelled (undefined)", async () => { const ctx = createMockContext({ selectResponses: [] }); - const action = await showPlanReadyMenu(ctx.ctx); + const action = await showPlanReadyMenu(ctx.ctx, { + ...createInitialState(), + latestPlan: "# Plan", + }); expect(action).toBe("stay"); }); - it("calls ctx.ui.select with three options", async () => { + it("shows Implement, Save plan, Stay, Exit in order when plan exists and idle", async () => { const ctx = createMockContext({ selectResponses: [PLAN_MENU_LABELS.stay] }); - await showPlanReadyMenu(ctx.ctx); + await showPlanReadyMenu(ctx.ctx, { + ...createInitialState(), + latestPlan: "# Plan", + }); expect(ctx.selectCalls).toHaveLength(1); - expect(ctx.selectCalls[0].options).toHaveLength(3); + expect(ctx.selectCalls[0].options).toEqual([ + PLAN_MENU_LABELS.implement, + PLAN_MENU_LABELS.save, + PLAN_MENU_LABELS.stay, + PLAN_MENU_LABELS.exit, + ]); + }); + + it("hides Save plan when no plan exists", async () => { + const ctx = createMockContext({ selectResponses: [PLAN_MENU_LABELS.stay] }); + await showPlanReadyMenu(ctx.ctx, createInitialState()); + expect(ctx.selectCalls[0].options).toEqual([ + PLAN_MENU_LABELS.implement, + PLAN_MENU_LABELS.stay, + PLAN_MENU_LABELS.exit, + ]); + }); + + it("hides Save plan when context is busy", async () => { + const ctx = createMockContext({ isIdle: false, selectResponses: [PLAN_MENU_LABELS.stay] }); + await showPlanReadyMenu(ctx.ctx, { + ...createInitialState(), + latestPlan: "# Plan", + }); + expect(ctx.selectCalls[0].options).toEqual([ + PLAN_MENU_LABELS.implement, + PLAN_MENU_LABELS.stay, + PLAN_MENU_LABELS.exit, + ]); }); }); describe("showPlanMenu", () => { - it("includes show-plan and implement options when plan exists", async () => { + it("includes show-plan, implement, and Save plan options in order when plan exists and idle", async () => { const ctx = createMockContext({ selectResponses: [PLAN_MENU_LABELS.stay] }); const state = { ...createInitialState(), enabled: true, latestPlan: "# My Plan" }; await showPlanMenu(ctx.ctx, state); - const options = ctx.selectCalls[0].options; - expect(options).toContain(PLAN_MENU_LABELS["show-plan"]); - expect(options).toContain(PLAN_MENU_LABELS.implement); - expect(options).toContain(PLAN_MENU_LABELS.stay); - expect(options).toContain(PLAN_MENU_LABELS.exit); + expect(ctx.selectCalls[0].options).toEqual([ + PLAN_MENU_LABELS["show-plan"], + PLAN_MENU_LABELS.implement, + PLAN_MENU_LABELS.save, + PLAN_MENU_LABELS.tools, + PLAN_MENU_LABELS.stay, + PLAN_MENU_LABELS.exit, + ]); }); - it("excludes show-plan and implement options when no plan exists", async () => { + it("excludes show-plan, implement, and Save plan options when no plan exists", async () => { const ctx = createMockContext({ selectResponses: [PLAN_MENU_LABELS.stay] }); const state = { ...createInitialState(), enabled: true }; await showPlanMenu(ctx.ctx, state); - const options = ctx.selectCalls[0].options; - expect(options).not.toContain(PLAN_MENU_LABELS["show-plan"]); - expect(options).not.toContain(PLAN_MENU_LABELS.implement); - expect(options).toContain(PLAN_MENU_LABELS.stay); - expect(options).toContain(PLAN_MENU_LABELS.exit); + expect(ctx.selectCalls[0].options).toEqual([ + PLAN_MENU_LABELS.tools, + PLAN_MENU_LABELS.stay, + PLAN_MENU_LABELS.exit, + ]); + }); + + it("hides Save plan when plan exists but context is busy", async () => { + const ctx = createMockContext({ isIdle: false, selectResponses: [PLAN_MENU_LABELS.stay] }); + const state = { ...createInitialState(), enabled: true, latestPlan: "# My Plan" }; + await showPlanMenu(ctx.ctx, state); + expect(ctx.selectCalls[0].options).toEqual([ + PLAN_MENU_LABELS["show-plan"], + PLAN_MENU_LABELS.implement, + PLAN_MENU_LABELS.tools, + PLAN_MENU_LABELS.stay, + PLAN_MENU_LABELS.exit, + ]); }); it("returns selected action", async () => { From a4f706faad702c435c0b3d5ced96a4d876ae3abf Mon Sep 17 00:00:00 2001 From: Lanh Hoang Date: Sat, 25 Jul 2026 01:03:54 -0400 Subject: [PATCH 06/10] feat: authorize safe plan saves --- CHANGELOG.md | 3 + README.md | 15 +- src/index.ts | 93 ++++++++- tests/index.test.ts | 449 +++++++++++++++++++++++++++++++++++++++++++- 4 files changed, 550 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index deac0db..b8b3699 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,8 +4,11 @@ All notable changes to `@pi-vault/pi-plan` are documented in this file. ## Unreleased +Pi 0.82.0 or newer is now required. + ### Changed +- Add an idle-only **Save plan** action that keeps Plan mode active, asks the agent to choose a new lowercase `.md` filename inside the workspace, and grants built-in `write` for that save turn only. - Preserve the latest proposed plan as context for only the first normal-mode turn after exit. - Send the full proposed plan directly when choosing **Implement this plan**. diff --git a/README.md b/README.md index 552726d..3ae922b 100644 --- a/README.md +++ b/README.md @@ -70,17 +70,20 @@ A typical flow looks like this: 1. Enter plan mode with `/plan` or `pi --plan`. 2. Let the agent inspect the repo and ask clarifying questions. 3. Receive exactly one `` block when the plan is ready. -4. Choose whether to implement it, stay in plan mode, or exit. +4. While the agent is idle, choose whether to implement it, save it to a new Markdown file, stay in plan mode, or exit. If you choose **Implement this plan**, pi-plan turns plan mode off first, restores full tool access, and immediately sends the full proposed plan back into the conversation as the implementation instruction. +If you choose **Save plan**, pi-plan keeps plan mode active, keeps the current plan, and asks the agent to choose a new lowercase `.md` filename in an existing directory inside the current workspace. For that save turn only, Pi temporarily allows built-in `write` for the exact captured plan, permits a retry if the write execution fails, and disables `write` again after a successful save. + ## What’s New In Current Behavior The current release behavior includes a few workflow improvements beyond the original 0.2.0 command surface: - Optional plan-mode tool selections persist across Pi sessions. - Choosing **Implement this plan** directly sends the full proposed plan as the next instruction. -- After a normal exit, the latest proposed plan is available only to the next normal-mode turn, then it is consumed. +- While idle, choosing **Save plan** keeps plan mode active and lets the agent write the exact current plan to one new lowercase `.md` file inside the workspace. +- After a normal exit, the latest proposed plan is available only to the first normal-mode turn, then it is consumed. - Once plan mode is off, `` blocks are stripped from normal assistant context so later turns do not carry stale planning markup forward. ## Command Reference @@ -102,10 +105,8 @@ The current release behavior includes a few workflow improvements beyond the ori - Turns off plan mode. - Restores the tool set that was active before planning started. -- Preserves the latest plan for the next normal-mode prompt only, then consumes it. To save it: - 1. Select **Exit** or run `/plan:exit`. - 2. On the next prompt, ask: `Write the latest proposed plan verbatim to docs/my-plan.md. Do not implement it.` -- `/plan:exit` does not accept handoff text: `/plan:exit write the plan to proposed-plan.md` exits but does not send the write request. Re-entering plan mode before the next normal prompt discards the pending plan. +- Preserves the latest plan for the first normal-mode prompt only, then consumes it. +- If you want a file copy while staying in plan mode, use **Save plan** from the menu while the agent is idle. ### `pi --plan` @@ -122,6 +123,8 @@ Plan mode keeps the default workflow read-only: Optional extension tools are off by default. You can enable them with `/plan:tools`, and those selections persist across sessions. Built-in `edit` and `write` remain blocked even when extra tools are enabled, but non-built-in tools may still expose broader capabilities through their own interfaces, so treat them as deliberate opt-ins. +During a **Save plan** turn, pi-plan narrows active tools back to the safe built-ins and temporarily adds built-in `write` only for the exact captured plan. If a write execution fails, the agent can retry during the same save turn; after a successful save, `write` stays disabled again. Save-path preflight rejects existing targets, broken symlinks, path traversal, and any path that resolves outside the current workspace. This preflight is a guardrail, not an atomic no-clobber guarantee against a concurrent filesystem race. + ## Development And Verification ```bash diff --git a/src/index.ts b/src/index.ts index ec4dd9f..05b03ab 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,6 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; +import { lstatSync, realpathSync } from "node:fs"; +import { dirname, relative, resolve, sep } from "node:path"; import { extractProposedPlan, filterPlanModeMessages, @@ -92,6 +94,10 @@ export default function createExtension(pi: ExtensionAPI): void { pi.setActiveTools(toolNames); } + function blockPlanSave(reason: string): { block: true; reason: string } { + return { block: true, reason }; + } + function doEnter(ctx: ExtensionContext): void { state = enterPlanMode(state); activatePlanModeTools(); @@ -251,9 +257,77 @@ export default function createExtension(pi: ExtensionAPI): void { }, }); - pi.on("tool_call", async (event) => { + pi.on("tool_call", async (event, ctx) => { if (!state.enabled) return; + if (event.toolName === "write") { + if (planToSave === undefined || planWriteCallId !== undefined || planSaveSucceeded) { + return blockPlanSave("Plan mode blocks 'write'. Exit plan mode first with /plan:exit."); + } + + const input = + typeof event.input === "object" && event.input !== null + ? (event.input as Record) + : undefined; + if (input === undefined) { + return blockPlanSave("Save plan requires an input object with path and content."); + } + + const content = input.content; + if (typeof content !== "string" || content !== planToSave) { + return blockPlanSave("Save plan requires the exact captured plan content."); + } + + const filePath = typeof input.path === "string" ? input.path : undefined; + if (filePath === undefined) { + return blockPlanSave("Save plan requires a string file path."); + } + + if ( + filePath.startsWith("/") || + filePath.startsWith("~") || + filePath.startsWith("@") || + filePath.startsWith("file:") || + /[\u00A0\u1680\u2000-\u200A\u202F\u205F\u3000]/u.test(filePath) || + filePath.split("/").includes("..") + ) { + return blockPlanSave("Save plan path must be a normal relative workspace path."); + } + + if (!/^[a-z0-9_./-]+\.md$/.test(filePath)) { + return blockPlanSave("Save plan path must be a lowercase .md file."); + } + + try { + const workspaceRoot = realpathSync(ctx.cwd); + const targetPath = resolve(workspaceRoot, filePath); + const parentPath = dirname(targetPath); + const parentRealPath = realpathSync(parentPath); + const parentStats = lstatSync(parentRealPath, { throwIfNoEntry: false }); + if (!parentStats?.isDirectory()) { + return blockPlanSave("Save plan parent directory must already exist."); + } + + const parentRelative = relative(workspaceRoot, parentRealPath); + if (parentRelative === ".." || parentRelative.startsWith(`..${sep}`)) { + return blockPlanSave("Save plan path must stay inside the workspace."); + } + + const targetStats = lstatSync(targetPath, { throwIfNoEntry: false }); + // ponytail: preflight existence check is not atomic; use an exclusive-create save tool if concurrent no-clobber becomes required. + if (targetStats !== undefined) { + return blockPlanSave("Save plan target must not already exist."); + } + } catch { + return blockPlanSave("Save plan path must be an existing directory inside the workspace."); + } + + Object.freeze(input); + planWriteCallId = event.toolCallId; + activatePlanSaveTools(); + return; + } + if (BLOCKED_BUILTIN_TOOLS.has(event.toolName)) { return { block: true, @@ -273,6 +347,23 @@ export default function createExtension(pi: ExtensionAPI): void { } }); + pi.on("tool_execution_end", async (event) => { + if (event.toolName !== "write" || event.toolCallId !== planWriteCallId) return; + + planWriteCallId = undefined; + if (event.isError === false) { + planSaveSucceeded = true; + if (state.enabled && planToSave !== undefined) { + activatePlanSaveTools(); + } + return; + } + + if (state.enabled && planToSave !== undefined) { + activatePlanSaveTools(); + } + }); + pi.on("before_agent_start", async (event, ctx) => { if (state.enabled && planToSave !== undefined) { activatePlanSaveTools(); diff --git a/tests/index.test.ts b/tests/index.test.ts index 974cca4..96490dd 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -1,4 +1,12 @@ -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { afterEach, describe, expect, it, vi } from "vitest"; @@ -23,12 +31,13 @@ describe("createExtension", () => { expect(mock.commands.has("plan:tools")).toBe(true); }); - it("registers session_start, session_shutdown, tool_call, and before_agent_start handlers", () => { + it("registers session_start, session_shutdown, tool_call, tool_execution_end, and before_agent_start handlers", () => { const mock = createMockPi(); createExtension(mock.pi); expect(mock.events.has("session_start")).toBe(true); expect(mock.events.has("session_shutdown")).toBe(true); expect(mock.events.has("tool_call")).toBe(true); + expect(mock.events.has("tool_execution_end")).toBe(true); expect(mock.events.has("before_agent_start")).toBe(true); }); }); @@ -255,7 +264,7 @@ describe("tool_call blocking", () => { expect(result).toEqual(expect.objectContaining({ block: true })); }); - it("blocks write tool in plan mode", async () => { + it("blocks ordinary write tool in plan mode outside a save turn", async () => { const mock = createMockPi(); createExtension(mock.pi); const ctx = createMockContext(); @@ -324,6 +333,440 @@ describe("tool_call blocking", () => { }); }); +describe("write save preflight", () => { + function setupSavePlan(plan = "# Saved Plan\n") { + const workspace = mkdtempSync(join(tmpdir(), "pi-plan-write-")); + mkdirSync(join(workspace, "docs"), { recursive: true }); + const capturedPlan = plan.trim(); + + const mock = createMockPi(); + createExtension(mock.pi); + const ctx = createMockContext({ cwd: workspace, selectResponses: [PLAN_MENU_LABELS.save] }); + + const startSave = async () => { + await mock.commands.get("plan")!.handler("", ctx.ctx); + await mock.fireEvent( + "agent_end", + { + type: "agent_end", + messages: [{ role: "assistant", content: `\n${plan}` }], + }, + ctx, + ); + await mock.commands.get("plan")!.handler("", ctx.ctx); + }; + + tempDirs.push(workspace); + return { mock, ctx, workspace, plan: capturedPlan, startSave }; + } + + const tempDirs: string[] = []; + + afterEach(() => { + for (const path of tempDirs.splice(0)) { + rmSync(path, { recursive: true, force: true }); + } + }); + + it("allows one exact write for the captured content and sibling write paths only", async () => { + const test = setupSavePlan("# Saved Plan\n"); + await test.startSave(); + + const result = await test.mock.fireEvent( + "tool_call", + { + type: "tool_call", + toolCallId: "write-1", + toolName: "write", + input: { path: "docs/plan.md", content: test.plan }, + }, + test.ctx, + ); + + expect(result).toBeUndefined(); + expect(test.mock.activeTools).toEqual(expect.arrayContaining([...SAFE_BUILTIN_PLAN_TOOLS])); + expect(test.mock.activeTools).not.toContain("write"); + + await test.mock.fireEvent( + "tool_execution_end", + { + type: "tool_execution_end", + toolCallId: "write-1", + toolName: "write", + result: { ok: true }, + isError: false, + }, + test.ctx, + ); + + expect(test.mock.activeTools).not.toContain("write"); + }); + + it("blocks write content changes unless the captured content is exact", async () => { + const test = setupSavePlan("# Saved Plan\n"); + await test.startSave(); + + const result = await test.mock.fireEvent( + "tool_call", + { + type: "tool_call", + toolCallId: "write-content", + toolName: "write", + input: { path: "docs/plan.md", content: "# Saved Plan\nextra\n" }, + }, + test.ctx, + ); + + expect(result).toEqual(expect.objectContaining({ block: true })); + expect(test.mock.activeTools).toContain("write"); + }); + + it("blocks null or undefined write input instead of throwing", async () => { + const test = setupSavePlan(); + await test.startSave(); + + for (const [toolCallId, input] of [ + ["write-null-input", null], + ["write-undefined-input", undefined], + ] as const) { + const result = await test.mock.fireEvent( + "tool_call", + { + type: "tool_call", + toolCallId, + toolName: "write", + input, + }, + test.ctx, + ); + + expect(result).toEqual(expect.objectContaining({ block: true })); + } + }); + + it("blocks write path validation failures for non-md, missing parent, absolute, traversal, home, @, file, and Unicode-space paths", async () => { + const test = setupSavePlan(); + await test.startSave(); + + const badPaths = [ + "docs/plan.txt", + "missing/plan.md", + join(test.workspace, "docs/plan.md"), + "../plan.md", + "~/plan.md", + "@docs/plan.md", + "file:///tmp/plan.md", + "docs/plan\u00A0.md", + ]; + + for (const [index, filePath] of badPaths.entries()) { + const result = await test.mock.fireEvent( + "tool_call", + { + type: "tool_call", + toolCallId: `write-path-${index}`, + toolName: "write", + input: { path: filePath, content: test.plan }, + }, + test.ctx, + ); + + expect(result).toEqual(expect.objectContaining({ block: true })); + expect(test.mock.activeTools).toContain("write"); + } + }); + + it("blocks write targets when the target exists or is a broken target symlink", async () => { + const existing = setupSavePlan(); + writeFileSync(join(existing.workspace, "docs", "plan.md"), "old"); + await existing.startSave(); + + const existingResult = await existing.mock.fireEvent( + "tool_call", + { + type: "tool_call", + toolCallId: "write-existing", + toolName: "write", + input: { path: "docs/plan.md", content: existing.plan }, + }, + existing.ctx, + ); + + expect(existingResult).toEqual(expect.objectContaining({ block: true })); + + const fileParent = setupSavePlan(); + writeFileSync(join(fileParent.workspace, "docs", "taken"), "nope"); + await fileParent.startSave(); + + const fileParentResult = await fileParent.mock.fireEvent( + "tool_call", + { + type: "tool_call", + toolCallId: "write-file-parent", + toolName: "write", + input: { path: "docs/taken/plan.md", content: fileParent.plan }, + }, + fileParent.ctx, + ); + + expect(fileParentResult).toEqual(expect.objectContaining({ block: true })); + + const broken = setupSavePlan(); + symlinkSync(join(broken.workspace, "missing.md"), join(broken.workspace, "docs", "plan.md")); + await broken.startSave(); + + const brokenResult = await broken.mock.fireEvent( + "tool_call", + { + type: "tool_call", + toolCallId: "write-symlink-target", + toolName: "write", + input: { path: "docs/plan.md", content: broken.plan }, + }, + broken.ctx, + ); + + expect(brokenResult).toEqual(expect.objectContaining({ block: true })); + }); + + it("blocks write when a symlinked parent resolves outside the workspace", async () => { + const test = setupSavePlan(); + const outside = mkdtempSync(join(tmpdir(), "pi-plan-outside-")); + tempDirs.push(outside); + rmSync(join(test.workspace, "docs"), { recursive: true, force: true }); + symlinkSync(outside, join(test.workspace, "docs")); + await test.startSave(); + + const result = await test.mock.fireEvent( + "tool_call", + { + type: "tool_call", + toolCallId: "write-symlink-parent", + toolName: "write", + input: { path: "docs/plan.md", content: test.plan }, + }, + test.ctx, + ); + + expect(result).toEqual(expect.objectContaining({ block: true })); + }); + + it("freezes write input before later handlers can mutate it", async () => { + const test = setupSavePlan(); + await test.startSave(); + + let frozen = false; + let contentAfter = ""; + test.mock.pi.on("tool_call", async (event) => { + const input = (event as { input: { content: string } }).input; + frozen = Object.isFrozen(input); + try { + input.content = "changed"; + } catch { + // expected in strict mode + } + contentAfter = input.content; + }); + + await test.mock.fireEvent( + "tool_call", + { + type: "tool_call", + toolCallId: "write-frozen", + toolName: "write", + input: { path: "docs/plan.md", content: test.plan }, + }, + test.ctx, + ); + + expect(frozen).toBe(true); + expect(contentAfter).toBe(test.plan); + }); + + it("re-enables write after a later handler blocks and tool_execution_end arrives", async () => { + const test = setupSavePlan(); + await test.startSave(); + + test.mock.pi.on("tool_call", async () => ({ block: true, reason: "later block" })); + + const result = await test.mock.fireEvent( + "tool_call", + { + type: "tool_call", + toolCallId: "write-blocked-later", + toolName: "write", + input: { path: "docs/plan.md", content: test.plan }, + }, + test.ctx, + ); + + expect(result).toEqual(expect.objectContaining({ block: true })); + expect(test.mock.activeTools).not.toContain("write"); + + await test.mock.fireEvent( + "tool_execution_end", + { + type: "tool_execution_end", + toolCallId: "write-blocked-later", + toolName: "write", + result: undefined, + isError: true, + }, + test.ctx, + ); + + expect(test.mock.activeTools).toContain("write"); + }); + + it("blocks a reserved sibling second write until execution_end releases the reservation", async () => { + const test = setupSavePlan(); + await test.startSave(); + + const first = await test.mock.fireEvent( + "tool_call", + { + type: "tool_call", + toolCallId: "write-reserved-1", + toolName: "write", + input: { path: "docs/first.md", content: test.plan }, + }, + test.ctx, + ); + + expect(first).toBeUndefined(); + + const second = await test.mock.fireEvent( + "tool_call", + { + type: "tool_call", + toolCallId: "write-reserved-2", + toolName: "write", + input: { path: "docs/second.md", content: test.plan }, + }, + test.ctx, + ); + + expect(second).toEqual(expect.objectContaining({ block: true })); + + await test.mock.fireEvent( + "tool_execution_end", + { + type: "tool_execution_end", + toolCallId: "write-reserved-1", + toolName: "write", + result: { error: "blocked later" }, + isError: true, + }, + test.ctx, + ); + + expect(test.mock.activeTools).toContain("write"); + }); + + it("allows write retry after tool_execution_end reports an error", async () => { + const test = setupSavePlan(); + await test.startSave(); + + const first = await test.mock.fireEvent( + "tool_call", + { + type: "tool_call", + toolCallId: "write-retry-1", + toolName: "write", + input: { path: "docs/plan.md", content: test.plan }, + }, + test.ctx, + ); + + expect(first).toBeUndefined(); + + await test.mock.fireEvent( + "tool_execution_end", + { + type: "tool_execution_end", + toolCallId: "write-retry-1", + toolName: "write", + result: { message: "disk full" }, + isError: true, + }, + test.ctx, + ); + + expect(test.mock.activeTools).toContain("write"); + + const second = await test.mock.fireEvent( + "tool_call", + { + type: "tool_call", + toolCallId: "write-retry-2", + toolName: "write", + input: { path: "docs/retry.md", content: test.plan }, + }, + test.ctx, + ); + + expect(second).toBeUndefined(); + }); + + it("keeps write disabled after successful tool_execution_end and finishes without the failure notification", async () => { + const test = setupSavePlan(); + await test.startSave(); + + const result = await test.mock.fireEvent( + "tool_call", + { + type: "tool_call", + toolCallId: "write-success", + toolName: "write", + input: { path: "docs/plan.md", content: test.plan }, + }, + test.ctx, + ); + + expect(result).toBeUndefined(); + + await test.mock.fireEvent( + "tool_execution_end", + { + type: "tool_execution_end", + toolCallId: "write-success", + toolName: "write", + result: { ok: true }, + isError: false, + }, + test.ctx, + ); + + expect(test.mock.activeTools).not.toContain("write"); + + await test.mock.fireEvent("agent_settled", { type: "agent_settled" }, test.ctx); + + expect(test.mock.activeTools).not.toContain("write"); + expect( + test.ctx.notifications.some((n) => n.type === "warning" && n.message.includes("Save plan failed")), + ).toBe(false); + expect(test.ctx.statuses.get("pi-plan")).toBe("plan ready"); + }); + + it("allows saving to a root workspace markdown file", async () => { + const test = setupSavePlan(); + await test.startSave(); + + const result = await test.mock.fireEvent( + "tool_call", + { + type: "tool_call", + toolCallId: "write-root", + toolName: "write", + input: { path: "plan.md", content: test.plan }, + }, + test.ctx, + ); + + expect(result).toBeUndefined(); + }); +}); + describe("session persistence", () => { it("persists state on enter and exit", async () => { const mock = createMockPi(); From a93e769d69662f175fe664fb205b052ae5a89121 Mon Sep 17 00:00:00 2001 From: Lanh Hoang Date: Sat, 25 Jul 2026 01:19:21 -0400 Subject: [PATCH 07/10] refactor: update save-turn prompt to hide plan text and persist captured state via tool context instead --- src/index.ts | 2 +- tests/index.test.ts | 8 +++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/index.ts b/src/index.ts index 05b03ab..a6a9647 100644 --- a/src/index.ts +++ b/src/index.ts @@ -369,7 +369,7 @@ export default function createExtension(pi: ExtensionAPI): void { activatePlanSaveTools(); updateUi(ctx); return { - systemPrompt: `${event.systemPrompt}\n\n${buildPlanModePrompt()}\n\n[PLAN SAVE TURN]\nUse only one approved write call for the exact captured plan. Do not edit, implement, or modify any other file.\n\n${planToSave}`, + systemPrompt: `${event.systemPrompt}\n\n${buildPlanModePrompt()}\n\n[PLAN SAVE TURN]\nUse only one approved write call for the exact captured plan. Do not edit, implement, or modify any other file.`, }; } diff --git a/tests/index.test.ts b/tests/index.test.ts index 96490dd..e271530 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -1794,7 +1794,7 @@ describe("plan save lifecycle", () => { expect(ctx.statuses.get("pi-plan")).toBe("plan ready"); }); - it("keeps the captured plan for the save turn prompt and suppresses optional tools", async () => { + it("uses the narrow save-turn prompt and suppresses optional tools", async () => { const mock = createMockPi({ allTools: [ { name: "my-search", description: "Search", sourceInfo: { source: "my-ext" } }, @@ -1831,7 +1831,7 @@ describe("plan save lifecycle", () => { expect((result as { systemPrompt: string }).systemPrompt).toContain("[PLAN SAVE TURN]"); expect((result as { systemPrompt: string }).systemPrompt).toContain("[PLAN MODE ACTIVE]"); - expect((result as { systemPrompt: string }).systemPrompt).toContain("# Saved Plan"); + expect((result as { systemPrompt: string }).systemPrompt).not.toContain("# Saved Plan"); expect(ctx.statuses.get("pi-plan")).toBe("plan ready"); expect(mock.activeTools).toEqual(expect.arrayContaining([...SAFE_BUILTIN_PLAN_TOOLS, "write"])); expect(mock.activeTools).not.toContain("my-search"); @@ -1878,8 +1878,10 @@ describe("plan save lifecycle", () => { ctx, ); - expect((result as { systemPrompt: string }).systemPrompt).toContain("# Original Plan"); + expect((result as { systemPrompt: string }).systemPrompt).not.toContain("# Original Plan"); expect((result as { systemPrompt: string }).systemPrompt).not.toContain("# Replacement Plan"); + const persistedEntries = mock.entries.filter((entry) => entry.customType === "plan-mode-state"); + expect(persistedEntries.at(-1)?.data).toMatchObject({ latestPlan: "# Original Plan" }); expect(ctx.selectCalls).toHaveLength(1); }); From 36b5da84c78ae92a233369a2b125e6efa1e05e13 Mon Sep 17 00:00:00 2001 From: Lanh Hoang Date: Sat, 25 Jul 2026 14:05:51 -0400 Subject: [PATCH 08/10] refactor: trim save menu complexity --- src/index.ts | 17 ++++------------- src/tui/menus.ts | 8 ++------ tests/tui/menus.test.ts | 30 ++++++------------------------ 3 files changed, 12 insertions(+), 43 deletions(-) diff --git a/src/index.ts b/src/index.ts index a6a9647..b67a0b9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -182,9 +182,8 @@ export default function createExtension(pi: ExtensionAPI): void { ctx.ui.notify("Save plan is unavailable while the agent is busy.", "warning"); break; } + clearPlanSaveState(); planToSave = state.latestPlan; - planWriteCallId = undefined; - planSaveSucceeded = false; activatePlanSaveTools(); sendPlanModeMessage( `Save the current proposed plan. choose a new lowercase .md file in an existing directory within ${ctx.cwd}. Write exactly the plan below to that file. Make no other changes.\n\n${planToSave}`, @@ -351,23 +350,16 @@ export default function createExtension(pi: ExtensionAPI): void { if (event.toolName !== "write" || event.toolCallId !== planWriteCallId) return; planWriteCallId = undefined; - if (event.isError === false) { - planSaveSucceeded = true; - if (state.enabled && planToSave !== undefined) { - activatePlanSaveTools(); - } - return; - } - - if (state.enabled && planToSave !== undefined) { + if (event.isError) { activatePlanSaveTools(); + } else { + planSaveSucceeded = true; } }); pi.on("before_agent_start", async (event, ctx) => { if (state.enabled && planToSave !== undefined) { activatePlanSaveTools(); - updateUi(ctx); return { systemPrompt: `${event.systemPrompt}\n\n${buildPlanModePrompt()}\n\n[PLAN SAVE TURN]\nUse only one approved write call for the exact captured plan. Do not edit, implement, or modify any other file.`, }; @@ -419,7 +411,6 @@ export default function createExtension(pi: ExtensionAPI): void { clearPlanSaveState(); if (state.enabled) { pi.setActiveTools(planModeToolNamesWithSelections(state.selectedToolNames)); - updateUi(ctx); } if (!didSave) { ctx.ui.notify("Save plan failed or was not completed.", "warning"); diff --git a/src/tui/menus.ts b/src/tui/menus.ts index 03d5b41..5cc1531 100644 --- a/src/tui/menus.ts +++ b/src/tui/menus.ts @@ -27,16 +27,12 @@ function resolveAction(choice: string | undefined): PlanMenuAction { return LABEL_TO_ACTION.get(choice) ?? "stay"; } -function shouldShowSave(ctx: ExtensionContext, state: Pick): boolean { - return Boolean(state.latestPlan) && ctx.isIdle(); -} - export async function showPlanReadyMenu( ctx: ExtensionContext, state: Pick, ): Promise { const options = [PLAN_MENU_LABELS.implement]; - if (shouldShowSave(ctx, state)) { + if (state.latestPlan && ctx.isIdle()) { options.push(PLAN_MENU_LABELS.save); } options.push(PLAN_MENU_LABELS.stay, PLAN_MENU_LABELS.exit); @@ -54,7 +50,7 @@ export async function showPlanMenu( if (state.latestPlan) { options.push(PLAN_MENU_LABELS["show-plan"]); options.push(PLAN_MENU_LABELS.implement); - if (shouldShowSave(ctx, state)) { + if (ctx.isIdle()) { options.push(PLAN_MENU_LABELS.save); } } diff --git a/tests/tui/menus.test.ts b/tests/tui/menus.test.ts index b7514a8..7063a57 100644 --- a/tests/tui/menus.test.ts +++ b/tests/tui/menus.test.ts @@ -10,46 +10,31 @@ describe("showPlanReadyMenu", () => { it("returns implement when user selects implement label", async () => { const ctx = createMockContext({ selectResponses: [PLAN_MENU_LABELS.implement] }); - const action = await showPlanReadyMenu(ctx.ctx, { - ...createInitialState(), - latestPlan: "# Plan", - }); + const action = await showPlanReadyMenu(ctx.ctx, { latestPlan: "# Plan" }); expect(action).toBe("implement"); }); it("returns stay when user selects stay label", async () => { const ctx = createMockContext({ selectResponses: [PLAN_MENU_LABELS.stay] }); - const action = await showPlanReadyMenu(ctx.ctx, { - ...createInitialState(), - latestPlan: "# Plan", - }); + const action = await showPlanReadyMenu(ctx.ctx, { latestPlan: "# Plan" }); expect(action).toBe("stay"); }); it("returns exit when user selects exit label", async () => { const ctx = createMockContext({ selectResponses: [PLAN_MENU_LABELS.exit] }); - const action = await showPlanReadyMenu(ctx.ctx, { - ...createInitialState(), - latestPlan: "# Plan", - }); + const action = await showPlanReadyMenu(ctx.ctx, { latestPlan: "# Plan" }); expect(action).toBe("exit"); }); it("defaults to stay when selection is cancelled (undefined)", async () => { const ctx = createMockContext({ selectResponses: [] }); - const action = await showPlanReadyMenu(ctx.ctx, { - ...createInitialState(), - latestPlan: "# Plan", - }); + const action = await showPlanReadyMenu(ctx.ctx, { latestPlan: "# Plan" }); expect(action).toBe("stay"); }); it("shows Implement, Save plan, Stay, Exit in order when plan exists and idle", async () => { const ctx = createMockContext({ selectResponses: [PLAN_MENU_LABELS.stay] }); - await showPlanReadyMenu(ctx.ctx, { - ...createInitialState(), - latestPlan: "# Plan", - }); + await showPlanReadyMenu(ctx.ctx, { latestPlan: "# Plan" }); expect(ctx.selectCalls).toHaveLength(1); expect(ctx.selectCalls[0].options).toEqual([ PLAN_MENU_LABELS.implement, @@ -71,10 +56,7 @@ describe("showPlanReadyMenu", () => { it("hides Save plan when context is busy", async () => { const ctx = createMockContext({ isIdle: false, selectResponses: [PLAN_MENU_LABELS.stay] }); - await showPlanReadyMenu(ctx.ctx, { - ...createInitialState(), - latestPlan: "# Plan", - }); + await showPlanReadyMenu(ctx.ctx, { latestPlan: "# Plan" }); expect(ctx.selectCalls[0].options).toEqual([ PLAN_MENU_LABELS.implement, PLAN_MENU_LABELS.stay, From 21af147e68aa915871a72673225a68112c9bae16 Mon Sep 17 00:00:00 2001 From: Lanh Hoang Date: Sat, 25 Jul 2026 16:45:09 -0400 Subject: [PATCH 09/10] docs: add implementation plan for clarifying save-plan instructions --- ...26-07-25-clarify-save-plan-instructions.md | 143 ++++++++++++++++++ 1 file changed, 143 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-25-clarify-save-plan-instructions.md diff --git a/docs/superpowers/plans/2026-07-25-clarify-save-plan-instructions.md b/docs/superpowers/plans/2026-07-25-clarify-save-plan-instructions.md new file mode 100644 index 0000000..1999802 --- /dev/null +++ b/docs/superpowers/plans/2026-07-25-clarify-save-plan-instructions.md @@ -0,0 +1,143 @@ +# Clarify Save-Plan Instructions Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make the save-plan prompt request a new dated Markdown filename at the workspace root and preserve the captured plan byte-for-byte. + +**Architecture:** Keep the existing agent-mediated save turn and all current path/content preflight rules. Change only the user message sent by `handleMenuAction()` and the tests that define that prompt contract; Pi 0.82.0 may create parent directories, but `pi-plan` intentionally rejects missing parents, so the prompt will use the existing workspace root and avoid requiring directory creation. + +**Tech Stack:** TypeScript, Pi extension API, Node.js filesystem/path validation, Vitest, pnpm. + +--- + +## File Map + +- Modify `tests/index.test.ts`: assert the complete save-turn prompt and use a dated root filename in the successful preflight case. +- Modify `src/index.ts`: update only the save message in `handleMenuAction()`. +- Do not modify validation, authorization, save state, documentation, or any other worktree files. + +### Task 1: Define the dated root-filename contract in tests + +**Files:** + +- Modify: `tests/index.test.ts:1756-1800` (save lifecycle prompt test) +- Modify: `tests/index.test.ts:371-405` (successful write preflight test) + +- [ ] **Step 1: Replace the loose prompt assertions with an exact message assertion.** + +In `"sends the full captured plan and restricts tools for a save turn"`, keep the existing setup and lifecycle assertions, then replace the three current prompt-content assertions with: + +```ts +const saveMessage = mock.userMessages[0].content; +expect(saveMessage).toBe( + "Save the current proposed plan. Choose a new lowercase .md filename in the workspace root /repo. Prefix the filename with today's date followed by a hyphen (YYYY-MM-DD-); use date +%F if needed. Pass only the filename as a relative workspace path; do not use an absolute path or a subdirectory. Write exactly the plan below to that file. Do not add leading or trailing whitespace, including a trailing newline. Make no other changes.\n\n# Ship It\n\nDetails", +); +``` + +Leave these assertions unchanged: + +```ts +expect(mock.userMessages).toHaveLength(1); +expect(mock.userMessages[0].options).toBeUndefined(); +expect(mock.activeTools).toEqual( + expect.arrayContaining([...SAFE_BUILTIN_PLAN_TOOLS, "write"]), +); +expect(mock.activeTools).not.toContain("my-search"); +expect(ctx.statuses.get("pi-plan")).toBe("plan ready"); +``` + +- [ ] **Step 2: Change the successful preflight destination to a dated workspace-root filename.** + +In `"allows one exact write for the captured content and sibling write paths only"`, change the valid input from: + +```ts +input: { path: "docs/plan.md", content: test.plan }, +``` + +to: + +```ts +input: { path: "2026-07-25-plan.md", content: test.plan }, +``` + +This verifies that the existing lowercase `.md` validation accepts the dated root filename without creating a directory. Keep the other preflight fixtures, including the missing-parent rejection, unchanged. + +- [ ] **Step 3: Run the focused tests and verify the prompt test fails before implementation.** + +Run: + +```bash +pnpm exec vitest run tests/index.test.ts -t "sends the full captured plan|allows one exact write" +``` + +Expected: the dated root-filename preflight test passes, while the prompt test fails because the current message still says `choose a new lowercase .md file` and does not contain the new path/date/whitespace instructions. + +### Task 2: Update the save-turn prompt and verify the complete change + +**Files:** + +- Modify: `src/index.ts:188-192` (`handleMenuAction()` save branch) +- Test: `tests/index.test.ts` from Task 1 + +- [ ] **Step 1: Replace only the save message template.** + +In the `case "save"` branch, replace the existing `sendPlanModeMessage()` content with: + +```ts +sendPlanModeMessage( + `Save the current proposed plan. Choose a new lowercase .md filename in the workspace root ${ctx.cwd}. Prefix the filename with today's date followed by a hyphen (YYYY-MM-DD-); use date +%F if needed. Pass only the filename as a relative workspace path; do not use an absolute path or a subdirectory. Write exactly the plan below to that file. Do not add leading or trailing whitespace, including a trailing newline. Make no other changes.\n\n${planToSave}`, + ctx, +); +``` + +Do not alter `clearPlanSaveState()`, `activatePlanSaveTools()`, path preflight, exact-content comparison, tool authorization, or save lifecycle state. + +- [ ] **Step 2: Rerun the focused regression.** + +Run: + +```bash +pnpm exec vitest run tests/index.test.ts -t "sends the full captured plan|allows one exact write" +``` + +Expected: both selected tests pass. + +- [ ] **Step 3: Run the full project check.** + +Run: + +```bash +pnpm check +``` + +Expected: Biome, TypeScript, and all Vitest tests pass. The repository may emit its existing Node engine warning because the current environment is Node 23.11 while `package.json` declares Node `>=24.15.0`. + +- [ ] **Step 4: Review the diff and confirm scope.** + +Run: + +```bash +git diff -- src/index.ts tests/index.test.ts +git status --short +``` + +Expected: only the save prompt and the two planned test edits are present; unrelated worktree files remain unstaged and untouched. + +- [ ] **Step 5: Commit only the implementation files.** + +After confirming the scope, run: + +```bash +git add src/index.ts tests/index.test.ts +git commit -m "fix: clarify plan save constraints" +``` + +Do not stage or commit this plan file or unrelated worktree changes unless explicitly requested. + +## Self-Review + +- Pi 0.82.0's built-in `write` creates parent directories recursively, but `pi-plan` preflight still requires the parent to exist; using a root-relative filename avoids that boundary. +- The date prefix is an instruction only, not a new validation rule. Existing lowercase `.md` validation accepts `2026-07-25-plan.md`. +- The prompt preserves internal plan whitespace and forbids only added leading/trailing whitespace, including a trailing newline. +- No public API, interface, type, persistence, authorization, or documentation changes are required. +- No placeholders, incomplete sections, or unassigned requirements remain. From bd57a6b332ab6bdb45f2d7d45876fc00621a4d67 Mon Sep 17 00:00:00 2001 From: Lanh Hoang Date: Sat, 25 Jul 2026 16:47:31 -0400 Subject: [PATCH 10/10] fix: clarify plan save constraints --- src/index.ts | 2 +- tests/index.test.ts | 9 +++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/index.ts b/src/index.ts index b67a0b9..7c8298e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -186,7 +186,7 @@ export default function createExtension(pi: ExtensionAPI): void { planToSave = state.latestPlan; activatePlanSaveTools(); sendPlanModeMessage( - `Save the current proposed plan. choose a new lowercase .md file in an existing directory within ${ctx.cwd}. Write exactly the plan below to that file. Make no other changes.\n\n${planToSave}`, + `Save the current proposed plan. Choose a new lowercase .md filename in the workspace root ${ctx.cwd}. Prefix the filename with today's date followed by a hyphen (YYYY-MM-DD-); use date +%F if needed. Pass only the filename as a relative workspace path; do not use an absolute path or a subdirectory. Write exactly the plan below to that file. Do not add leading or trailing whitespace, including a trailing newline. Make no other changes.\n\n${planToSave}`, ctx, ); break; diff --git a/tests/index.test.ts b/tests/index.test.ts index e271530..94db9ec 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -378,7 +378,7 @@ describe("write save preflight", () => { type: "tool_call", toolCallId: "write-1", toolName: "write", - input: { path: "docs/plan.md", content: test.plan }, + input: { path: "2026-07-25-plan.md", content: test.plan }, }, test.ctx, ); @@ -1785,9 +1785,10 @@ describe("plan save lifecycle", () => { await mock.commands.get("plan")!.handler("", ctx.ctx); expect(mock.userMessages).toHaveLength(1); - expect(mock.userMessages[0].content).toContain("# Ship It\n\nDetails"); - expect(mock.userMessages[0].content).toContain("choose a new lowercase .md file"); - expect(mock.userMessages[0].content).toContain("/repo"); + const saveMessage = mock.userMessages[0].content; + expect(saveMessage).toBe( + "Save the current proposed plan. Choose a new lowercase .md filename in the workspace root /repo. Prefix the filename with today's date followed by a hyphen (YYYY-MM-DD-); use date +%F if needed. Pass only the filename as a relative workspace path; do not use an absolute path or a subdirectory. Write exactly the plan below to that file. Do not add leading or trailing whitespace, including a trailing newline. Make no other changes.\n\n# Ship It\n\nDetails", + ); expect(mock.userMessages[0].options).toBeUndefined(); expect(mock.activeTools).toEqual(expect.arrayContaining([...SAFE_BUILTIN_PLAN_TOOLS, "write"])); expect(mock.activeTools).not.toContain("my-search");