From 7cc79b2844729a49b662366a73c672d800e17766 Mon Sep 17 00:00:00 2001 From: Carolina <26188349+carolitascl@users.noreply.github.com> Date: Fri, 18 Sep 2026 16:44:27 -0300 Subject: [PATCH 1/6] feat(history): tombstones, deletion, and privacy semantics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slice 5/6 of the PR #819 split (maintainer-requested review slices). - store: scope-delete section — sweepFiles (atomic rewrite per affected file, emptied session files kept so live writers stay functional, never fatal), deleteFromProject, deleteFromGlobal - selector-helpers: deletionActionsFor (pure provenance->actions planner) and loadedCountAfterDelete (backfill window math), completing the helper surface - index.ts: deleteCurrent on the selector exactly as upstream — provenance- planned sweep + tombstone ALWAYS written (seed/transcript-sourced entries cannot resurface; the seed is write-once) + splice + backfill + failure toast; ctrl+shift+backspace dispatch entry and footer affordance restored (dispatch table back to 12 entries) - privacy semantics (enforced by tests): hidden.json fail-open read, tombstone precedes any visibility change, deletion from the global seed and project stores is permanent because the seed is written once - tests: 17 new/updated node:test cases (cumulative 165/165): sweep with a concurrent live writer, emptied-file-kept, chmod-000 partial failure toast path, tombstone-always planner pin, unknown-prompt no-op, backfill bounds, dispatch/wheel table pins restored to 12 Gates: cumulative scoped history tests 165/165 green. Known pre-existing environmental gate failures unchanged. --- extensions/history/index.ts | 63 ++++++++- extensions/history/selector-helpers.ts | 40 ++++++ extensions/history/store.ts | 85 ++++++++++- tests/history-delete-backfill.test.ts | 186 +++++++++++++++++++++++++ tests/history-dispatch.test.ts | 11 +- tests/history-scope-delete.test.ts | 163 ++++++++++++++++++++++ tests/history-wheel-mouse.test.ts | 6 +- 7 files changed, 541 insertions(+), 13 deletions(-) create mode 100644 tests/history-delete-backfill.test.ts create mode 100644 tests/history-scope-delete.test.ts diff --git a/extensions/history/index.ts b/extensions/history/index.ts index b62963d39..424008b68 100644 --- a/extensions/history/index.ts +++ b/extensions/history/index.ts @@ -4,8 +4,8 @@ // Prompt-history extension entry (slice 3): the selector TUI, overlay glue, // and the shortcut/command wiring over the slice-1 writer, slice-2 drains, // and slice-4 init sequence (legacy migration + seed bootstrap run once -// inside getWriter). Deletion (slice 5) and GC/compaction (slice 6) arrive -// in later slices. +// inside getWriter). Deletion (slice 5) is wired here; GC/compaction +// (slice 6) arrives in a later slice. import { join } from "node:path"; import { homedir } from "node:os"; @@ -18,6 +18,8 @@ import { import { appendSessionCapture, bootstrapProjectSeed, + deleteFromGlobal, + deleteFromProject, drainGlobal, drainProject, ensureRegistryEntry, @@ -26,15 +28,18 @@ import { type SessionWriterState, } from "./store.ts"; import { randomUUID } from "node:crypto"; +import { hidePrompt } from "./hide-prompts.ts"; import { buildPromptRecords, filterPrompts, type PromptEntry, clampPreviewOffset, clampSelectedIndex, + deletionActionsFor, dedupePromptEntries, getVisiblePromptRecords, initialLoadedCount, + loadedCountAfterDelete, loadedCountForQuery, loadedCountForTarget, moveSelectedIndex, @@ -292,6 +297,10 @@ class PromptHistorySelector extends Container implements Focusable { match: (d, _kb) => matchesKey(d, "end"), handler: () => this.jumpToLast(), }, + { + match: (d, _kb) => matchesKey(d, "ctrl+shift+backspace"), + handler: () => this.deleteCurrent(), + }, { match: (d, _kb) => matchesKey(d, "ctrl+shift+up"), handler: () => this.previewPageUp(), @@ -364,7 +373,7 @@ class PromptHistorySelector extends Container implements Focusable { new FixedRowText( theme.fg( "dim", - "↑↓ move • PgUp/PgDn page • tab scope • enter select and quit • ctrl+shift+↑/↓ preview • esc cancel", + "↑↓ move • PgUp/PgDn page • tab scope • enter select and quit • ctrl+shift+↑/↓ preview • ctrl+shift+backspace delete • esc cancel", ), true /* centered */, ), @@ -547,6 +556,54 @@ class PromptHistorySelector extends Container implements Focusable { this.applyFilter(this.searchInput.getValue()); } + /** Delete the currently selected prompt from disk and refresh the list. */ + private deleteCurrent(): void { + const selected = this.filteredRecords[this.selectedIndex]; + if (!selected) return; + + // C4 delete flows (design §F): the record's provenance decides the + // actions via the pure planner; module constants are used directly. + const actions = deletionActionsFor(selected.source ?? "editor"); + + if (actions.deleteFromEditorStore) { + // Store path: physically remove EVERY copy from the JSONL store + // (memory + file in one atomic rewrite). + const { removed } = + this.scope === "global" + ? deleteFromGlobal(PI_HISTORY_ROOT, selected.text) + : deleteFromProject(PI_HISTORY_ROOT, CURRENT_CWD, selected.text); + if (removed === 0) return; + } + + // Tombstone ALWAYS: the session transcripts are immutable and would + // re-supply the deleted prompt on the next merge (hide-file suppresses + // the twin). Only the session path aborts on a hide error — the store + // row is already gone on the editor path, so the splice proceeds. + const hide = hidePrompt(PI_HISTORY_NAV_STATE_DIR, selected.text); + if (hide.status === "error") { + this.onNotify?.(hide.message, "error"); + if (!actions.deleteFromEditorStore) { + return; + } + } + // Remove from the master records array so a subsequent filter doesn't + // bring it back. + const idx = this.records.indexOf(selected); + if (idx !== -1) { + this.records.splice(idx, 1); + // C4 delete backfill (design §B3): shrink the window with the splice, + // then pull the next unloaded row while any remain — genuine shrink + // only at exhaustion. + this.loadedCount = loadedCountAfterDelete( + this.loadedCount, + this.records.length, + ); + } + + // Re-apply current filter (rebuilds filteredRecords, list, preview). + this.applyFilter(this.searchInput.getValue()); + } + // -- Navigation --------------------------------------------------------- private moveUp(): void { diff --git a/extensions/history/selector-helpers.ts b/extensions/history/selector-helpers.ts index fd02400ce..292c05907 100644 --- a/extensions/history/selector-helpers.ts +++ b/extensions/history/selector-helpers.ts @@ -215,6 +215,46 @@ export function loadedCountForTarget( return next; } +/** + * Delete backfill (spec C4's two steps verbatim, AC-L4-1..3): decrement the + * window against the splice-shrunk snapshot; while unloaded rows remain, + * backfill one row (clamped) so the next unloaded record slides into the + * deleted slot and the visible list length stays stable; at exhaustion the + * decrement is the genuine shrink. Written stepwise — NOT the algebraic + * min(L, T') shortcut — so the unit tests pin the contract, not an + * equivalence. Callers guarantee the deleted row sits inside the loaded + * prefix (idx < loadedCount by construction). + */ +export function loadedCountAfterDelete( + loadedCount: number, + totalCountAfterSplice: number, +): number { + const decrement = loadedCount - 1; + if (decrement < totalCountAfterSplice) { + return Math.min(decrement + 1, totalCountAfterSplice); + } + return decrement; +} + +/** + * Pure delete-flow planner (spec C4, design §F): maps a record's provenance + * to the two delete actions. "editor" deletes from the editor store on disk + * AND writes the tombstone (twin suppression — the session copy of the same + * text would otherwise resurface next open); "session" writes the tombstone + * only (session transcripts are NEVER written). Takes source as a plain + * parameter (no member reads — the T23 provenance pin keeps overlay + * consumers source-agnostic outside deleteCurrent); the only consumer is + * deleteCurrent in history/index.ts. + */ +export function deletionActionsFor( + source: PromptSource, +): { deleteFromEditorStore: boolean; writeTombstone: boolean } { + if (source === "editor") { + return { deleteFromEditorStore: true, writeTombstone: true }; + } + return { deleteFromEditorStore: false, writeTombstone: true }; +} + export function getVisiblePromptRecords( records: PromptRecord[], selectedIndex: number, diff --git a/extensions/history/store.ts b/extensions/history/store.ts index 32fb48295..04626f48e 100644 --- a/extensions/history/store.ts +++ b/extensions/history/store.ts @@ -193,8 +193,8 @@ export function parseStoreLine(raw: string): StoreEntry | null { } // =========================================================================== -// Instance writer (formerly multi-store.ts; scope deletes and GC arrive -// in later slices) +// Instance writer (formerly multi-store.ts; GC/compaction arrives in a +// later slice) // =========================================================================== /** Mutable state of ONE pi instance's exclusive capture file. */ @@ -411,6 +411,87 @@ export function drainGlobal( ); } +// --------------------------------------------------------------------------- +// Scope delete (design v2) +// --------------------------------------------------------------------------- + +interface SweepResult { + filesAffected: number; + removed: number; +} + +/** + * Remove every line whose prompt identity matches `text` from each file in + * `files`, one atomic rewrite (tmp + rename) per affected file. Files whose + * every line matched are kept as empty files (never removed — the instance + * owning a session file may still append to it). + */ +function sweepFiles(files: string[], text: string): SweepResult { + const key = promptKey(text); + let filesAffected = 0; + let removed = 0; + for (const file of files) { + let raw = ""; + try { + raw = fs.readFileSync(file, "utf8"); + } catch { + continue; + } + const kept: string[] = []; + let fileRemoved = 0; + for (const lineText of raw.split("\n")) { + const parsed = parseStoreLine(lineText); + if (!parsed) continue; + if (promptKey(parsed.text) === key) { + fileRemoved += 1; + } else { + kept.push(JSON.stringify(parsed)); + } + } + if (fileRemoved === 0) continue; + const tmp = `${file}.tmp-${process.pid}-${Date.now()}`; + fs.writeFileSync(tmp, kept.length > 0 ? kept.join("\n") + "\n" : "", "utf8"); + fs.renameSync(tmp, file); + filesAffected += 1; + removed += fileRemoved; + } + return { filesAffected, removed }; +} + +/** Delete every copy of a prompt from the CURRENT project's scope. */ +export function deleteFromProject( + root: string, + cwd: string, + text: string, +): SweepResult { + return sweepFiles( + listProjectFiles(path.join(root, "projects", projectHash(cwd))), + text, + ); +} + +/** Delete every copy of a prompt from the GLOBAL scope (all projects + seed). */ +export function deleteFromGlobal(root: string, text: string): SweepResult { + const files: string[] = []; + const globalSeed = globalSeedPath(root); + if (fs.existsSync(globalSeed)) files.push(globalSeed); + let projectDirs: fs.Dirent[]; + try { + projectDirs = fs.readdirSync(path.join(root, "projects"), { + withFileTypes: true, + }); + } catch { + projectDirs = []; + } + for (const dirEntry of projectDirs) { + if (!dirEntry.isDirectory()) continue; + files.push( + ...listProjectFiles(path.join(root, "projects", dirEntry.name)), + ); + } + return sweepFiles(files, text); +} + // --------------------------------------------------------------------------- // Legacy migration (design v2: one-time, gated) // --------------------------------------------------------------------------- diff --git a/tests/history-delete-backfill.test.ts b/tests/history-delete-backfill.test.ts new file mode 100644 index 000000000..e5fcd3bd8 --- /dev/null +++ b/tests/history-delete-backfill.test.ts @@ -0,0 +1,186 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import { + deletionActionsFor, + loadedCountAfterDelete, +} from "../extensions/history/selector-helpers.ts"; + +// Unit 3 — L4 delete backfill (spec C4, design §B3). +// +// C4's contract as a verbatim two-step: a successful delete splices the +// master snapshot AND shrinks the loaded window together; while unloaded +// rows remain, the window backfills one row (clamped) so the next unloaded +// record slides into the deleted slot and the visible list length stays +// stable; at exhaustion (loadedCount == records.length after the decrement) +// there is NO backfill — the visible set genuinely shrinks by one row, by +// design. +// +// The deleted record always comes from filteredRecords ⊆ the loaded prefix, +// so idx < loadedCount by construction (§B3). Change 1's delete contract +// (delete-prompt.ts, error-only notify) is UNTOUCHED — AC-L4-4's regression +// pin is test/history/delete-prompt.test.ts itself, green and unmodified. + +// T11 — AC-L4-1 + AC-L4-2: mid-window delete with unloaded rows remaining — +// the count is preserved by pulling the next record: (30, 99) decrements to +// 29, 29 < 99, so backfill min(29 + 1, 99) = 30 (stable window). + +test("loadedCountAfterDelete backfills while unloaded rows remain — stable window (AC-L4-1, AC-L4-2)", () => { + assert.equal(loadedCountAfterDelete(30, 99), 30); +}); + +// T11 — AC-L4-3: exhaustion shrink — the window was fully loaded (100 of 100, +// 99 after the splice), so the decrement is the genuine shrink, no backfill: +// 99 < 99 is false → 99. + +test("loadedCountAfterDelete shrinks genuinely at exhaustion (AC-L4-3)", () => { + assert.equal(loadedCountAfterDelete(100, 99), 99); +}); + +// T11 — AC-L4-3 terminal case: deleting the last loaded row on an exhausted +// window bottoms out at 0: (1, 0) decrements to 0, 0 < 0 is false → 0. + +test("loadedCountAfterDelete bottoms out at 0 on the terminal delete (AC-L4-3)", () => { + assert.equal(loadedCountAfterDelete(1, 0), 0); +}); + +// T11 — defensive degenerate row: an empty window stays 0 even when counts +// disagree: (0, 5) decrements to −1, −1 < 5, so min(−1 + 1, 5) = 0. +// Unreachable via deleteCurrent (a delete implies a selected row inside the +// loaded prefix) — pinned as C4's defensive bound. + +test("loadedCountAfterDelete is defensive for an empty window (AC-L4-1)", () => { + assert.equal(loadedCountAfterDelete(0, 5), 0); +}); + +// T11 — AC-L4-1 + AC-L4-4 (source-parse): ordering shape inside deleteCurrent +// — the bookkeeping call sits strictly between the existing splice and the +// trailing applyFilter, INSIDE the existing `if (idx !== -1)` guarded block, +// and the non-`deleted` early return still precedes every mutation +// (Change 1 C1 interplay unchanged). + +const selectorSource = fs.readFileSync( + path.join(process.cwd(), "extensions", "history", "index.ts"), + "utf8", +); + +test("deleteCurrent splices, backfills, then re-filters — inside the guarded block (AC-L4-1, AC-L4-4)", () => { + const decl = selectorSource.indexOf("private deleteCurrent("); + assert.ok(decl >= 0, "deleteCurrent should exist"); + const end = selectorSource.indexOf("\n }", decl); + assert.ok(end > decl, "deleteCurrent's body should close"); + const body = selectorSource.slice(decl, end); + + const earlyReturnAt = body.indexOf("if (removed === 0) return;"); + const spliceAt = body.indexOf("this.records.splice("); + const backfillAt = body.indexOf("loadedCountAfterDelete("); + const refilterAt = body.lastIndexOf("this.applyFilter("); + assert.ok(earlyReturnAt >= 0, "the Change 1 early return must stay"); + assert.ok(spliceAt >= 0, "the existing splice must stay"); + assert.ok( + backfillAt >= 0, + "the loadedCountAfterDelete bookkeeping call must exist", + ); + assert.ok(refilterAt >= 0, "the trailing applyFilter must stay"); + assert.ok( + earlyReturnAt < spliceAt && + spliceAt < backfillAt && + backfillAt < refilterAt, + "ordering must be: early return → splice → backfill → re-filter", + ); + + // Inside the guarded block: no 4-space block closer may appear between the + // `if (idx !== -1)` guard and the bookkeeping call (the block's own close + // sits only AFTER the call). + const guardAt = body.indexOf("if (idx !== -1)"); + assert.ok(guardAt >= 0, "the `if (idx !== -1)` guard must stay"); + const guardToCall = body.slice(guardAt, backfillAt); + assert.ok( + !guardToCall.includes("\n }"), + "the bookkeeping must sit inside the `if (idx !== -1)` block", + ); + + // The call assigns this.loadedCount from the unfiltered counts only. + assert.ok( + body.includes("this.loadedCount = loadedCountAfterDelete("), + "the call must assign this.loadedCount", + ); + const callRegion = body.slice(backfillAt, refilterAt); + assert.ok( + callRegion.includes("this.loadedCount") && + callRegion.includes("this.records.length"), + "the bookkeeping must read the unfiltered window and the shrunk snapshot", + ); +}); + +// Slice 5 scenario pins (porting contract): the tombstone-always rule and +// the partial-failure toast path. The dev suite pins the planner + these +// deleteCurrent branch shapes in hide-prompts.test.ts (T27/T28); this file +// carries the delete-flow source-parse half so the slice-5 branch stays +// pinned inside the delete slice's own tests. + +test("deletionActionsFor always plans a tombstone — session provenance deletes nothing from disk", () => { + // Session/seed-born records: tombstone ONLY (transcripts and the seed are + // never rewritten by a delete) — the tombstone is what keeps the deleted + // prompt from resurfacing on the next drain. + assert.deepEqual(deletionActionsFor("session"), { + deleteFromEditorStore: false, + writeTombstone: true, + }); + // Editor records: disk delete AND tombstone (twin suppression). + assert.deepEqual(deletionActionsFor("editor"), { + deleteFromEditorStore: true, + writeTombstone: true, + }); + + const decl = selectorSource.indexOf("private deleteCurrent("); + assert.ok(decl >= 0, "deleteCurrent should exist"); + const end = selectorSource.indexOf("\n }", decl); + assert.ok(end > decl, "deleteCurrent's body should close"); + const body = selectorSource.slice(decl, end); + + // Branch shape: the tombstone write sits OUTSIDE the editor-store guard — + // every provenance lands a tombstone, so an entry that came from the + // seed or a transcript cannot resurface after its delete. + const editorGuardAt = body.indexOf("if (actions.deleteFromEditorStore)"); + assert.ok(editorGuardAt >= 0, "the editor-store guard must exist"); + const guardCloseAt = body.indexOf("\n }", editorGuardAt); + assert.ok(guardCloseAt > editorGuardAt, "the editor-store guard must close"); + const hideAt = body.indexOf("hidePrompt("); + assert.ok(hideAt >= 0, "the tombstone write must exist"); + assert.ok( + hideAt > guardCloseAt, + "the tombstone must follow (not sit inside) the editor-store guard", + ); +}); + +test("a failed hide toasts and only the session path aborts — the editor path still splices", () => { + const decl = selectorSource.indexOf("private deleteCurrent("); + assert.ok(decl >= 0, "deleteCurrent should exist"); + const end = selectorSource.indexOf("\n }", decl); + assert.ok(end > decl, "deleteCurrent's body should close"); + const body = selectorSource.slice(decl, end); + + const gateAt = body.indexOf('if (hide.status === "error")'); + assert.ok(gateAt >= 0, "hide errors must be gated"); + const spliceAt = body.indexOf("this.records.splice("); + assert.ok( + gateAt < spliceAt, + "the hide-error gate must precede the splice", + ); + const gate = body.slice(gateAt, spliceAt); + assert.ok( + gate.includes('this.onNotify?.(hide.message, "error")'), + "a hide error must toast", + ); + const abortGuardAt = gate.indexOf("if (!actions.deleteFromEditorStore)"); + assert.ok( + abortGuardAt >= 0, + "the early return must be exclusive to the session path", + ); + assert.ok( + !gate.slice(0, abortGuardAt).includes("return;"), + "no unconditional abort before the editor/session split — the editor path splices", + ); +}); diff --git a/tests/history-dispatch.test.ts b/tests/history-dispatch.test.ts index 575a4d5a1..cdda32505 100644 --- a/tests/history-dispatch.test.ts +++ b/tests/history-dispatch.test.ts @@ -10,8 +10,7 @@ import { fileURLToPath } from "node:url"; * PromptHistorySelector is private to extensions/history/index.ts and needs * the pi-tui runtime (Container, Input, TUI, Theme), so these tests read the * source file and pin the normative §B2 shape instead of importing it: - * exactly 11 explicit entries in a fixed order (the ctrl+shift+backspace - * delete entry joins with deletion in slice 5), then the implicit + * exactly 12 explicit entries in a fixed order, then the implicit * forwardToSearch fallthrough inside handleInput. */ @@ -34,6 +33,7 @@ const EXPECTED_MATCHERS = [ 'kb.matches(_d, "tui.select.cancel")', 'matchesKey(d, "home")', 'matchesKey(d, "end")', + 'matchesKey(d, "ctrl+shift+backspace")', 'matchesKey(d, "ctrl+shift+up")', 'matchesKey(d, "ctrl+shift+down")', ]; @@ -49,6 +49,7 @@ const EXPECTED_HANDLERS = [ "this.onCancel()", "this.jumpToFirst()", "this.jumpToLast()", + "this.deleteCurrent()", "this.previewPageUp()", "this.previewPageDown()", ]; @@ -84,13 +85,13 @@ function methodBody(name: string): string { } describe("dispatch table (source-parsed, §B2)", () => { - it("has exactly 11 explicit match: entries (AC-P2-4.1)", () => { + it("has exactly 12 explicit match: entries (AC-P2-4.1)", () => { const table = dispatchTable(); const matchCount = table.split("match:").length - 1; assert.strictEqual( matchCount, - 11, - `expected 11 explicit entries, found ${matchCount}`, + 12, + `expected 12 explicit entries, found ${matchCount}`, ); }); diff --git a/tests/history-scope-delete.test.ts b/tests/history-scope-delete.test.ts new file mode 100644 index 000000000..11c50f2da --- /dev/null +++ b/tests/history-scope-delete.test.ts @@ -0,0 +1,163 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { + appendSessionCapture, + deleteFromGlobal, + deleteFromProject, + globalSeedPath, + openSessionWriter, + projectHash, +} from "../extensions/history/store.ts"; + +// Scope delete (design v2): sweepFiles' atomic per-file rewrite semantics +// plus the project/global delete entry points. Synthetic project cwds — +// never real directories on any machine (identity only feeds projectHash; +// the fixtures live in tmpdirs and never touch the user's real ~/.pi). +const PROJECT_A = "/fixtures/pi-history/project-a"; +const PROJECT_B = "/fixtures/pi-history/project-b"; + +function makeRoot(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), "pi-history-del-")); +} + +function writeLines(file: string, texts: string[]): void { + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync( + file, + `${texts.map((t) => JSON.stringify({ v: 1, text: t })).join("\n")}\n`, + "utf8", + ); +} + +function fileTexts(file: string): string[] { + return fs + .readFileSync(file, "utf8") + .trim() + .split("\n") + .filter((l) => l.length > 0) + .map((l) => (JSON.parse(l) as { text: string }).text); +} + +test("project delete removes every copy across the project's files", () => { + const root = makeRoot(); + const dir = path.join(root, "projects", projectHash(PROJECT_A)); + writeLines(path.join(dir, "s1.jsonl"), ["keep", "victim"]); + writeLines(path.join(dir, "s2.jsonl"), ["VICTIM ", "also-keep"]); + const result = deleteFromProject(root, PROJECT_A, "victim"); + assert.deepEqual(result, { filesAffected: 2, removed: 2 }); + assert.deepEqual(fileTexts(path.join(dir, "s1.jsonl")), ["keep"]); + assert.deepEqual(fileTexts(path.join(dir, "s2.jsonl")), ["also-keep"]); +}); + +test("project delete leaves other projects untouched", () => { + const root = makeRoot(); + const dirA = path.join(root, "projects", projectHash(PROJECT_A)); + const dirB = path.join(root, "projects", projectHash(PROJECT_B)); + writeLines(path.join(dirA, "s.jsonl"), ["victim"]); + writeLines(path.join(dirB, "s.jsonl"), ["victim", "b-keep"]); + deleteFromProject(root, PROJECT_A, "victim"); + assert.deepEqual(fileTexts(path.join(dirB, "s.jsonl")), ["victim", "b-keep"]); +}); + +test("project delete of unknown prompt is a no-op", () => { + const root = makeRoot(); + const dir = path.join(root, "projects", projectHash(PROJECT_A)); + writeLines(path.join(dir, "s.jsonl"), ["a"]); + const result = deleteFromProject(root, PROJECT_A, "missing"); + assert.deepEqual(result, { filesAffected: 0, removed: 0 }); + assert.deepEqual(fileTexts(path.join(dir, "s.jsonl")), ["a"]); +}); + +test("project delete on a missing dir is a no-op", () => { + const root = makeRoot(); + const result = deleteFromProject(root, PROJECT_A, "x"); + assert.deepEqual(result, { filesAffected: 0, removed: 0 }); +}); + +test("global delete on a root without a projects dir is a zero-delete no-op", () => { + const root = makeRoot(); + const result = deleteFromGlobal(root, "x"); + assert.deepEqual(result, { filesAffected: 0, removed: 0 }); +}); + +test("global delete sweeps every project dir plus the legacy seed", () => { + const root = makeRoot(); + const dirA = path.join(root, "projects", projectHash(PROJECT_A)); + const dirB = path.join(root, "projects", projectHash(PROJECT_B)); + writeLines(path.join(dirA, "s.jsonl"), ["victim", "a-keep"]); + writeLines(path.join(dirB, "s.jsonl"), ["victim"]); + writeLines(globalSeedPath(root), ["victim", "legacy-keep"]); + const result = deleteFromGlobal(root, "victim"); + assert.deepEqual(result, { filesAffected: 3, removed: 3 }); + assert.deepEqual(fileTexts(path.join(dirA, "s.jsonl")), ["a-keep"]); + assert.deepEqual(fileTexts(path.join(dirB, "s.jsonl")), []); + assert.deepEqual(fileTexts(globalSeedPath(root)), ["legacy-keep"]); +}); + +test("delete leaves no tmp files behind", () => { + const root = makeRoot(); + const dir = path.join(root, "projects", projectHash(PROJECT_A)); + writeLines(path.join(dir, "s.jsonl"), ["victim"]); + deleteFromProject(root, PROJECT_A, "victim"); + const leftovers = fs.readdirSync(dir).filter((f) => f.includes(".tmp-")); + assert.deepEqual(leftovers, []); +}); + +// node:test has no test.skipIf (Bun-ism): root skips via the options object. +test( + "an unreadable store file (chmod 000) is skipped; readable copies still swept", + { skip: process.getuid?.() === 0 ? "requires non-root" : false }, + () => { + const root = makeRoot(); + const dir = path.join(root, "projects", projectHash(PROJECT_A)); + const readable = path.join(dir, "readable.jsonl"); + const sealed = path.join(dir, "sealed.jsonl"); + writeLines(readable, ["victim", "keep"]); + writeLines(sealed, ["victim"]); + fs.chmodSync(sealed, 0o000); + try { + const result = deleteFromProject(root, PROJECT_A, "victim"); + // The unreadable file's copy is invisible to the sweep; the readable + // copy is removed and the sweep is never fatal. + assert.deepEqual(result, { filesAffected: 1, removed: 1 }); + assert.deepEqual(fileTexts(readable), ["keep"]); + assert.equal(fs.existsSync(sealed), true); + } finally { + fs.chmodSync(sealed, 0o644); // restore before cleanup + } + }, +); + +test("a file whose every line is deleted becomes empty (kept, not removed)", () => { + const root = makeRoot(); + const dir = path.join(root, "projects", projectHash(PROJECT_A)); + const file = path.join(dir, "s.jsonl"); + writeLines(file, ["only-victim"]); + deleteFromProject(root, PROJECT_A, "only-victim"); + assert.equal(fs.existsSync(file), true); + assert.equal(fs.readFileSync(file, "utf8"), ""); +}); + +// Active-writer safety (design v2): the sweep rewrites the writer's own +// file IN PLACE (tmp + rename, never a removal — emptied files are kept), +// so a concurrently live writer keeps working by path: its next capture +// appends into the swept file, and the surviving + new lines parse fine. +test("a sweep with a concurrent live writer keeps the writer's file functional", () => { + const root = makeRoot(); + const state = openSessionWriter(root, PROJECT_A, "instance-1"); + appendSessionCapture(state, "victim"); + appendSessionCapture(state, "keeper"); + + const result = deleteFromProject(root, PROJECT_A, "victim"); + assert.deepEqual(result, { filesAffected: 1, removed: 1 }); + + // The same writer state keeps appending after the sweep — the file was + // rewritten under the writer's feet, not removed. + appendSessionCapture(state, "after-delete"); + assert.equal(state.lineCount, 3); + assert.equal(fs.existsSync(state.filePath), true); + assert.deepEqual(fileTexts(state.filePath), ["keeper", "after-delete"]); +}); diff --git a/tests/history-wheel-mouse.test.ts b/tests/history-wheel-mouse.test.ts index 37ddcd6b6..fbb4a33b7 100644 --- a/tests/history-wheel-mouse.test.ts +++ b/tests/history-wheel-mouse.test.ts @@ -34,7 +34,7 @@ const selectorSource = fs.readFileSync( // T13 — AC-L6-1: wheel-only override + no extra dispatch entry. -test("handleMouse override is wheel-only and the dispatch table keeps 11 entries (AC-L6-1)", () => { +test("handleMouse override is wheel-only and the dispatch table keeps 12 entries (AC-L6-1)", () => { const decl = selectorSource.indexOf("override handleMouse("); assert.ok(decl >= 0, "PromptHistorySelector should override handleMouse"); const end = selectorSource.indexOf("\n }", decl); @@ -60,8 +60,8 @@ test("handleMouse override is wheel-only and the dispatch table keeps 11 entries const entries = table.split("match:").length - 1; assert.equal( entries, - 11, - "wheel is not a keybinding: exactly the 11 §B2 dispatch entries, no extra", + 12, + "wheel is not a keybinding: exactly 12 dispatch entries, no 13th", ); }); From ea7e33b5f34fbe25cbbe0f6ee128f32d3b7beb62 Mon Sep 17 00:00:00 2001 From: Carolina <26188349+carolitascl@users.noreply.github.com> Date: Wed, 23 Sep 2026 21:20:33 -0300 Subject: [PATCH 2/6] fix(history): satisfy upstream typecheck gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit extensions/history/index.ts imported `ShortcutContext`, a type that only exists in the dev repo's @types shim — the real @earendil-works/pi-coding-agent exports `ExtensionCommandContext`, so the type gate added to main reports TS2305 on this branch's CI merge. Import `ExtensionCommandContext` and narrow both handler contexts to `Pick` (the only member they use), mirroring the fix already carried on the slice-6 branch. --- extensions/history/index.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/extensions/history/index.ts b/extensions/history/index.ts index 424008b68..c63993cd1 100644 --- a/extensions/history/index.ts +++ b/extensions/history/index.ts @@ -12,7 +12,7 @@ import { homedir } from "node:os"; import { DynamicBorder, type ExtensionAPI, - type ShortcutContext, + type ExtensionCommandContext, type Theme, } from "@earendil-works/pi-coding-agent"; import { @@ -880,7 +880,7 @@ function createPromptHistorySelectorFactory( } async function runPromptHistorySelection( - ctx: ShortcutContext, + ctx: Pick, records: PromptRecord[], ): Promise { const historyGlobals: PiHistoryGlobals = globalThis as Record< @@ -956,7 +956,7 @@ function drainForScope(scope: HistoryScope): string[] { } async function openHistorySelector( - ctx: Pick, + ctx: Pick, ): Promise { // Store-only drain (user-directed): both scopes read the store files // symmetrically — no live transcript merge (the one-time seed bootstrap From 041973d4cce2aa2a418fc06ccba1bb699c15af2d Mon Sep 17 00:00:00 2001 From: Carolina <26188349+carolitascl@users.noreply.github.com> Date: Thu, 24 Sep 2026 14:47:37 -0300 Subject: [PATCH 3/6] fix(history): make prompt capture opt-in and document the store Review follow-up on the slice-01 PR: the before_agent_start handler recorded delivered prompts by default while the deletion UI is still unshipped, so an intermediate release could accumulate sensitive prompts with no removal path. - Capture is now strictly opt-in via GENTLE_PI_HISTORY_CAPTURE=1|true|on (default off); the switch doubles as the disable path, is checked per prompt, and a disabled session writes nothing - no registry entry, no files. - promptHistoryExtension takes injectable deps (env/root/cwd/ instanceId/now) with one writer closure per extension load. - New tests: strict opt-in matrix, default-off inertness, opted-in capture, disable-leaves-existing-files. - docs/prompt-history.md documents the switch, storage locations, permissions/readers, and disable/removal semantics; the README docs table gains a pointer. --- README.md | 1 + docs/prompt-history.md | 61 +++++++++++++++++++ extensions/history/index.ts | 88 ++++++++++++++++++++++++---- tests/history-session-writer.test.ts | 66 ++++++++++++++++++++- 4 files changed, 205 insertions(+), 11 deletions(-) create mode 100644 docs/prompt-history.md diff --git a/README.md b/README.md index 9e20d72f4..0207c389c 100644 --- a/README.md +++ b/README.md @@ -880,6 +880,7 @@ To opt out: | `docs/skill-style-guide.md` | Normative style guide used by the packaged skill creation/improvement skills. | | `docs/native-authority-architecture.md` | Post-U8 ownership boundary, reproducible slimming metrics, Windows evidence, exact #191 seam, and the `review-integration/v1`→`v2` migration status, including the "compact-v2" naming disambiguation. | | `docs/review-integration.md` | Negotiated provider/consumer contract and the current Gentle Pi adoption boundary. | +| `docs/prompt-history.md` | Prompt-history slice 1: opt-in capture switch, storage layout, readers, and disable/removal semantics. | ## Development diff --git a/docs/prompt-history.md b/docs/prompt-history.md new file mode 100644 index 000000000..1fa8eba4f --- /dev/null +++ b/docs/prompt-history.md @@ -0,0 +1,61 @@ +# Prompt history + +Slice 1 of the prompt-history extension (#819 split) ships the storage layer only: +a per-instance JSONL capture store, project identity, and the read/write +primitives later slices build on. The selector UI, deletion/scope drains, and GC +arrive in later slices of the chain. + +## Capture is opt-in + +Recording is **off by default**. Delivered prompts can contain secrets, and the +deletion UI is not shipped yet, so nothing is stored unless you explicitly opt in: + +```bash +GENTLE_PI_HISTORY_CAPTURE=1 pi +``` + +- Enabled by `1`, `true`, or `on` (case-insensitive). Unset, empty, or any other + value means **off** — the same switch is the disable path. +- The check runs per prompt: unsetting the switch (or setting it to `0`) stops + new captures immediately, no pi restart needed. +- With capture off the extension is inert: no registry entry, no files, and + prompts are never written. + +## Where the files live + +Everything sits under `~/.pi/agent/history/`: + +- `registry.json` — advisory map of project hash → cwd, used for display + labels. +- `projects//.jsonl` — one append-only capture file per pi + process. + +`` is the first 16 hex chars of the SHA-256 of the canonicalized project +cwd; `` is a per-process UUID. Each line is one delivered prompt: + +```json +{"v":1,"text":"the prompt as delivered","ts":1700000000000} +``` + +UI command-like prompts (`/name ...`) and empty lines are never stored. Later +slices add the rebuildable `seed.jsonl`, scope drains/deletes, and GC. + +## Who can read them + +The store is plain JSONL on your local disk, not encrypted. Files are created by +the pi process with default umask permissions (typically `0644` files inside +`0755` directories), so any process running as your OS user can read them, and +other local accounts can too wherever they can traverse your home directory. +Treat the store as sensitive: it holds your prompts verbatim. + +## What disabling capture does + +Turning the switch off only stops **new** captures. Nothing is deleted: files +already written — and the registry entry — stay on disk until you remove them or +the deletion UI ships. To erase the store manually while capture is off (or pi +is not running): + +```bash +rm -rf ~/.pi/agent/history # whole store +rm -rf ~/.pi/agent/history/projects/ # one project (see registry.json) +``` diff --git a/extensions/history/index.ts b/extensions/history/index.ts index c63993cd1..97ec8f4a4 100644 --- a/extensions/history/index.ts +++ b/extensions/history/index.ts @@ -6,6 +6,12 @@ // and slice-4 init sequence (legacy migration + seed bootstrap run once // inside getWriter). Deletion (slice 5) is wired here; GC/compaction // (slice 6) arrives in a later slice. +// +// Capture is OPT-IN while the deletion/privacy behavior is unshipped: +// nothing is recorded unless GENTLE_PI_HISTORY_CAPTURE=1|true|on. With the +// switch off the handler is a no-op — no registry entry, no files, and +// prompts are never written. Unsetting the switch only stops NEW captures; +// files already written stay on disk (docs/prompt-history.md). import { join } from "node:path"; import { homedir } from "node:os"; @@ -988,14 +994,74 @@ function recordsFromEntries( return buildPromptRecords(dedupePromptEntries(entries)); } -export default function promptHistoryExtension(pi: ExtensionAPI) { - // One writer per extension load; see getWriter() for the init order. - // Warm migrate/registry/seed OFF the first-prompt path: the scheduled - // init runs once, immediately after load. A prompt arriving earlier - // falls back to the synchronous lazy init in getWriter(), whose - // writerState guard makes whichever runs second a no-op — bootstrap - // work is never duplicated. + +export interface HistoryDeps { + env?: NodeJS.ProcessEnv; + root?: string; + cwd?: string; + instanceId?: string; + now?: () => number; +} + +/** + * Strict opt-in: capture stays off unless GENTLE_PI_HISTORY_CAPTURE is + * explicitly 1, true, or on (case-insensitive). The same switch is the + * disable path — unsetting it stops new captures; files already on disk + * are left untouched until the deletion tooling lands. + */ +export function captureEnabled(env: NodeJS.ProcessEnv = process.env): boolean { + const value = env.GENTLE_PI_HISTORY_CAPTURE?.trim().toLowerCase(); + return value === "1" || value === "true" || value === "on"; +} + +export default function promptHistoryExtension( + pi: ExtensionAPI, + deps: HistoryDeps = {}, +): void { + const env = deps.env ?? process.env; + const root = deps.root ?? PI_HISTORY_ROOT; + const cwd = deps.cwd ?? CURRENT_CWD; + const instanceId = deps.instanceId ?? INSTANCE_ID; + const now = deps.now ?? Date.now; + let writerState: SessionWriterState | null = null; + + /** + * One-time init per extension load: migrate legacy stores, register the + * project, bootstrap the seed, then open this instance's exclusive file. + */ + const getWriter = (): SessionWriterState => { + if (!writerState) { + try { + migrateLegacyStores(root, AGENT_DIR); + } catch { + // migration is best-effort; the gate keeps it one-shot + } + try { + ensureRegistryEntry(root, cwd); + } catch { + // registry is advisory + } + try { + bootstrapProjectSeed( + root, + cwd, + SESSIONS_ROOT, + 500, + PI_HISTORY_NAV_STATE_DIR, + ); + } catch { + // bootstrap is a rebuildable cache + } + writerState = openSessionWriter(root, cwd, instanceId); + } + return writerState; + }; + + // Warm migrate/registry/seed OFF the first-prompt path, but only for + // opted-in sessions: with capture disabled nothing may be written — + // no registry entry, no seed files, no store (docs/prompt-history.md). setImmediate(() => { + if (!captureEnabled(env)) return; try { getWriter(); } catch { @@ -1003,12 +1069,14 @@ export default function promptHistoryExtension(pi: ExtensionAPI) { } }); - // Persist every delivered user prompt (write-through, append-only JSONL). - // The local ExtensionAPI stub types handler args as unknown; narrow here. + // Persist every delivered user prompt (write-through, append-only JSONL), + // but only for opted-in sessions — see captureEnabled(). The local + // ExtensionAPI stub types handler args as unknown; narrow here. pi.on("before_agent_start", (...args: unknown[]) => { + if (!captureEnabled(env)) return; try { const event = args[0] as { prompt?: string } | undefined; - appendSessionCapture(getWriter(), event?.prompt ?? "", Date.now()); + appendSessionCapture(getWriter(), event?.prompt ?? "", now()); } catch { // A capture failure must never break the agent loop or unregister // the handler - swallow and keep the next prompt capturable. diff --git a/tests/history-session-writer.test.ts b/tests/history-session-writer.test.ts index 61a2689b3..bb135582a 100644 --- a/tests/history-session-writer.test.ts +++ b/tests/history-session-writer.test.ts @@ -9,7 +9,7 @@ import { projectHash, sessionFilePath, } from "../extensions/history/store.ts"; -import promptHistoryExtension from "../extensions/history/index.ts"; +import promptHistoryExtension, { captureEnabled } from "../extensions/history/index.ts"; function makeRoot(): string { return fs.mkdtempSync(path.join(os.tmpdir(), "pi-history-writer-")); @@ -29,6 +29,29 @@ function openWriterForTest(root: string, instanceId: string) { return openSessionWriter(root, CWD, instanceId); } +/** Load the extension against a temp root and return the capture handler. */ +function captureHandlerWith(env: NodeJS.ProcessEnv, root: string) { + const registered: Array<[string, unknown]> = []; + const pi = { + on: (event: string, handler: unknown) => { + registered.push([event, handler]); + }, + // Slice-3+ wiring surface: the factory also registers the shortcut, + // command, and tool_call dismissal; the capture handler stays the + // first registration, so these no-ops only absorb the extra wiring. + registerShortcut: () => {}, + registerCommand: () => {}, + }; + promptHistoryExtension(pi as never, { + env, + root, + cwd: CWD, + instanceId: "inst-entry", + now: () => 1700000000000, + }); + return registered[0][1] as (event: unknown) => void; +} + test("no file is created until the first capture", () => { const root = makeRoot(); const state = openWriterForTest(root, "sess-1"); @@ -121,3 +144,44 @@ test("the extension entry registers exactly the slice-3 wiring surface", () => { assert.equal(typeof handler, "function"); } }); + +test("captureEnabled is a strict opt-in", () => { + assert.equal(captureEnabled({}), false); + assert.equal(captureEnabled({ GENTLE_PI_HISTORY_CAPTURE: "0" }), false); + assert.equal(captureEnabled({ GENTLE_PI_HISTORY_CAPTURE: "false" }), false); + assert.equal(captureEnabled({ GENTLE_PI_HISTORY_CAPTURE: "off" }), false); + assert.equal(captureEnabled({ GENTLE_PI_HISTORY_CAPTURE: "yes" }), false); + assert.equal(captureEnabled({ GENTLE_PI_HISTORY_CAPTURE: " 1 " }), true); + assert.equal(captureEnabled({ GENTLE_PI_HISTORY_CAPTURE: "TRUE" }), true); + assert.equal(captureEnabled({ GENTLE_PI_HISTORY_CAPTURE: "On" }), true); +}); + +test("the capture handler is a no-op unless the user opts in", () => { + const root = makeRoot(); + const handler = captureHandlerWith({}, root); + handler({ prompt: "sensitive prompt" }); + handler({ prompt: "another one" }); + // Nothing at all: no capture file, no project dir, no registry entry. + assert.deepEqual(fs.readdirSync(root), []); +}); + +test("an opted-in session captures delivered prompts", () => { + const root = makeRoot(); + const handler = captureHandlerWith({ GENTLE_PI_HISTORY_CAPTURE: "1" }, root); + handler({ prompt: "hello store" }); + assert.deepEqual(fileTexts(sessionFilePath(root, CWD, "inst-entry")), [ + "hello store", + ]); +}); + +test("disabling capture stops new lines and leaves existing files alone", () => { + const root = makeRoot(); + const env: NodeJS.ProcessEnv = { GENTLE_PI_HISTORY_CAPTURE: "true" }; + const handler = captureHandlerWith(env, root); + handler({ prompt: "kept" }); + const file = sessionFilePath(root, CWD, "inst-entry"); + assert.equal(fs.existsSync(file), true); + delete env.GENTLE_PI_HISTORY_CAPTURE; + handler({ prompt: "never written" }); + assert.deepEqual(fileTexts(file), ["kept"]); +}); From 3cc57c7ed05d977bfcac7509a344e310e40eb69a Mon Sep 17 00:00:00 2001 From: Carolina <26188349+carolitascl@users.noreply.github.com> Date: Thu, 24 Sep 2026 17:21:16 -0300 Subject: [PATCH 4/6] chore(readme): remove README delta from history slice The history slice branches must not touch README.md: the docs table lives in main and evolves independently of the extension slices. The opt-in capture documentation stays in docs/prompt-history.md; the README pointer row introduced by the capture-gate commit is dropped and README.md is restored to upstream/main verbatim. --- README.md | 1036 +++++++++++------------------------------------------ 1 file changed, 207 insertions(+), 829 deletions(-) diff --git a/README.md b/README.md index 0207c389c..a8a5ee727 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,44 @@ -# gentle-pi + -[![npm](https://img.shields.io/npm/v/gentle-pi?color=blue)](https://www.npmjs.com/package/gentle-pi) -[![pi package](https://img.shields.io/badge/Pi-package-6f42c1)](https://pi.dev/packages/gentle-pi) -[![license](https://img.shields.io/npm/l/gentle-pi?color=blue)](LICENSE) -[![GitHub stars](https://img.shields.io/github/stars/Gentleman-Programming/gentle-pi?style=flat&color=yellow)](https://github.com/Gentleman-Programming/gentle-pi/stargazers) -[![Gentle-AI](https://img.shields.io/badge/Gentle--AI-ecosystem-ff69b4)](https://github.com/Gentleman-Programming/gentle-ai) -[![Gentleman Programming](https://img.shields.io/badge/by-Gentleman%20Programming-black)](https://github.com/Gentleman-Programming) -[![YouTube](https://img.shields.io/badge/YouTube-Gentleman%20Programming-red?logo=youtube&logoColor=white)](https://www.youtube.com/c/GentlemanProgramming) -[![Discord](https://img.shields.io/badge/Discord-community-5865F2?logo=discord&logoColor=white)](https://discord.com/invite/gentleman-programming-769863833996754944) -[![SDD/OpenSpec](https://img.shields.io/badge/SDD-OpenSpec-00ADD8)](#sddopenspec-flow) -[![Subagents](https://img.shields.io/badge/Pi-subagents-brightgreen)](#what-it-adds) +
+ gentle-shell — Ecosystem, Agent, One shell +
+ +

gentle-shell™

+ +

Your coding agent for controlled development in the workspace you lead.

+ +

+ npm + Pi-native package + MIT license + GitHub stars + Last commit +

+ +

+ + Website +  ·  + Quickstart +  ·  + Docs +  ·  + Wiki + +

+ +
+ +

Your terminal can run an agent. Your workspace should help you lead it.
gentle-shell is your coding agent, bringing your changes, tasks, and engineering workflow together—built for Pi.

-**[Gentle-AI website](https://gentle-ai.gentlemanprogramming.com/)** • **[Gentle-AI wiki](https://gentle-ai-wiki.gentlemanprogramming.com/)** • **[Engram](https://engram.gentlemanprogramming.com/)** +

One workspace. A coding agent you direct. A workflow you can inspect.

+ +

BUILT FOR PI  ·  Coding-agent workspace  ·  Focused agents  ·  ODD

+ +

+ ★ Star gentle-shell on GitHub +

@@ -27,934 +54,285 @@ - Star History Chart + Star History Chart -
- -**Turn Pi from a powerful coding agent into a controlled development harness.** - -`gentle-pi` installs **el Gentleman** in Pi: a senior-architect operating layer for Spec-Driven Development, focused subagents, strict TDD evidence, reviewable work units, safety guards, project/user skill discovery, and bounded native review. - -Pi already has strong tools. `gentle-pi` adds the discipline for using them well, keeps review evidence Git-derived instead of agent narration, and leaves delivery decisions to ordinary repository policy. - -`gentle-pi` is the Pi-native package from the [Gentle-AI ecosystem](https://github.com/Gentleman-Programming/gentle-ai), built by [Gentleman Programming](https://github.com/Gentleman-Programming): the broader open-source project for turning AI coding agents into disciplined engineering environments with SDD workflows, skills, memory integrations, model routing, and review guardrails across multiple agents. - -> **Trademark notice:** The gentle-pi name and logo are trademarks of Alan Buscaglia. The MIT License applies to the code; it does not permit implying endorsement or official affiliation. See [TRADEMARKS.md](TRADEMARKS.md). - -Follow the project and the community around it: - -- GitHub: [Gentleman-Programming](https://github.com/Gentleman-Programming) -- YouTube: [Gentleman Programming](https://www.youtube.com/c/GentlemanProgramming) -- Community Discord: [Gentleman Programming](https://discord.com/invite/gentleman-programming-769863833996754944) - -Startup intro collaboration: thanks to [@aporcelli](https://github.com/aporcelli) for [`pi-gentle-startup`](https://github.com/aporcelli/pi-gentle-startup), which inspired the clean-screen startup animation, compact runtime panel, and pink visual treatment. - -## The problem - -Most coding-agent sessions fail for operational reasons, not model reasons: - -- the agent jumps into code before requirements are clear; -- architectural decisions disappear into chat history; -- one request quietly becomes a huge multi-area diff; -- tests run late, or not at all; -- reviewers get handed a wall of changes; -- subagents are available, but the parent session has no orchestration discipline; -- project skills exist, but the model forgets to load them. - -`gentle-pi` fixes the workflow around the agent. - -## What it adds - -| Capability | What it does | -| ------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------- | -| **el Gentleman persona** | Makes Pi behave like a senior architect and teacher, not a generic chatbot. Spanish responses use Rioplatense voseo by default; neutral mode is saved globally with project overrides. | -| **Configurable startup intro** | Adds a rose/text-logo startup intro, compact runtime panel, color presets, and commands to hide or show the decorative parts. | -| **Work routing discipline** | Small tasks stay inline. Context-heavy exploration can be delegated. Large or risky changes go through SDD/OpenSpec. | -| **SDD/OpenSpec assets** | Installs phase agents and chains for `init`, `onboard`, `explore`, `proposal`, `spec`, `design`, `tasks`, `apply`, `verify`, `sync`, and `archive`. | -| **Lazy SDD preflight** | Resolves SDD mode, artifact store, delivery strategy, and review budget once per session; prompts only when a choice is genuinely unresolved. | -| **Subagent orchestration** | Keeps one parent session responsible while child agents explore, implement, test, or review with focused context. | -| **Strict TDD support** | When project config declares a test command, apply/verify phases must record RED → GREEN → TRIANGULATE → REFACTOR evidence. | -| **Closed choice prompts** | Per-option hover/click/wheel in fullscreen; keyboard selection in either TUI mode. | -| **Native pointer regions** | Compose hover, press, click, and wheel behavior around public TUI components. | -| **Agent overlay close control** | Adds a header close button that adapts to available width. | -| **Reviewer protection** | Surfaces review workload risk before a task turns into an oversized PR. | -| **Per-agent model assignment** | Pi-native modal for assigning stronger or cheaper models to specific SDD/custom agents. | -| **Skill discovery registry** | Maintains `.atl/skill-registry.md` from project and user skills so review/comment/PR workflows do not silently miss the right skill. | -| **Skill creation workflow** | Provides the `gentle-ai-skill-creator`/`gentle-ai-skill-improver` skills, `/skill-creation` prompt, and packaged style guide for LLM-first skills. | -| **Delivery skills** | Includes issue-first PRs, chained PRs, work-unit commits, cognitive docs, comment writing, and Judgment Day review. | -| **Bounded native review** | Freezes one candidate, dispatches only controller-selected lenses, and records native authority. Review outcomes are informational; delivery follows ordinary repository policy. | -| **Verified native runtime** | Provisions the exact package-local Gentle AI v2.7.0 runtime: signed, SHA-256-pinned release archives on Darwin/Linux and a Go SumDB-verified source build on Windows x64/arm64. It validates package-local integrity and rejects PATH, global, sibling, symlink, and mode fallbacks. | -| **Runtime safety** | Blocks destructive shell commands, asks for confirmation for sensitive operations, and blocks direct read/write/edit access to sensitive paths. | - -## Native pointer regions - -Compose pointer behavior around public `Text`, `Box`, or custom content without making it a keyboard target: - -```ts -const scope = createNativePointerScope(); -const openInput = scope.wrap(new Text("Open input", 0, 0), { - onClick: () => { - openInputEditor(); - return { handled: true }; - }, -}); -const panel = new Container(); -panel.addChild(openInput); -const observer = scope.createMouseObserver(() => tui.requestRender()); -``` - -Pass `observer` around the root's native mouse dispatch; reuse `panel` as custom or overlay content. -Pointer input is fullscreen-only. Regions preserve a consuming child's native result and do not focus -`Text`, activate on press or wheel, synthesize outside leave events, or alter terminal tracking. -Callers own keyboard policy, theme state, and business actions. - -**Migration note:** Do not enable `pi-tool-cards` and `quiet-tools` together: Pi rejects duplicate `bash`, `read`, `edit`, and `write` registrations. Disable or remove the standalone package during migration; gentle-pi does not change those package registrations or delete that repository. The global fullscreen setting described below is a separate install-time change. - -## Install - -```bash -pi install npm:gentle-pi@0.14.0 -``` - -### Install-time fullscreen - -For this release, a successful postinstall in Pi's **global npm-managed** `agent-home/npm/node_modules/gentle-pi` installation persists `"tuiMode": "fullscreen"` in `agent-home/settings.json`, preserving other settings. Agent home resolves through `GENTLE_PI_AGENT_HOME`, then `PI_CODING_AGENT_DIR`, then `~/.pi/agent`. Use `/settings` to switch back to regular; rerunning this recognized postinstall resets it to fullscreen. Existing project overrides still take precedence. - -Project-local installs (`pi install -l`), Git/local-path installs, temporary packages, development checkouts, ordinary npm consumers, and pnpm symlink-store packages do **not** receive this change. Updates or installs that do not execute postinstall cannot reassert it; this is not a universal install/update guarantee or a change to historical releases. - -Malformed/nonobject JSON, symlink/nonregular settings, unsafe paths, or a busy settings lock fail without replacing settings. The installer coordinates with Pi's cooperative settings lock and uses atomic replacement; it does not guarantee safety against noncooperating writers or malicious concurrent directory replacement. Already-fullscreen settings remain byte-identical. Native installation failure leaves settings untouched; `GENTLE_PI_SKIP_GENTLE_AI_INSTALL=1` skips only native provisioning, not the recognized global fullscreen setting. - -### RDD version policy - -Native RDD started in `gentle-pi` `v0.15.0` on 2026-07-10 with bounded review transactions. Every release from `v0.15.0` onward is part of the unstable RDD development line. New releases will continue improving RDD until the project declares the line stable. The stable version for normal use without native RDD is the last preceding release, `v0.14.0`. - -```bash -# Stable version without native RDD -pi install npm:gentle-pi@0.14.0 - -# Latest released RDD build (unstable) -pi install npm:gentle-pi@latest -``` - -The latest RDD package installs Gentle AI only into its private `.gentle-ai/` directory. Darwin and Linux use pinned release assets with asset and executable SHA-256 verification (signed archives for stable pins such as the current v2.7.0; raw prerelease binaries only under a prerelease pin). Windows x64 and arm64 build the exact `v2.7.0` source tag with a local Go 1.25.10+ toolchain, a sealed Go environment, `GOTOOLCHAIN=local`, and `GOSUMDB=sum.golang.org`; it does not download Go automatically. Windows provenance is Go-toolchain plus SumDB evidence and postinstall tamper detection, **not** Authenticode or protection against a malicious joint binary-and-manifest replacement. Package-private locks coordinate cooperative concurrent or crashed installers; their tombstones fail closed. A malicious same-user process with write access to package-private `node_modules` is outside that protocol because it can already replace package code, binary, or manifest, and portable Node has no pathname-delete CAS. It never uses `PATH` or a global `gentle-ai` installation. For development or offline installs only, set `GENTLE_PI_SKIP_GENTLE_AI_INSTALL=1`; native review operations then fail closed with an actionable `package-local-binary-missing` error until the package is reinstalled normally. - -Recommended companion packages: - -```bash -pi install npm:pi-intercom -pi install npm:gentle-engram -pi install npm:pi-web-access -pi install npm:pi-lens -pi install npm:@juicesharp/rpiv-ask-user-question -``` - -Then start Pi in a project: - -```bash -pi -``` - -`gentle-pi` provides SDD agents as global Pi runtime assets, not per-project setup. The first SDD flow in a session still runs a one-time SDD preflight for preferences; for natural-language requests, el Gentleman decides when SDD is needed and runs the explicit preflight first. - -## Quick start - -```text -/gentle:status Check package, SDD assets, OpenSpec, and global model config. -/gentle:doctor Run read-only diagnostics for SDD assets, config, tools, and guards. -/gentle:sdd-preflight Run or reuse the session SDD preflight explicitly. -/gentle-sdd-init Create or refresh openspec/config.yaml (openspec/both stores only). -/gentle:models Assign global model/effort routing to SDD/custom agents. -/gentle:persona Switch between gentleman and neutral persona modes. -/gentle:background-subagents Show or set the managed background-subagents policy, with its deciding source. -/gentle:banner Configure startup rose, text logo, and color preset. -``` - -Typical flow: - -1. Open Pi in your repo. -2. Run `/gentle:status`. -3. Run `/gentle-sdd-init` once per project, or when test/project capabilities change. This also runs the session SDD preflight. -4. For a substantial change, ask Pi to use SDD. Natural-language requests are classified by the parent agent, not by brittle runtime regexes. -5. Review the phase artifacts instead of trusting floating chat context. - -## Core workflow - -1. **Install and inspect.** Install `gentle-pi`, open Pi in the target repository, then run `/gentle:status` or `/gentle:doctor`. -2. **Plan when risk justifies it.** Small work stays direct; substantial work uses SDD with Engram, OpenSpec, or both so requirements and decisions survive compaction. -3. **Build with evidence.** One focused writer implements the approved scope. When Strict TDD is available, apply and verify preserve RED → GREEN → TRIANGULATE → REFACTOR evidence. -4. **Use runtime-owned RDD when available.** Gentle AI supplies any runtime-specific review instructions; this package does not recreate a lifecycle in documentation or prompts. -5. **Deliver through ordinary repository policy.** Review and Judgment Day evidence is informational only; Pi never creates a delivery route, authorization, target rederivation, or receipt gate. - -> **Trust what the system can derive, not what an agent claims.** Agents analyze the candidate. The package-local Gentle AI runtime owns scope, risk, findings, and review authority. Review outcomes inform delivery; ordinary repository policy decides delivery commands. Dangerous-command safety and destructive-review consent remain independent. See Gentle AI's [review authority threat model](https://github.com/Gentleman-Programming/gentle-ai/blob/main/docs/review-authority-threat-model.md) and [Chapter 21 — Verifiable Trust](https://the-amazing-gentleman-programming-book.vercel.app/en/book/Chapter21_Verifiable-Trust). - -## How the harness decides what to do - -`gentle-pi` routes through the smallest safe workflow: - -| Request shape | Harness | -| --------------------------------------------------------------------------- | ---------------------------- | -| Small, clear, local edit | Inline direct work. | -| Unknown codebase area or context-heavy investigation | Focused subagent delegation. | -| Large, ambiguous, architectural, product-facing, or high-review-risk change | SDD/OpenSpec flow. | - -The goal is not ceremony. The goal is to avoid accidental chaos. Once a task stops being small, delegation is mandatory. - -### Delegation triggers - -`gentle-pi` keeps the parent session thin and delegates at the narrowest useful point. When the Pi Subagents extension is installed, the preferred runtime is the `subagent_*` tool family because it runs the user's configured project/global subagent definitions and preserves history/background behavior. With the background policy on, delegations default to background mode: the terminal stays free and each result comes back as a message that starts a new turn; task mode is reserved for delegations that must ask the user something mid-flight. If those tools are unavailable, the parent should fall back to Pi's native `Agent` tool or another available delegation mechanism. The requirement is delegation; the runtime is capability-dependent. - -| Trigger | Required behavior | -| --------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- | -| Reading 4+ files to understand a flow | Launch `scout`, `context-builder`, or the closest read-only mapping subagent. | -| Touching 2+ non-trivial code files | Delegate one writer; do not continue inline unless delegation is unavailable. | -| Commit, push, or PR after code changes | Follow the loaded native instruction, or ordinary repository policy when none is supplied. | -| Wrong cwd, worktree/git accident, merge recovery, confusing test/env issue | Stop, preserve the affected scope, and investigate separately before resuming. | -| Long monolithic session with accumulating complexity, roughly 20 tool calls, 5 exploratory reads, or 2 non-mechanical edits | Pause and delegate the remaining work, or stop and explain the exact blocker. | - -The intended balanced loop for a bounded bugfix is: - -```text -parent git/status + clarify → one worker writes authorized fixes → focused verification → parent reports -``` - -`scout`/`context-builder` save parent context by compressing broad exploration. `worker` preserves a single writer thread. Any RDD-specific actor behavior belongs to the runtime instruction supplied by Gentle AI, not to this README. - -### Review authority recovery and reset safety - -Legacy pre-graph authority is never migrated. `gentle_review inspect` reports an exact repository-bound destructive reset challenge for legacy corruption; after that fresh interactive authorization, RESET and RECOVER_LOCK route to the audited native `gentle-ai review reclaim` operation and RECOVER routes to native `gentle-ai review recover`, so every destructive transition is executed and audited by the native authority store. Native inputs the request did not carry return a `native-input-required` envelope instead of being invented. Existing graph-v1 ordinary lineages remain readable and gate-validatable but are read-only; Judgment Day remains mutable on graph-v1. - -`gentle_review abandon`, `quarantine-legacy`, and `reconcile-authority` remain explicit v2.1.11 maintenance routes. Pi derives and displays the published nine-line `gentle-ai.review-abandon-authorization/v2` binding only for a caller-specified compact lineage, revision, snapshot identity, and discarded-work summary (captured lens results, findings presence, evidence-record presence); the native CLI re-derives non-terminal compact-v2 eligibility and the exact discarded work before accepting it. Legacy quarantine accepts only `historical findings freeze changed unrelated transaction state` with disposition `quarantine-malformed-freeze-event` and uses its exact eight-line binding. Both require fresh interactive approval and fail closed headlessly. - -`gentle_review reconcile-authority` accepts one predecessor lineage and revision, one successor lineage and revision, an actor, and a reason. Pi derives the exact seven-line `gentle-ai.review-reconcile-authorization/v1` binding, or appends exactly `anomalies=unchanged_target,malformed_recovery_authorization` for the published dual anomaly in that order. Native code re-derives every anomaly; malformed bindings, changed revisions, unavailable native support, cancellation, and native refusal fail closed through typed envelopes. - -Reconciliation is intentionally narrow: native code may quarantine only the bound invalid compact-v2 recovery successor and persists the returned audit record; the predecessor stays untouched. Pi never recreates the retired `prepare-supersession`/`supersede` authority writer and never falls back to RESET or RECOVER. - -`gentle_review repair-legacy-alias` is the sole v2.1.11 route for `unsupported historical v1 operation alias`. The model supplies only lineage, actor, and reason. Pi freshly reads the native inventory, derives the canonical repository, exact legacy revision, fixed diagnostic, and fixed `quarantine-approved-historical-alias` disposition, displays the LF-only eight-line binding, and requires a new interactive approval. Native re-derives eligibility and quarantines rather than rewriting or validating the historical chain. - -`review dispose-result` is deliberately unsupported by Pi pending a separate design; it has no controller operation or fallback. All maintenance routes fail closed headlessly and never auto-run against legacy history. - -Native lifecycle status remains informational. VALIDATE does not authorize delivery; commit, push, PR, and release commands follow ordinary repository policy. Recovery grants no new budget, and legacy graph bundle export/import is retired. - -This is the post-U8 boundary, not the final architecture. [Issue #191](https://github.com/Gentleman-Programming/gentle-pi/issues/191) is the immediate final unit in this same delivery: extract the remaining Pi command-projection and lifecycle-gate surface from `review-transaction.ts`, repoint runtime enforcement, then delete only dependencies proven unreachable without weakening graph-v1 Judgment Day. The branch-wide High-tier 4R runs after that extraction, before the single size-exception PR. - -### Review Lens Selection (architecture reference) - -`reviewer` is not an installed subagent name. It is historical routing vocabulary, not a static instruction. When a runtime-specific Gentle AI instruction applies, it alone determines whether any concrete lens is used: - -| Context | Review lens | -| --- | --- | -| Clear naming, structure, maintainability, small refactors | `review-readability` | -| Behavior, state, tests, determinism, regressions | `review-reliability` | -| Shell/process integration, partial failures, recovery, degraded dependencies | `review-resilience` | -| Security, permissions, data exposure/loss, architecture, dependencies | `review-risk` | -| Large PR, hot path, or >400 changed lines | Full 4R: `review-risk`, `review-resilience`, `review-readability`, `review-reliability` | - -The former compact controller classified documentation/comment/formatting-only changes as zero-lens, standard changes as one dominant lens, and higher-risk paths as full 4R. This describes compatibility architecture only; never derive or run those choices from this README. - -### Review authority architecture (reference only) - -Gentle AI dynamically supplies runtime-specific RDD instructions. `gentle-pi` does not define an RDD lifecycle, command route, approval path, recovery sequence, or fallback. The historical compact-controller material below documents architecture and compatibility boundaries only; it is not an operator instruction. - -Concretely: `gentle-pi` mirrors the Gentle AI provider contract bundle's `orchestration/pi.md` locally (`contracts/review-provider-contract-mirror/`, verified against the mirror lock's recorded SHA-256 before injection) and injects that mirrored text into the primary session's system prompt at session start. Gentle AI does not write anything into Pi's system prompt; when the mirrored contract is absent, unreadable, or fails digest verification, `gentle-pi` invents no fallback lifecycle. - -```mermaid -flowchart TD - A["Clarify scope and acceptance criteria"] --> B{"Choose the smallest safe workflow"} - B -->|Small and local| C["Inline implementation"] - B -->|Context-heavy or multi-file| D["Focused subagent"] - B -->|Large or architectural| E["SDD phase artifacts"] - C --> F["Implement with test evidence"] - D --> F - E --> F - F --> G["Independent verification"] - G --> H["Target-scoped native status"] - H -->|Ambiguous or corrupted| X["Blocked: native maintainer action"] - H -->|Unrelated| I["START freezes candidate, scope, tier, lenses, and budget"] - - subgraph Ordinary_review["Ordinary bounded review"] - I --> R["reviewing"] - R --> J["Run each selected lens once"] - J --> K{"Severe candidate-caused blocker?"} - K -->|No| A1["approved"] - K -->|Yes| C1["correction_required"] - C1 --> C2["Forecast bounded correction"] - C2 --> C3["Apply scoped fix"] - C3 --> V["validating"] - V -->|Validator passes| A1 - V -->|Fails, malformed, or out of scope| E1["escalated"] - end - - A1 --> O["Review outcome is informational"] - E1 --> O -``` - -VALIDATE is informational. Commit, push, PR, and release commands follow ordinary repository policy; RDD never authorizes, rewrites, consumes review state for, or blocks them. Dangerous-command safety and destructive-review consent remain independent. - -Native contract pairing is exact: this adapter resolves only the integrity-verified package-local Gentle AI v2.7.0 executable, independently hashes it, then negotiates `gentle-ai.review-integration/v2` outside the repository. Capabilities are cached by that executable digest. Every START, target status, FINALIZE, validate, and BIND-SDD request passes the same contract identifier. Negotiated envelopes decode exactly against the vendored schemas; `recover` routes only the provider-selected `action_disposition`, and optional additions require a future compatible schema/minor that the provider explicitly advertises and the consumer negotiates. - -Contract `/v2` replaces the Base64 `candidate_diff` reviewer transport of `/v1` with immutable `base_tree`/`candidate_tree` plus an ordered `changed_path_manifest` and never an inline patch. `gentle-pi` negotiates `/v2` only, with no dual-lane fallback; the cutover landed as one atomic commit against gentle-ai v2.2.2 (tracked by the `migrate-review-integration-v2` change), and the `/v1` schemas stay packaged because the `/v2` schemas `$ref` into their fragments. This provider contract version is unrelated to Pi's own internal "compact-v2" review-authority naming used below — the shared digit is coincidental, not a version pairing. - -Target status owns `current_target`, `unrelated`, `ambiguous`, and `corrupted` applicability and returns one native action. Pi does not reconstruct ordinary authority from provider-private files or choose a lineage from repository-wide history. Restart recovery rebuilds only the derived candidate view from the native Git/content projection, including intended-untracked paths, symlinks, and immutable gitlink identities. Native failure envelopes retain their exact mutation outcome, replayability, required inputs, request digest, and next action. After an unknown or lost mutating result, Pi calls target status before any replay decision and returns only the provider-declared action. - -Once the pinned gentle-ai runtime (currently v2.7.0) has written review authority, rollback MUST preserve every native store and receipt and MUST NOT run a downgraded binary against that repository. Disable the Pi route or roll forward to a compatible authority-aware release instead; deleting authority data or reinstalling an older binary is not a rollback path. - -### FINALIZE wrapper input - -`gentle_review` accepts `input` as a JSON-serialized object string. For initial results, provide `review_result.lens_results[]`; each selected lens appears exactly once with `lens`, `findings`, and non-empty `evidence`. A clean lens uses `findings: []`. Pair `final_evidence` with exactly one of `final_verification_passed` or `final_verification_outcome`. - -```json -{ - "review_result": { - "lens_results": [ - { - "lens": "review-reliability", - "findings": [], - "evidence": ["complete candidate reviewed"] - } - ] - } -} -``` - -This is the Pi wrapper contract, not the native CLI file contract. The native command receives separate `--result`, `--refuter`, `--validation`, and `--evidence` files from the wrapper. - -START derives the complete Git/untracked snapshot, lineage, persisted `low | medium | high` tier, zero/one/four lenses, authored changed lines, and correction budget `min(200, ceil(original_changed_lines / 2))`. Generated `testdata/golden/**` stays in snapshot identity but does not count as authored risk lines. - -Every finding requires `evidence_class`, `causal_disposition`, and concrete changed-hunk, candidate-created-path, differential-test, or before/after proof. Missing IDs are assigned natively and selected-lens results are canonicalized deterministically. - -Actor output is untrusted data and cannot authorize transitions, fixes, receipts, gates, or delivery. - -Only severe `introduced`, `behavior-activated`, or `worsened` findings with valid proof enter correction IDs. `pre-existing` and `base-only` become follow-ups; `unknown`, insufficient, malformed, or inconclusive severe claims escalate. WARNING and SUGGESTION are informational. - -Deterministic blockers need no refuter. Inferential blockers use exactly one complete read-only refuter batch. - -Refuter proof may be independent concrete reproduction evidence; it does not need to duplicate reviewer `proof_refs`. Invalid, empty, malformed, missing, duplicate, unknown, or inconclusive refuter output escalates without a replacement refuter. - -When native IDs are assigned to inferential findings, the first FINALIZE returns their canonical rows and a content-derived request hash without mutation; the second replays identical lens input with that hash and one complete refuter batch. - -Ordinary permits one correction transaction within the original budget. FINALIZE requires a positive forecast before editing and derives actual correction lines from Git; one targeted validator and final verification close that transaction. Initial lenses are never rerun, while frozen findings and genesis scope remain unchanged. - -The validator checks original criteria and correction regression only and cannot add scope or findings. Final evidence is hashed during FINALIZE, never at START. - -Compact ordinary has five states: `reviewing`, `correction_required`, `validating`, `approved`, and `escalated`. - -The validator cannot change claims, add findings, request fixes, launch actors, or request another attempt. A failed correction escalates instead of opening another review budget. - -Compact authority uses content-derived CAS under the Git common directory. Exact retries are idempotent; stale/semantic retries, terminal mutation, and same-lineage graph-v1/compact-v2 ambiguity fail closed. - -Trust boundary: The local orchestrator and same-user process are trusted to execute selected actors and submit their exact outputs. Native code owns scope, risk, IDs, canonicalization, state, receipts, and gates, and rejects malformed or inconsistent results structurally and causally. Malicious same-user host/process authenticity is a non-goal because that actor can replace the extension or mutate local authority; externally trusted attestation would require a separately privileged signer/service and is not claimed. - -Ordinary ends only as `approved` or `escalated`. +
-Judgment Day starts only when explicitly requested and replaces ordinary review for that lineage. +Built for Pi. Shaped by Gentle-AI. -Judgment Day starts with exactly two blind judges and zero refuters. - -Judgment Day alone may iterate discovery and scoped re-judgment, for at most two rounds. - -Findings surviving round two escalate; no third-round transition exists. - -Native review mode and the two candidate choices remain provider-owned lifecycle semantics. For a validated `consent/v3` envelope in the interactive parent TUI, Pi displays those two choices unchanged and adds a clearly separate host-owned action: **Run this review and allow reviews for this Pi session**. Only direct human selection creates this process-memory grant. Its scope is the coordinating live SessionManager session and the canonical Git common-directory identity of the selected repository: it runs the current envelope's exact provider `granted` invocation through the existing one-shot `answer-consent` path, then does the same for later fresh validated envelopes in sibling worktrees of that same clone, including package-owned children. An unrelated repository requires a separate explicit human grant. Reload preserves it; `/tree` retains it; revoke removes the current repository grant; quit, new, resume, fork, or process restart removes all session grants. The command's `status` action reports the in-memory state without changing provider mode or authority. - -The host grant is held only in a schema-checked `globalThis[Symbol.for(...)]` WeakMap registry keyed by session and canonical Git common-directory digest. It is never written through session entries, settings, environment variables, or the old asked latch. A package-owned Gentle Agents child can request one bounded parent-owned stdio authorization for its own validated pending ordinary START; it sends only that target's canonical repository digest, and the parent rechecks the live task, digest, and current parent session grant before the child replays its exact provider grant locally. No candidate bytes, provider vectors, paths, local child grant, or delivery authority crosses that channel. External or legacy `pi-subagents` launchers do not receive this channel and remain unsupported. Headless/RPC/unsupported UI, external processes, model prose, tool arguments, cancellation, identity drift, malformed identity, and uncertain native results cannot create or consume the grant. Native workspace binding remains canonical and target-specific; session-wide consent never authorizes an unselected target or an unrelated repository. The grant conveys no review verdict, forecast/cost approval, acknowledgement, maintenance, delivery, or cross-repository authority. When the host cannot resolve the choice, `gentle_review` returns the original unresolved two-choice provider envelope unchanged for the normal lossless relay. SessionManager binding isolates simultaneous SDK sessions; Pi does not claim universal same-process agent-principal isolation because the SDK exposes no principal identity. - -When RDD is on and an agent loop ends with an unreviewed candidate, `gentle-pi` sends one read-only reminder pointing the agent back to `gentle_review {"operation":"inspect"}` before it reports completion. This nudge is idempotent (at most once per target identity per session), never fires for a headless session or a subagent's own loop, and never runs START or answers consent itself. Pi treats a child `agent_end` as a latest-answer update, not completion: queued retry, compaction, follow-up, required verification, and legitimate post-correction verification remain live until `agent_settled`. It does not claim ready or RDD-ready first, but this ordering rule does not impose a universal full-suite requirement or turn a receipt into a delivery gate. At session start, `gentle-pi` records the current target identity as a baseline, so a candidate that already existed before the session began (the user's own prior work, not this session's output) never draws the reminder. - -Review outcomes and receipt state are informational; commit, push, pull-request, and release delivery follow ordinary repository policy. No one-shot command authorization, publication-target revalidation, or receipt gate is required for delivery, and Pi does not inspect RDD mode or native authority to decide a Bash delivery command. - -Dangerous-command safety remains independent and authoritative. Destructive-review-maintenance consent remains separate from delivery. Review operations, informational VALIDATE, and SDD perform no commit, push, pull-request, release, or publication operation. - -The Pi host relay bounds each locked-down reviewer subprocess by materialized prompt size rather than by one fixed number: a 15-minute floor plus 15 minutes per mebibyte of prompt, clamped to a 2-hour ceiling. Set `GENTLE_PI_REVIEW_RELAY_PI_TIMEOUT_MS` to a positive decimal to replace that derived bound with your own; malformed values are ignored and the same 2-hour ceiling still applies, so no configuration turns a foreground finalize into an unbounded child process. A reviewer killed by the bound reports `pi-host-relay-timeout` with the elapsed time and the limit it was measured against, and it explicitly does not ask you to relaunch the identical slot — that would re-spend the model tokens to reach the same wall. Reviewer results admitted earlier in the same finalize stay admitted and are not re-run. - -Adversarial review roles (the refuter and the targeted validator) are never Pi-authored: the provider renders self-contained `review.capture-refuter` / `review.capture-validation` vectors and Go runs its own locked-down `pi` process on them. Package agent assets remain a package-managed isolated installation. Project and user overrides may shadow a package asset; `gentle-pi` preserves those definitions and does not claim their effective permissions are package-compliant. - -## SDD/OpenSpec flow - -```text -init - ↓ -explore → research (optional) → proposal → spec ─┬→ design ─┐ - └─────────┴→ tasks → apply → verify → sync → archive -``` - -The main loop is intentionally file-backed when you choose `openspec` or `both`: - -```text -planning artifacts implementation evidence canonical update -────────────────── ─────────────────────── ──────────────── -proposal/spec/design/tasks → apply-progress/verify-report → sync-report → archive-report -``` - -For substantial work, the parent session coordinates the flow and each phase writes artifacts. That gives you: - -- explicit requirements and non-goals; -- design decisions that survive compaction; -- task plans reviewers can reason about; -- implementation evidence; -- verification reports; -- sync reports that update canonical specs while keeping the change active; -- archive notes for future agents. - -### OpenSpec artifact model - -`gentle-pi` treats OpenSpec-compatible behavior as part of the harness. You do not need to install the external OpenSpec CLI/package for SDD. - -In file-backed modes, canonical accepted behavior lives in `openspec/specs/`, while active changes carry deltas under `openspec/changes/`: - -```text -openspec/ -├── specs/ # accepted source of truth -│ └── {domain}/spec.md -└── changes/ - ├── {change}/ # active work - │ ├── proposal.md - │ ├── specs/{domain}/spec.md # full spec or delta spec - │ ├── design.md - │ ├── tasks.md - │ ├── apply-progress.md - │ ├── verify-report.md - │ └── sync-report.md - └── archive/YYYY-MM-DD-{change}/ # immutable audit trail -``` - -Delta flow: - -```text -openspec/changes/{change}/specs/{domain}/spec.md - │ - │ sdd-sync applies ADDED / MODIFIED / REMOVED - ▼ -openspec/specs/{domain}/spec.md - │ - │ sdd-archive moves the completed change folder - ▼ -openspec/changes/archive/YYYY-MM-DD-{change}/ -``` - -When a canonical spec already exists, change specs use requirement operation sections: - -```markdown -## ADDED Requirements - -## MODIFIED Requirements - -## REMOVED Requirements -``` - -`MODIFIED` requirements must include the full requirement block, including still-valid scenarios, because sync replaces the canonical block by requirement name. `sdd-sync` syncs file-backed deltas into `openspec/specs/{domain}/spec.md` while keeping the change active; `sdd-archive` then moves the synced change to `openspec/changes/archive/YYYY-MM-DD-{change}/`. - -Engram-only mode is different by design: Engram is working memory and does not maintain a canonical spec merge layer. Use `openspec` or `both` (hybrid file + memory persistence) when you need canonical spec evolution. + -## SDD preflight and project files +

+ +

-`gentle-pi` does not require SDD agents to be copied into every project. The package ensures global Pi SDD assets exist under the Pi agent home and treats project-local files only as overrides/debug copies. Slash SDD flows such as `/sdd-*`, `/gentle-sdd-init`, and the explicit `/gentle:sdd-preflight` command run a lazy preflight and resolve session-scoped SDD preferences. For natural-language requests, the parent agent decides whether the work should use SDD and must run/reuse `/gentle:sdd-preflight` before continuing. +## Features -```text -~/.pi/agent/agents/sdd-*.md -~/.pi/agent/chains/sdd-*.chain.md -~/.pi/agent/gentle-ai/support/strict-tdd*.md -``` +--- -Preflight values resolve in this order: explicit current user/session choice, valid persisted preference, capability or already-selected strategy constraint, canonical default, then a prompt only when genuinely unresolved. Resolved values are reused for later SDD flows in the session. +### gentle-shell — Your coding agent, in the workspace you lead -Canonical values are `auto` execution mode, `openspec` artifact store, `ask-on-risk` delivery strategy, and a `400` changed-line review threshold. The delivery strategy domain is `ask-on-risk`, `auto-chain`, `single-pr`, or `exception-ok`; `chain_strategy` remains deferred until chaining is selected. `exception-ok` requires explicit `size:exception` acceptance and is never inferred. Consent, authorization, security, destructive/publishing, interactive phase approval, and ambiguous-scope gates remain human-controlled. +gentle-shell running a live agent session: a header row with branch, model, and context gauge above the transcript, with status, changes, and todo cards in the right rail -It does **not** overwrite existing global assets unless you explicitly run: +A bare terminal answers "what is the agent doing?" only with scrollback. gentle-shell turns your Pi session into a workspace: agent orchestration, live changes and runtime status, usage monitoring for supported provider accounts, and built-in diff views — so you lead the work instead of chasing it. -```text -/gentle:install-sdd --force -``` +

gentle-shell in action. Screenshot from Gentle-AI.

-Manual preflight command: +**[Docs →](docs/gentle-shell.md)** -```text -/gentle:sdd-preflight -``` +--- -## Skill registry +### el Gentleman — Think before you build -`gentle-pi` keeps a local registry at: +Say what you need once, then keep moving. el Gentleman helps turn intent into clear scope, a sensible next step, and evidence people can review — without making every task feel like a process meeting. -```text -.atl/skill-registry.md -``` +**[Docs →](docs/readme-reference.md#organic-driven-development)** -The registry scans project and user skill roots, not package-owned skills. It exists to catch workflow skills that are present on disk but not visible in Pi's injected skill list. +--- -It scans common roots such as: +### Focused agents — Context with a return path -```text -./skills -.opencode/skills -.claude/skills -.gemini/skills -.cursor/skills -.github/skills -.codex/skills -.qwen/skills -.kiro/skills -.openclaw/skills -.pi/skills -.agent/skills -.agents/skills -.atl/skills -~/.pi/agent/skills -~/.config/agents/skills -~/.agents/skills -~/.kimi/skills -~/.config/opencode/skills -~/.config/kilo/skills -~/.claude/skills -~/.gemini/skills -~/.gemini/antigravity/skills -~/.cursor/skills -~/.copilot/skills -~/.codex/skills -~/.codeium/windsurf/skills -~/.qwen/skills -~/.kiro/skills -~/.openclaw/skills -``` +Diagram of one parent session directing bounded map, implementation, and verification work and receiving evidence back -Behavior: +Bring in help without losing the thread. Focused package-owned Pi agents can map a codebase, implement a bounded change, or verify it, while one parent stays accountable for the scope, the decisions, and the final summary. -- `.atl/` is added to `.gitignore` when needed; -- the registry refreshes on session start; -- startup refresh is skipped when Pi starts with `--no-skills` / `-ns`, `--no-skill-registry`, or `GENTLE_PI_NO_SKILL_REGISTRY=1`; -- `/skill-registry:refresh` forces regeneration; -- a best-effort watcher refreshes when skill files change; -- the registry indexes skill names, full descriptions, scope, and exact `SKILL.md` paths without copying skill body rules. +**[Docs →](docs/readme-reference.md#how-the-harness-decides-what-to-do)** -Skill discovery is a guardrail, not a workflow router: it helps Pi load the right skill without forcing extra ceremony. +--- -`gentle-pi` also ships package-owned `gentle-ai-skill-creator` and `gentle-ai-skill-improver` skills plus the `/skill-creation` prompt for creating or updating project skills. Both skills use `docs/skill-style-guide.md` as their normative style contract. The workflow checks for duplicates, keeps `SKILL.md` concise, uses one-line trigger-rich frontmatter, and reminds maintainers to refresh the registry after skill changes. +### ODD — The everyday workflow -Packaged skills include `cognitive-doc-design`, `comment-writer`, `gentle-ai-judgment-day`, `gentle-ai-skill-creator`, `gentle-ai-skill-improver`, and the other delivery/review skills under `skills/`. SDD init is installed as the packaged `sdd-init` runtime agent under `assets/agents/` and refreshed with the SDD assets. +Organic Driven Development as seven numbered steps: Authorize, Explore, Resolve uncertainty, and Classify across the top row; Classify forks, so small understood work stays light while substantial work gets step five, Track, with one feature document; both paths converge on Implement task by task and then Close, above a dashed band marking that one feature document mirrored in Engram lets work resume across sessions -Compatibility: the package keeps the existing skill folders (`skills/branch-pr`, `skills/cognitive-doc-design`, `skills/comment-writer`, `skills/judgment-day`, `skills/skill-creator`, `skills/skill-registry`, and `skills/work-unit-commits`) but their exported frontmatter names are prefixed to avoid collisions with user/global skills. Treat former package names such as `branch-pr`, `cognitive-doc-design`, `comment-writer`, `judgment-day`, `skill-creator`, `skill-registry`, and `work-unit-commits` as legacy aliases in prose; runtime skill selection should use `gentle-ai-branch-pr`, `gentle-ai-cognitive-doc-design`, `gentle-ai-comment-writer`, `gentle-ai-judgment-day`, `gentle-ai-skill-creator`, `gentle-ai-skill-registry`, and `gentle-ai-work-unit-commits`. +**Organic Driven Development (ODD)** is the everyday path: the agent explores before changing anything, clarifies only real decisions, and keeps small understood work small. Substantial, authorized work gets one recoverable feature document — mirrored in memory when available — so progress, evidence, and the next step survive an interruption; checks follow the configured TDD mode. -Delegation contract: +**[Docs →](docs/readme-reference.md#organic-driven-development)** -- parent/orchestrator resolves project/user skills from the registry and passes matching paths under `## Skills to load before work`; -- SDD subagents still use their assigned executor/phase skill; -- during normal runtime, subagents should not independently discover additional project/user `SKILL.md` files or the registry; -- fallback loading is degraded self-healing and must be reported via `skill_resolution` as `fallback-registry`, `fallback-path`, or `none`. +--- -## Persona modes +### Native review — Review the exact change -```text -/gentle:persona -``` +Diagram showing one frozen candidate passing through risk-scoped native review to an outcome, while human delivery choices stay separate -| Persona | Behavior | -| ----------- | ------------------------------------------------------------------------------------------------------------- | -| `gentleman` | Senior architect, teacher, direct technical feedback, Rioplatense Spanish/voseo when the user writes Spanish. | -| `neutral` | Same discipline, warmer professional language, no regional expression. | +Review the exact change, not a moving target. Native review keeps one candidate in view, returns risk-scoped evidence, and can surface a bounded correction path. You still decide what happens next in your repository. -Saved globally at: +**[Docs →](docs/review-integration.md)** -```text -~/.pi/gentle-ai/persona.json -``` +--- -A project can still override the global default with: +### Gentle Changes — Every edit, attributed and reviewable -```text -.pi/gentle-ai/persona.json -``` +Gentle Changes viewer: worktree accordion with per-file status on the left, the captured diff with line counts on the right, and a keyboard hint row -`/gentle:persona` writes the global config and updates an existing project override when one is present, so the current project does not stay stale. Run `/reload` or start a new Pi session after switching persona. +You should not have to run `git status` to find out what your agent did. Gentle Changes captures the successful write and edit tool calls from the current session and its owned subagents — no repository scans, no background polling — and shows them in a two-pane viewer with per-file line counts and an honest **diff unavailable** when an external edit breaks continuity. Coverage stops at those tools, so shell commands and failed runs leave no row, and a missing entry never proves a clean tree. `alt+g` opens it; `o` drops the real file into your editor. -## Model and effort assignment +**[Docs →](docs/gentle-shell.md#browse-captured-diffs)** -```text -/gentle:models -``` +--- -The modal discovers: +### Gentle Agents — Parallel work with a live view -- project agents in `.pi/subagents/`, `.pi/agents/`, and `.agents/`; -- user agents in `~/.pi/agent/subagents/`, `~/.pi/agent/agents/`, and `~/.agents/`. +Gentle Agents overlay showing a completed subagent thread with model, tokens, and elapsed columns, and the structured handoff it returned -When applying routing, project agents write runtime profiles to `.pi/subagents.json`; global and built-in agents write profiles to `~/.pi/agent/subagents.json`. +Delegating work should not mean losing it. Every subagent runs as its own process with a live card above the editor — model, tokens, cost, elapsed — and `alt+a` opens the full view with retained threads, stop controls, and history restored on resume. A child can ask you a question as an ordinary dialog, and background results come back as cards that start a new turn — nothing polls. -Recommended model/effort shape: +**[Docs →](docs/gentle-shell.md#gentle-agents)** -| Agent kind | Recommended model | Recommended effort (`thinking`) | -| -------------------------- | ---------------------------------------------------- | ------------------------------- | -| Explore, proposal, archive | Fast and cheap is usually enough. | `off` to `low` | -| Spec, design, tasks | Strong reasoning model. | `medium` to `high` | -| Apply | Strong coding and tool-use model. | `medium` to `high` | -| Verify / review | Strong fresh-context model. | `high` | -| Tiny utilities | Inherit active/default model unless they bottleneck. | `inherit` | +--- -Saved globally at: +### Profiles and model routing — One deliberate decision per knob -```text -~/.pi/gentle-ai/models.json -``` +Profiles view: profile list on the left, orchestrator model and effort on the right, with per-role profile routing and effective current routing -Existing project-local `.pi/gentle-ai/models.json` files are still read as a legacy fallback when no global model config exists, but `/gentle:models` writes the shared global config. +Model, effort, and who does what should be choices, not accidents. Named profiles route the orchestrator atomically and independently from packaged and review roles; a repository can pin its profile so its subagents stop following the globally active one, and the panel always shows the routing the runtime actually uses. -Inside `/gentle:models`, press `x` to export the saved routing to `~/.pi/gentle-ai/models.export.json`, or `r` to restore from that file after confirmation. Export uses a versioned envelope and restore writes the normal `models.json` shape before applying routing to agents. +**[Docs →](docs/readme-reference.md#agent-model-profiles)** -Config shape (per agent): +--- -```json -{ - "sdd-design": { - "model": "anthropic/claude-sonnet-4", - "thinking": "high" - }, - "sdd-archive": { - "model": "openai/gpt-5-mini" - } -} -``` +### Command palette — Every command, one keystroke away -Legacy string entries are still accepted and treated as `model`-only config. +Extension commands are only useful if you can find them. `alt+k` opens a curated, grouped palette — Configuration, Session, Diagnostics, and Skills — searchable by label, command name, or description, showing entries only when they are actually registered. -## Gentle Shell +**[Docs →](docs/gentle-shell.md#command-palette)** -Gentle Shell is the visual layer gentle-pi puts on top of pi. It follows the Gentle themes: one border language, champagne titles, rose for whatever is alive. +--- -In fullscreen at 140 columns or wider, the right sidebar scrolls **✿ Gentle-Pi ✿ → Status → Changes → Agents → TODO** together. The one-line heading is horizontally centered within the usable rail width, with pink flowers and normal white text in the Gentleman themes. Colors follow the active theme; no artwork scaling or custom fonts are used. Narrow/mobile terminals and regular mode retain bottom widgets without the sidebar heading. The original rose and text logo remain in the main chat startup intro. +### Also in the box -The status bar replaces pi's three-line footer with a single line of segments: +| Component | What it does | +| :--- | :--- | +| Startup and runtime panel | A configurable gentle-shell entry point and visible runtime state for Pi. | +| Skills and delivery guidance | Package skills for documentation, issue work, PRs, reviews, and reviewable work units. | +| Model, effort, persona, and profile controls | Explicit knobs for how Pi routes and presents work. | +| Safety boundaries | Guards around destructive operations and sensitive-path handling. | +| Optional companion packages | Extra capabilities you may choose to add; persistent memory is **not** bundled with `gentle-pi`. | +| Fullscreen workspace layout | Header row plus a scrolling Status → Changes → TODO rail on wide terminals. | +| Live status bar and prompt petal | One-line gauge, cost, and statuses; the petal shows `working` and `queued`. | +| Parent ↔ subagent communication | Delegate, steer, reply, and cross-session notification within your local profile. | +| Native interactive tools | Built-in questions, choices, and review captures — no third-party dependency. | +| Gentle Todo | A plan card that turns amber when the model lets it go stale. | +| Subscription usage | Per-window meters and resets for supported provider accounts. | +| Gentle notices | Gentle AI calls and review reminders as cards in the transcript. | -```text -✿ gentle-pi ⟡ ~/work/gentle-pi main ⟡ gpt-5.5 · medium ⟡ ctx ▰▰▰▰▱▱▱▱ 45% ⟡ $9.49 sub ⟡ MCP: 3 servers enabled Release notes -``` +> **Every component, skill and preset: [Full breakdown →](docs/gentle-shell.md)** -- Context is a gauge, not a number. It turns amber at 80% and red at 95%; after compaction it shows `?%` until the next response. -- Cost carries `sub` when the active model runs on a subscription login. -- Statuses other extensions publish through `setStatus` are appended as trailing segments; the session name sits at the right edge. -- On narrow terminals the session name is dropped first, then trailing segments, before the line is truncated. +--- -The prompt wraps pi's editor in a rounded frame with a petal that shows what the agent is doing: +### What's new in v3.5 -```text -╭─ ✿ working ──────────────────────────────────────────╮ -│ type, or / for commands │ -╰──────────────────────────────────────────────────────╯ -``` +The [v3.5.1 release](https://github.com/Gentleman-Programming/gentle-shell/releases/tag/v3.5.1) makes Gentle Shell runnable on its own: -- The petal is still while pi waits, spins with a `working` label while the agent works, and turns amber with a `queued` label when messages are waiting behind the current turn. pi's own "Working" row above the editor is hidden, since the frame already says it. -- The frame uses the theme's border color over the panel background, so the prompt reads as one panel with the cards around it; the editor's scroll indicators stay inside the frame. -- The hint appears only while the editor is empty. -- If another extension already installed a custom editor, Gentle Shell leaves it alone. +- **Standalone launcher:** `npm i -g gentle-pi` installs `gentle-shell`, which opens Pi with the Gentle Shell package loaded from its own home (`~/.gentle-shell/agent`) or, with `--link`, from your existing `~/.pi/agent`; `gentle-shell install npm:` and the other pi subcommands run against the selected home. A bundled or `PATH` pi is used, never a modified one. +- **Link mode take-over:** when `~/.pi/agent` already declares gentle-pi as a path package, the launcher takes over extension loading (`--no-extensions` plus explicit `-e` for every other declared package and loose extension) so tools never register twice. +- **Interactive RPC hosts:** with `GENTLE_SHELL_INTERACTIVE_HOST=1` and `--mode rpc`, ask-user tools use pi's RPC dialogs and gentle-agents publishes live subagent activity for the desktop app. See the [reference](docs/readme-reference.md#interactive-rpc-hosts). -Changes across this session's registered worktrees show up below the editor and as an aggregate `±N` next to the session branch in the bar: +--- -```text -✎ 3 files · +42 −7 · extensions/gentle-shell.ts, lib/shell-bar.ts, tests/x.test.ts · /gentle:changes -``` +

Back to top ↑

-- Each registered root shows **all** dirty files: plain `git diff` against HEAD plus untracked files, including edits that predate this session. There are no baselines or file-level attribution filters. -- The canonical session cwd root is included automatically. Successful standard `read`, `write`, `edit`, `grep`, `find`, and `ls` calls register their target worktree after completion. Failed calls, shell command text, and prose never register roots. Only roots sharing the session's Git common directory are accepted. -- For opaque shell use or worktrees used earlier, call `session_worktree_register` with `{"path":"/path/to/worktree"}`. Registration is explicit, canonicalized, and deduplicated; unrelated dirty siblings remain invisible without an ignored-roots list. -- The root registry persists in Pi custom entries (`gentle-pi.session-worktree/v1`). Exit/resume and `/reload` restore the same session UUID; `/tree` keeps roots session-wide. New sessions, `/fork`, and `/clone` ignore inherited registrations with another UUID. Clean roots stay registered but hidden until dirty; missing/prunable roots are skipped safely. Ephemeral `--no-session` runs cannot persist across exit. -- Counts refresh after every tool call, at the end of each turn, and every 5 seconds in the background, so edits made from nvim or another agent show up without touching pi. `GENTLE_PI_SHELL_CHANGES_WATCH_MS` changes the interval; `off` leaves only the tool-driven refresh. Outside a git repository the widget stays hidden. -- On narrow terminals the file list is dropped before the summary is truncated. +

+ +

-`/gentle:changes` or `alt+g` opens the framed two-pane viewer. Dirty worktrees are accordion groups in the left pane, labeled with branch and directory basename (`detached` when there is no branch). Expand groups to reveal indented changed files; multiple groups can stay expanded. The right pane previews the selected file's lazy-loaded diff, or shows the selected group's full directory and summary. Clean, bare, missing, and prunable roots remain hidden; untracked-only roots are included. +## Get started -- `j`/`k` or up/down traverse visible groups and files, keeping the selection in view. On a group, `enter`, space, or right arrow toggles expansion. Left arrow or backspace moves a file selection to its parent, or collapses the selected group. `ctrl+j`/`ctrl+k` or `pgdn`/`pgup` scroll the diff; `esc` or `q` closes the overlay. -- Opening, pressing `r`, and the background/overlay refresh cadence scan only registered roots. Worktree discovery supplies branch labels, never registration. No changes in registered roots means no widget and an informational notice instead of an overlay. -- While the overlay is open, git is polled every 2 seconds, so edits made from nvim, another agent, or a checkout show up in place. Expansion and selection stick to the raw worktree root and file path across refreshes; a diff reloads when its counts move. -- `GENTLE_PI_SHELL_CHANGES_KEY` rebinds the shortcut (pi key syntax, for example `ctrl+shift+g`); `off` disables it. On macOS, `alt+g` needs the terminal to send Option as Meta. -- On a file row, `o` (or `enter`) opens the selected file in `$VISUAL` or `$EDITOR`, with the selected worktree as the editor's working directory, and returns to pi when the editor exits. Diff lookup and caches are also scoped to that root; identical relative filenames in other worktrees cannot share a diff. -- Untracked files are diffed against an empty file so new files show their full content. +> **Naming transition:** The product is called `gentle-shell`; the current npm package and repository remain `gentle-pi` until migration. -Subscription usage shows in the bar after the cost, and `/gentle:usage` opens a panel with every window per provider: +### Path A: standalone `gentle-shell` (recommended, no pi changes) -```text -✿ gentle-pi ⟡ … ⟡ $9.49 sub ⟡ codex 5h ▰▰▰▰▰▱▱▱ 62% · week 31% -``` +`gentle-shell` opens Pi with the Gentle Shell package loaded, without installing it into your pi agent or editing its `settings.json`. -- For Codex, usage comes from the same account usage endpoint the Codex CLI reads, using the OAuth token pi already holds. It is fetched at session start, at most every 5 minutes after a turn, and on `r` in the panel. Rate-limit headers on SSE responses are picked up too. -- For Claude Pro/Max, usage arrives in the rate-limit headers of every response, so the 5h and weekly windows appear after the first turn. -- The bar names the subscription it shows (`codex`, `claude`) and always follows the active model. The panel puts the active provider first, marked with the petal, and says why it has no data when it does not: API-key providers have no subscription windows, Claude reports after the first response, Codex waits for a fetch. -- Only the plan name and the windows are kept; account details in the payload are discarded. -- Gauges turn amber at 80% and red at 95%, like the context gauge. +```bash +npm i -g gentle-pi -Gentle notices are drawn as cards: the same rounded frame as the prompt, with the left rail and the title in the tone of the notice and the rest of the frame in the theme's border color. +# Own home, never touches your pi install +gentle-shell -```text -╭─ ✿ Gentle AI · review preflight ─────────────────────────────────────╮ -│ Receipt-driven development is enabled, and this worktree holds an… │ -╰──────────────────────────────────────────────────────────────────────╯ +# Reuse your pi sign-ins, models and chats instead +gentle-shell --link ``` -- Every call into the gentle-ai binary and every `gentle_review` tool renders as a card under the rose, `🌹︎ Gentle AI`: the rail is amber while it runs, green when it finished, red when it failed; the expand key sits in the top rule once the tool finished, and the collapsed result shows only its line count. Reviewer captures name their lens (`review capture · risk`; the group lists all four). -- The review preflight reminder renders as a card in the transcript with the expand key in its top rule. -- An active dev-binary override shows above the editor at startup, in amber, naming the binary and its digest, and leaves with the first prompt; an invalid override shows in red with the reason. -- Subagents draw their own card; see Gentle Agents below. - -### Gentle Agents - -The current package requires Pi 0.85.1 or newer (development tests pin 0.85.1). Use the latest Pi release; gentle-pi does not update your installed Pi automatically. Children, including any `GENTLE_PI_AGENTS_PI` override, must emit `agent_settled`: `agent_end` records a run's output but is not completion because retries or queued continuations may follow. - -The `subagent_*` tools and the agents card replace the third-party subagents package (remove `npm:pi-subagents-j0k3r` from your pi packages; while it is still installed the tools stay unregistered and a warning says so at startup). Agent definitions and settings are the ones you already have: markdown agents in `~/.pi/agent/agents/`, `~/.pi/agent/subagents/`, `/.pi/agents/`, `/.pi/subagents/` (project beats global, `subagents/` beats `agents/`), and `subagents.json` at the global and project level (`default_model`, `default_effort`, `default_mode`, `model_profiles`, `stall_timeout_ms`, `max_concurrency`, `history_max_tasks`). - -Agent paths follow `GENTLE_PI_AGENT_HOME`, then `PI_CODING_AGENT_DIR`, then `~/.pi/agent` for definitions, config, history, child sessions, and transcripts. These overrides select the agent profile; they do not sandbox project or shared global resources. +`gentle-shell` alone starts in its own home, `~/.gentle-shell/agent`, and sets that home up on first run — no separate step. Gentle Shell keeps its own home with the Gentle AI companion packages and no conflicting plugins; gentle-pi itself always stays this launcher's own copy, never one installed into the home; your pi install is untouched. That home also defaults to the Gentleman-Cute theme unless you set your own. `gentle-shell --link` reuses `~/.pi/agent` as-is, is never auto-provisioned, and never has its theme touched. -```text -╭─ ❀ Agents · 1 active · 1 done ─────────────────────────────── 1m24s ╮ -│ ✓ sdd-explore map footer data sources gpt-5.6-terra · 34k · $0.27 · 25s │ -│ ◐ sdd-apply write gentle-shell footer gpt-5.6-terra · 12k · $0.09 · 41s │ -╰──────────────────────────────────────────────────────────────────────────────╯ +```bash +# Re-run provisioning by hand, e.g. to see the full install output +gentle-shell setup ``` -Every subagent is its own `pi --mode rpc` child process, so the terminal never runs subagent work: the host reads JSON lines, applies each one as a small delta to a bounded per-task thread, and notifies only the listeners of that task. A task-mode child's question (`ctx.ui.select`, `confirm`, `input`, `editor`) reaches you as an ordinary pi dialog; a background child's question is dismissed. Subagents have no automatic total execution timeout: a long-running child remains live while it continues emitting RPC events. A silent child still times out through the configurable `stall_timeout_ms` watchdog (default four minutes). Closing pi stops the children that are still running. - -- `subagent_list_agents`, `subagent_run` (`agent`, `task`, `label?`, `context?`, `workspace_root?`, `mode?` task or background), `subagent_status`, `subagent_result`, `subagent_list_tasks`, `subagent_reply` (one current-session reply to a live child query), `subagent_cancel`, `subagent_send_message` (steer a running child), `subagent_continue` (resume a finished task in its own session). -- `subagent_run.workspace_root` selects an existing worktree in the session's Git clone. Validation happens before queueing; the child runs at that canonical root. Successful OS spawn registers the root in the originating parent session, including delayed queued launches, even without an active shell listener. Failed spawns do not register. `subagent_continue` retains the previous task's cwd; status and task details expose it. -- A background task's result comes back to the model as a `gentle-agents.result` message, drawn as a rose card, and starts a new turn when the agent is idle; the model never polls. -- A configured child can call `subagent_parent_message` with bounded, well-formed Unicode text. Notifications retain their existing admission semantics. A `kind: "query"` waits for one strictly correlated `subagent_reply` for at most 30 seconds; each child has at most four pending queries, and disconnect, timeout, stop, and send failure settle each request once. The current parent session alone can reply. The first admitted task-mode query ends the original tool response while its child keeps running; its eventual non-cancelled completion returns once as a follow-up only if that same session is still active. Channel closure prevents later sends and automatic retry is not provided. Peer transport, offline delivery, retries, and broadcasts are unsupported. -- The card shows the active session's tasks only: after `/new` or `/resume` the earlier session's tasks leave it and come back with their session. Finished rows stay for one minute (three at most), and the card spends at most a quarter of the terminal (three to eight rows) on tasks; beyond that the rest fold into one `… N more · alt+a to view` line so the editor never leaves the screen. Questions and running work keep their rows first. -- `/gentle:agents` or `alt+a` opens a full-terminal overlay. At 60+ columns, the split view shows groups/tasks beside the retained semantic thread; uppercase `F` or **Fullscreen** expands that thread. At 12–59 columns, click a current subagent directly to inspect its thread; in All sessions, first select its orchestrator. `Enter`/`Tab` also enter a narrow selection. **Back** or `Escape` returns one level, closing only at the root; **Close** or `q` closes globally without cancelling children. Selection and manual thread scrolling survive Back and resize. -- Mouse controls take priority over keyboard hints: **Follow** (`f`), **Open session** (`o`), **Stop** (`s`, legacy `c`, owned active tasks only), and **Scope** (`a`). A compact footer's `>` cycles through actions. Scope switches between this session's direct active children and all open orchestrators, including idle ones. Open writes a markdown transcript for `$EDITOR`, not a resumed child session. `j`/`k` move through lists or scroll an expanded thread; `ctrl+j`/`ctrl+k` and Page Down/Up page the thread. In Pi fullscreen mode, the wheel scrolls the viewport under the pointer; regular terminal mode does not capture mouse input. Below 12 columns or three rows, only a bounded Close cell remains; zero-sized terminals render nothing. -- The thread displays all retained Text, Thinking, Note, and Tool content without an additional presentation cap; existing store limits and truncation markers still apply. Only the selected task is subscribed while the overlay is open. -- Thread entries are presented as labeled Text, Thinking, Note, or Tool blocks; tool blocks show their status and nonempty output. -- Current scope has no orchestrator wrapper and excludes every terminal task. All sessions discovers open Pi instances sharing the same agent profile, even across repositories; it does not infer open sessions from retained tasks. Directory headings support left/right and mouse expansion, and cannot stop or open a task. Peer children and their retained threads are read-only: no local stop, editor-open, or continuation routing, and no import into the local task store. -- Presence refresh is paged while the overlay is open. Graceful shutdown withdraws an instance; after abrupt closure its last heartbeat may remain visible for up to 15 seconds plus the time to complete the next directory refresh. A recent heartbeat is a heuristic, not proof that a process is alive. Same-profile, same-user processes share retained activity text; this is not an authorization channel. -- `alt+s` confirms stopping the current active or queued subagents owned by the current process. `GENTLE_PI_AGENTS_STOP_KEY` rebinds it; `off` disables it. -- Finished tasks are written to `~/.pi/agent/gentle-agents/tasks/` (one JSON per task, newest `history_max_tasks` kept, default 200) and come back on demand for `subagent_result` and `subagent_continue`, never as overlay history. Child sessions live under `~/.pi/agent/gentle-agents/sessions/`. -- `ctrl+shift+a` collapses the card to its first row (`GENTLE_PI_AGENTS_KEY`), `GENTLE_PI_AGENTS_VIEW_KEY` rebinds the overlay, `GENTLE_PI_AGENTS_PI` overrides the pi command used for children, and `GENTLE_PI_AGENTS=0` disables the tools and the card. - -### Gentle Todo +`gentle-shell setup` installs the same companion packages gentle-ai provisions into a regular Pi, into this home only, then removes the one package that conflicts with gentle-pi's own `ask_user_question` tool (gentle-ai #4820). The first `gentle-shell` launch in a home already runs this automatically; `setup` is for re-running it by hand. See **[First run](docs/readme-reference.md#first-run-in-an-isolated-or-custom-home)** for the opt-out (`GENTLE_SHELL_NO_AUTO_SETUP=1`) and failure behavior. -The `todo` tool and its card replace the third-party todo extension (remove `npm:@juicesharp/rpiv-todo` from your pi packages; sessions written by it replay into the new card). - -```text -╭─ ❀ Todos · 1 of 3 ──────────────────────────────────────╮ -│ ✓ Add quiet tool rendering │ -│ ◐ Fix quiet tools conflict · fixing conflict │ -│ ○ Show git bash tails │ -╰─────────────────────────────────────────────────────────╯ +```bash +# Make --link the default +gentle-shell home link ``` -Three things keep the list current, which a static tool description cannot: +Every other argument is forwarded to pi unchanged, for example `gentle-shell --mode rpc` or `gentle-shell -p "..."`. Full flags, env vars, and modes: **[launcher reference](docs/readme-reference.md#gentle-shell-launcher)**. -- `write` replaces the whole list in one call, so the model rewrites the plan instead of patching it; `add`, `update`, `clear`, and `list` remain for single moves. -- Every turn's system prompt carries the open tasks and the rules: in_progress before starting, done right after finishing, update before ending the turn. -- A list that goes two turns untouched while tasks stay open turns amber with `stale · N turns`, and the prompt says so, so the model brings it up to date. +### Path B: inside an existing pi -A finished list stays on screen for the turn it finished in and clears at the next. `ctrl+shift+t` collapses the card to the task in progress (`GENTLE_PI_TODO_KEY` rebinds it, `off` disables it); `GENTLE_PI_TODO=0` disables the tool and the card. +Install the stable release into an existing pi agent, restart Pi, then synchronize the installed assets. -Set `GENTLE_PI_SHELL=0` to keep pi's built-in footer and editor. - -## Commands - -| Command | What it does | -| -------------------------------- | ------------------------------------------------------------------- | -| `/gentle:status` | Shows package, SDD asset, OpenSpec, and global model config status. | -| `/gentle:doctor` | Runs read-only diagnostics for SDD assets, model/persona config, memory tools, and safety guards. | -| `/gentle:models` | Opens global model + effort assignment UI. Press `x` to export and `r` to restore saved routing. | -| `/gentle:persona` | Switches global persona mode, with project override support. | -| `/gentle:background-subagents` | Shows or sets the managed background-subagents policy (`status\|enable\|disable`), naming the source that decided it. | -| `/gentle:telemetry` | Shows or changes the local Gentle AI telemetry trigger (`status\|enable\|disable\|preview`). | -| `/gentle:banner` | Configures startup banner rose, text logo, and color preset. | -| `/gentle:toggle-rose` | Toggles the startup rose. | -| `/gentle:toggle-text-logo` | Toggles the startup text logo. | -| `/gentle:banner-color` | Selects a startup banner color preset. | -| `/gentle-sdd-init` | Initializes or refreshes `openspec/config.yaml` (openspec/both stores only). | -| `/gentle:install-sdd` | Repairs missing global SDD runtime assets without overwriting files. | -| `/gentle:install-sdd --force` | Force-refreshes installed global SDD assets. | -| `/skill-registry:refresh` | Regenerates `.atl/skill-registry.md`. | -| `/skill-creation` | Creates or updates an LLM-first skill using the packaged `gentle-ai-skill-creator` contract and style guide. | - -Package-owned global SDD runtime assets are also refreshed automatically on session start when `gentle-pi` changes. Project-local `.pi/agents` and `.pi/chains` remain manual overrides and are never overwritten by startup refresh. - -### Background subagents policy +```bash +# Published stable release: v3.5.1 +pi install npm:gentle-pi@3.5.1 -Background delegation is off unless you turn it on. The policy is user-owned: only an explicit `/gentle:background-subagents enable` or `disable` writes it, and Pi automation never toggles it. +# Restart Pi, then run: +gentle-ai sync -```text -/gentle:background-subagents Report the effective policy, the deciding source, and the resolved capability. -/gentle:background-subagents enable Write "on" to the global file. -/gentle:background-subagents disable Write "off" to the global file. +# Start Pi in your project +pi ``` -Four sources can decide the policy, and the first hit wins: - -| Priority | Source | Notes | -| -------- | ------------------------------------------------- | ------------------------------------------------------------ | -| 1 | `/.pi/gentle-ai/background-subagents.json` | Project file. Outranks everything, including a global write. | -| 2 | `/background-subagents.json` | Global file, written by `enable`/`disable`. `configHome` honors `GENTLE_PI_CONFIG_HOME` and defaults to `~/.pi/gentle-ai`. | -| 3 | `GENTLE_PI_BACKGROUND_SUBAGENTS` | Exactly `on` or `off`. Any other value is ignored. | -| 4 | Built-in default | `off`. | - -Both files use the strict shape `{"schema":"gentle-pi.background-subagents/v1","policy":"on"}`. A file that is present but malformed fails closed to `off` and is **not** skipped in favor of a lower-priority source, so a typo in the project file disables background subagents rather than silently handing the decision to the global file. The command reports that case as a warning instead of an ordinary `off`. - -Because the project file outranks the global one, `enable` still writes the global file but reports plainly when a project file keeps the effective policy unchanged. The resolved capability (`ready` or `absent`) reports whether `subagent_run` is actually callable in this session; a policy of `on` with capability `absent` means Gentle Agents is disabled or the retired subagents package is still installed. - -Startup banner settings remain global in `banner.json` under `GENTLE_PI_CONFIG_HOME` (default `~/.pi/gentle-ai`). Existing `showRose` and `showTextLogo` opt-outs independently control the main startup artwork; both default to enabled. Changes apply on the next session or `/reload`. Color presets are `pink` (default), `cyan`, `yellow`, and `green`. The static sidebar heading is independent of these preferences and follows the active theme. - -Startup flag: +See the [v3.5.1 release notes](https://github.com/Gentleman-Programming/gentle-shell/releases/tag/v3.5.1) for version-specific changes. ```text -pi --no-skill-registry -``` - -Use it when you want skills available normally but do not want Gentle AI to refresh/watch `.atl/skill-registry.md` on startup. `pi -ns` / `pi --no-skills` also skip the registry startup work because Pi is already disabling skill loading. - -## Included skills - -- `gentle-ai` — harness discipline for controlled Pi work. -- `gentle-ai-branch-pr` — issue-first PR preparation. -- `gentle-ai-chained-pr` — split oversized changes into reviewable PR chains. -- `work-unit-commits` — commits as reviewable work units. -- `gentle-ai-judgment-day` — blind dual review, fixes, and re-judgment. -- `cognitive-doc-design` — documentation that reduces cognitive load. -- `comment-writer` — concise, warm, postable collaboration comments. -- `gentle-ai-issue-creation` — issue workflow with checks before creation. -- `gentle-ai-skill-creator` — create LLM-first skills with valid frontmatter. -- `gentle-ai-skill-improver` — audit and upgrade existing LLM-first skills. - -## Memory - -`gentle-pi` does **not** provide persistent memory by itself. - -For memory, install the companion package: - -```bash -pi install npm:gentle-engram +/gentle:status +/gentle:doctor ``` -When memory tools are actually active, el Gentleman can save decisions, bug fixes, discoveries, user prompts, and session summaries across Pi sessions. +> **RDD is opt-in:** enable native receipt-driven development only through an explicit `/gentle:review-mode enable` decision. -Memory contract for SDD delegation: +> **Fullscreen installation note:** a recognized global installation persists Pi’s `"tuiMode": "fullscreen"` setting. Project-local and other install paths do not receive that change. -- parent/orchestrator owns memory retrieval and passes selected context into subagent prompts; -- subagents should not independently search memory during normal runtime unless explicitly instructed to retrieve a specific artifact or observation; -- subagents should save significant discoveries, decisions, bug fixes, and completed SDD phase artifacts before returning when memory tools are available; -- in memory/hybrid mode, SDD artifacts use stable topic keys such as `sdd//proposal`, `sdd//spec`, `sdd//design`, `sdd//tasks`, `sdd//apply-progress`, and `sdd//verify-report`. +> **Interactive RPC hosts:** the desktop app sets `GENTLE_SHELL_INTERACTIVE_HOST=1` automatically, without touching your Pi config — see the [installation reference](docs/readme-reference.md#interactive-rpc-hosts). -## Telemetry +For prerequisites, source-checkout instructions, full install behavior, and release policy, use the **[installation reference](docs/readme-reference.md#install)**. For everyday work, describe the outcome and follow [ODD](#odd--the-everyday-workflow). -`gentle-pi` does not collect anything itself. [gentle-ai](https://github.com/Gentleman-Programming/gentle-ai) owns anonymous usage telemetry end to end — install and heartbeat events, what fields are sent, rate limiting, and every opt-out. See its README/docs for the exact contract. +

Back to top ↑

-At session start, for a primary session only (never for a named or SDD sub-agent), Gentle Pi asks the local `gentle-ai` binary to send its own telemetry: it spawns `gentle-ai telemetry trigger --json` detached, with a 3 s deadline, discards its output, and never blocks session start or surfaces an error — an older binary without the verb is silently treated as nothing to do. This runs at most once per process. +

+ +

-Install counts for `gentle-pi` and `gentle-engram` come from npm download statistics; the package itself never emits an install event. +## Documentation -To opt out: +Start with the product-facing destination, then move into the operational reference only when you need the details. -- `/gentle:telemetry disable` — asks the local `gentle-ai` binary to disable telemetry (also `status` and `preview` to inspect it without leaving Pi). -- `DO_NOT_TRACK=1` — Gentle Pi itself will not spawn the trigger, and `gentle-ai` also honors this standard on its own. -- `GENTLE_AI_TELEMETRY=0` — same effect, `gentle-ai`'s own environment switch. - -`CI=true` also suppresses the trigger, since automated runs are not a real usage signal. - -## Package contents - -| Path | Purpose | -| ------------------------------ | ---------------------------------------------------------------------------------------------------------- | -| `extensions/gentle-ai.ts` | Injects identity, orchestrates native review authority, refreshes global SDD assets, registers commands, applies model/persona config, and enforces runtime safety. | -| `lib/native-review-cli.ts` | Strict package-local adapter for Gentle AI START, FINALIZE, VALIDATE, SDD binding, and status contracts. | -| `lib/review-integration-v2.ts` | Strict consumer decoder for negotiated capabilities, operations, target status, projections, repair, and failures against contract `review-integration/v2` (active today). | -| `lib/review-candidate-view.ts` | Builds immutable changed-scope actor views while preserving full-tree, path, mode, symlink, and index integrity. | -| `lib/review-canonical.ts` | Permanent Pi-owned canonical JSON and domain-hash primitives for consumer-side identities. | -| `lib/review-repository.ts` | Permanent Pi-owned Git common-directory identity, safe Git environment, and authority-root binding. | -| `lib/gentle-ai-binary.ts` | Resolves and verifies the confined package-local Gentle AI runtime without global or PATH fallback. | -| `scripts/gentle-ai-installer.mjs` | Installs signed Darwin/Linux archives or exact Go SumDB-verified Windows source builds into the package-local runtime. | -| `contracts/review-integration/v1/` | Byte-identical provider schemas and conformance fixtures for contract `review-integration/v1`, hash-checked before packaging; retained on disk permanently because `/v2`'s schemas `$ref` into these fragments. | -| `contracts/review-integration/v2/` | Byte-identical provider schemas and conformance fixtures for contract `review-integration/v2` (immutable `base_tree`/`candidate_tree`, ordered `changed_path_manifest`, no inline candidate diff), hash-checked before packaging. | -| `extensions/startup-banner.ts` | Shows and configures the startup intro, color presets, and compact runtime panel. | -| `extensions/sdd-init.ts` | Registers `/gentle-sdd-init` for OpenSpec initialization. | -| `extensions/skill-registry.ts` | Maintains `.atl/skill-registry.md` from project/user skills and closes file watchers on shutdown. | -| `assets/orchestrator.md` | Parent-session orchestration contract (always-on core). | -| `assets/orchestrator-delegation.md` | Lazy-loaded delegation/routing/review detail, including the mirrored gentle-ai canon. | -| `assets/orchestrator-memory.md` | Lazy-loaded SDD memory phase table, artifact keys, and lifecycle rule. | -| `assets/orchestrator-skills.md` | Lazy-loaded skill registry fallback semantics and intent-driven skill discovery. | -| `assets/sdd-orchestrator-workflow.md` | Lazy-loaded SDD workflow surface for the parent orchestrator. | -| `assets/agents/` | SDD agents installed as global Pi runtime assets. | -| `assets/chains/` | SDD chains installed as global Pi runtime assets. | -| `assets/support/` | Strict TDD support docs for apply/verify phases. | -| `skills/` | Gentle AI delivery and collaboration skills. | -| `prompts/` | The `/skill-creation` prompt template. | -| `docs/skill-style-guide.md` | Normative style guide used by the packaged skill creation/improvement skills. | -| `docs/native-authority-architecture.md` | Post-U8 ownership boundary, reproducible slimming metrics, Windows evidence, exact #191 seam, and the `review-integration/v1`→`v2` migration status, including the "compact-v2" naming disambiguation. | -| `docs/review-integration.md` | Negotiated provider/consumer contract and the current Gentle Pi adoption boundary. | -| `docs/prompt-history.md` | Prompt-history slice 1: opt-in capture switch, storage layout, readers, and disable/removal semantics. | - -## Development - -Install from this repo: +| Destination | Purpose | +| --- | --- | +| [gentle-shell reference](docs/gentle-shell.md) | Workspace layout, changes, usage, agents, and todo interactions. | +| [ODD workflow](docs/readme-reference.md#organic-driven-development) · [Technical reference](docs/readme-reference.md) | Everyday work and recovery, installation, configuration, commands, and contributor detail. | +| [Review integration](docs/review-integration.md) | The provider/consumer boundary for native review. | +| [Native authority architecture](docs/native-authority-architecture.md) | Ownership boundaries and review architecture. | +| [Telemetry](docs/telemetry.md) | Approved fields and source limitations. | +| [Delegated verification](docs/delegated-verification.md) | Practical verification guidance. | +| [Skill style guide](docs/skill-style-guide.md) | The package skill contract. | -```bash -pi install . -``` +

Back to top ↑

-Validate before publishing: +

+ +

-```bash -pnpm test -bun build extensions/skill-registry.ts --target=node --format=esm --outfile=/tmp/skill-registry.js -node --experimental-strip-types --check extensions/gentle-ai.ts -node --experimental-strip-types --check extensions/sdd-init.ts -node --experimental-strip-types --check extensions/startup-banner.ts -npm pack --dry-run -``` +## Community -### Running the cross-lane battery +This project is built in public. Bring a real workflow, a sharp question, a bug report, or a small improvement that makes the next person’s work clearer. -The cross-lane battery (`tests/crosslane/cross-lane.mjs`) validates the adapter against a real `gentle-ai` binary, end to end and out of CI on purpose. The pinned decoder lane only ever sees vendored fixtures, so new envelope schemas and full controller sequencing are never driven through a live lifecycle before merge; the battery closes that gap. +

+ GitHub issues + Contributors + Gentleman Programming Discord +

-```bash -pnpm test:cross-lane # requires the dev-binary override -pnpm test:cross-lane --with-model # adds the real Go-owned pi reviewer run (model spend) -``` +

+ gentle-shell contributors +

-What it checks, against live scratch repositories: +- Open an [issue](https://github.com/Gentleman-Programming/gentle-shell/issues) with the context needed to reproduce or understand the idea. +- See the people shaping the project in the [contributors graph](https://github.com/Gentleman-Programming/gentle-shell/graphs/contributors). +- Follow [Gentleman Programming](https://github.com/Gentleman-Programming) for the wider ecosystem. -- a low-risk lifecycle: START → native-approved FINALIZE → terminal burn; the `pre-commit` gate is informational and unmanaged, not an allow decision or retained receipt; -- the medium-risk `consent/v3` granted round-trip through the direct decoder lane; -- controller sequencing: each decoded offered next step equals the native transition; correction evidence precedes Go-owned targeted validation, then native approval and terminal burn leave no retained receipt; -- the active audited abandon end to end, asserting the adapter builds the exact nine-line `gentle-ai.review-abandon-authorization/v2` discarded-work binding and the native gate commits the quarantine record; -- after a scope change, a burned approved predecessor exposes no recoverable authority; recovered-successor hydration remains covered at unit level; -- forward-decoder freshness: every live envelope captured from the binary must decode without unknown-key rejection, the early warning that gentle-ai main grew a field gentle-pi lacks; -- the default no-model lane: 13 of 14 checks pass while the real-model check is intentionally skipped; Go-owned validation uses a deterministic scratch fake `pi`, and only `--with-model` runs the real locked-down reviewer with model spend. +

Back to top ↑

-Prerequisites: +

+ +

-- A real `gentle-ai` binary selected through the dev-binary override; there is no PATH or pinned-binary fallback, and the battery refuses to run without one. Either export `GENTLE_PI_GENTLE_AI_DEV_BINARY=` for the session, or register a persistent override with `/gentle:dev-binary ` (stored at `~/.pi/gentle-ai/dev-binary.json` with schema `gentle-pi.dev-binary/v1`; the environment variable takes precedence over the registration, and the binary is re-validated and re-hashed on every resolution). Any real build works: an installed release binary or a locally built gentle-ai main. -- A Git checkout or worktree of this repository. The battery is a contributor tool wired to the repository layout and is excluded from `pnpm test` and CI by construction; run it from the repo, not from an installed Pi package. +## About the author -The battery owns one throwaway scratch root under the OS temp directory and never touches the enclosing repository. Before any review lifecycle it creates private `HOME`, XDG config/cache/data/state, temporary, and RDD state directories inside that root; it proves RDD starts `off/default`, explicitly opts in with sandbox-global RDD, and removes the complete root after the run. It never requires or changes the user's ambient RDD mode. The default run spends no model tokens; `--with-model` launches one real reviewer model run and costs model spend. +`gentle-shell` is built by [Alan Buscaglia](https://github.com/Gentleman-Programming), the maker behind Gentleman Programming. It grew from a practical belief: capable agents are more useful when the human’s intent, review load, and delivery judgment stay visible all the way through the work. -It prints one PASS/FAIL/SKIP row per check plus a note, and exits non-zero when any check fails. A check blocked by a known upstream class is reported with a `known-red` prefix instead of being hidden; it remains a failure, not a success. +Startup intro collaboration: thanks to [@aporcelli](https://github.com/aporcelli) and [`pi-gentle-startup`](https://github.com/aporcelli/pi-gentle-startup), which inspired the clean-screen startup animation, compact runtime panel, and pink visual treatment. -Running this battery against new gentle-ai builds (release candidates or main) and reporting red checks is a valuable contribution. The sibling provider-side battery lives at `scripts/cross-lane-battery.sh` in [Gentleman-Programming/gentle-ai](https://github.com/Gentleman-Programming/gentle-ai). +

+ Gentleman Programming website + Gentleman Programming YouTube + Gentleman Programming GitHub +

-Publish npm through GitHub Actions only: +

Back to top ↑

-```bash -version="$(node -p "require('./package.json').version")" -tag="v${version}" -git fetch --no-tags origin "refs/tags/${tag}" -test "$(git rev-parse 'FETCH_HEAD^{commit}')" = "$(git rev-parse "${tag}^{commit}")" -gh workflow run publish.yml \ - --repo Gentleman-Programming/gentle-pi \ - --ref main \ - -f tag="${tag}" -gh run watch --repo Gentleman-Programming/gentle-pi --exit-status -npm view gentle-pi@ version --registry=https://registry.npmjs.org/ -npm dist-tag ls gentle-pi --registry=https://registry.npmjs.org/ -``` +

+ +

-Do not run `npm publish` locally for `gentle-pi`. Dispatch the trusted workflow definition only from protected default `main` and provide its sole `tag` input. The workflow requires an exact annotated `vSemVer` tag whose peeled commit, current remote `main`, dispatch/main workflow commit, checkout, and `package.json` version are identical. It rechecks remote tag and `main` immediately before publishing through OIDC with provenance and environment protection; an advanced `main` requires a new release version, never a moved tag. +

Built with the workflow it brings to Pi.

-## Principles +

+ MIT License +

-- Human control over agent momentum. -- Concepts before code. -- Artifacts over floating chat context. -- SDD when risk justifies it. -- Strict TDD when tests exist. -- One parent orchestrator, focused subagents. -- Reviewable changes over giant diffs. +> **Trademark notice:** The gentle-shell™ and gentle-pi™ names and associated logos are trademarks of Alan Buscaglia. The MIT License applies to the code; it does not permit implying endorsement or official affiliation. See [TRADEMARKS.md](TRADEMARKS.md). From 89ac34883767a7fac330e4f6afd3abe8017ff2a3 Mon Sep 17 00:00:00 2001 From: Carolina <26188349+carolitascl@users.noreply.github.com> Date: Thu, 24 Sep 2026 21:29:12 -0300 Subject: [PATCH 5/6] fix(history): restore fail-closed tombstones and honest delete UX Review follow-up on the slice-05 PR (plus restoration of a fix clobbered by the Sept-24 merge train): - Restore the fail-closed tombstone contract from slice-2: hidden.json reads return trusted (missing/valid array) or untrusted (unreadable/ corrupt/malformed) with a recovery message naming the file; hidePrompt refuses to silently rewrite an untrusted file; drains return a blocked DrainResult with no prompts field; seed bootstrap fails closed on untrusted tombstones. - Two-step delete confirmation: the first ctrl+shift+backspace arms the selected row with scope-aware copy, the second executes, any other key disarms. The copy distinguishes deleting a stored prompt (physical store removal + tombstone) from hiding a session-derived prompt (tombstone only; transcripts are immutable). - Failure semantics made explicit: store-delete failures toast and abort before any tombstone write; hide failures toast distinctly (session path aborts; editor path reports the store row was removed while the hide failed). - docs/prompt-history.md: "Delete vs hide" section covering provenance semantics, failure behavior, and corrupt-hidden.json recovery. - Tests: restore the fail-closed hide/drain suites, adapt drain-order to the DrainResult contract, and add history-delete-confirm covering arming, provenance, and failure paths. --- docs/prompt-history.md | 30 +++ extensions/history/hide-prompts.ts | 83 +++++-- extensions/history/index.ts | 137 +++++++++-- extensions/history/selector-helpers.ts | 54 +++++ extensions/history/store.ts | 64 +++++- tests/history-delete-confirm.test.ts | 300 +++++++++++++++++++++++++ tests/history-drain-hidden.test.ts | 62 ++++- tests/history-drain-order.test.ts | 22 +- tests/history-hide-prompts.test.ts | 187 +++++++++++---- 9 files changed, 840 insertions(+), 99 deletions(-) create mode 100644 tests/history-delete-confirm.test.ts diff --git a/docs/prompt-history.md b/docs/prompt-history.md index fce302705..6a62d81e8 100644 --- a/docs/prompt-history.md +++ b/docs/prompt-history.md @@ -72,3 +72,33 @@ is not running): rm -rf ~/.pi/agent/history # whole store rm -rf ~/.pi/agent/history/projects/ # one project (see registry.json) ``` + +## Delete vs hide + +The selector's delete key (`ctrl+shift+backspace`) is a two-step +confirmation: the first press **arms** the delete for the selected row and +shows what it will do in the footer (the row highlights); the second press +executes it. Any other key or cancel disarms without deleting. + +What a delete does depends on where the prompt came from: + +- **Editor-stored prompts** (captured into the store's `.jsonl` files) are + deleted physically: every copy is removed from the store in one atomic + rewrite per affected file. +- **Session-derived prompts** (seeded from past transcripts) can only be + hidden: session transcripts are immutable, so the delete writes a + **tombstone** (`hidden.json`) that keeps the prompt out of the list. The + original stays in the transcript file. + +Both flows therefore end with a tombstone — otherwise the next merge would +re-supply the prompt from transcripts. Write failures surface an error +toast and never lie about state: a failed store delete removes nothing and +aborts ("Store delete failed; nothing was removed."), while a failed +tombstone write after a store delete leaves the store row removed but the +prompt may reappear from session transcripts. + +The tombstone file fails closed: if `hidden.json` exists but cannot be +trusted (unreadable, corrupt, wrong shape), history is blocked with a +recovery warning instead of resurfacing hidden prompts, and deletes refuse +to silently rewrite it. Recovery is explicit — restore the file or delete +it yourself (hidden prompts may then reappear). diff --git a/extensions/history/hide-prompts.ts b/extensions/history/hide-prompts.ts index 9cffd6954..6d91a57d6 100644 --- a/extensions/history/hide-prompts.ts +++ b/extensions/history/hide-prompts.ts @@ -9,6 +9,14 @@ import { promptDedupKey } from "./selector-helpers.ts"; /** Name of the tombstone file inside the injected state dir (spec C4). */ const HIDE_FILE_NAME = "hidden.json"; +/** + * Shared recovery warning for a file that exists but cannot be trusted + * (spec C4, fail-closed READ half): toast-suitable, names hidden.json, and + * gives the user the explicit restore-or-delete choice. + */ +const RECOVERY_MESSAGE = + "The prompt-history hide list (hidden.json) is corrupt or unreadable. History is blocked until you restore the file or delete it (hidden prompts may then reappear)."; + /** * Result of one tombstone write (spec C4): `written` on a successful atomic * write, or an error object carrying a short, toast-suitable reason. Never @@ -19,32 +27,64 @@ export type HideResult = | { status: "error"; message: string }; /** - * Load the tombstone key set from `stateDir/hidden.json` — the READ half of - * the hide-file contract (spec C4). Fail-open: a missing, unreadable, - * corrupt, or wrong-shaped file is an EMPTY set and the call never throws; - * a corrupt file is rewritten clean by the next hide (the WRITE half, - * `hidePrompt`, lands in WU4). Keys are `promptDedupKey` strings written by - * `hidePrompt`; foreign values are ignored, never trusted. + * Result of one tombstone read (spec C4): `trusted` keys when the file is + * missing or holds a valid array, or `untrusted` when the file exists but + * cannot be trusted. History reads FAIL CLOSED on `untrusted`: callers must + * block the drain instead of emptying the tombstone set, because hidden + * prompts may contain secrets an empty set would resurface. */ -export function loadHiddenPrompts(stateDir: string): Set { +export type HiddenRead = + | { status: "trusted"; keys: Set } + | { + status: "untrusted"; + reason: "unreadable" | "corrupt" | "malformed"; + message: string; + }; + +/** + * Read the tombstone key set from `stateDir/hidden.json` — the READ half of + * the hide-file contract (spec C4). Fail-closed for history: a file that + * exists but is unreadable, corrupt, or wrong-shaped returns `untrusted` + * with the recovery warning so callers block the drain; it never degrades + * to an empty trusted set. A MISSING file — before any deletion — is the + * safe empty case and reads `trusted` with no keys. A valid array is + * trusted; junk items inside it are ignored, never trusted. Keys are + * `promptDedupKey` strings written by `hidePrompt`; the call never throws. + */ +export function readHiddenPrompts(stateDir: string): HiddenRead { let raw: string; try { raw = fs.readFileSync(path.join(stateDir, HIDE_FILE_NAME), "utf8"); - } catch { - return new Set(); // missing or unreadable → empty tombstones + } catch (error) { + const code = (error as { code?: unknown } | null | undefined)?.code; + if (code === "ENOENT") { + // Missing before any deletion: the safe empty tombstone set. + return { status: "trusted", keys: new Set() }; + } + return { + status: "untrusted", + reason: "unreadable", + message: RECOVERY_MESSAGE, + }; } let parsed: unknown; try { parsed = JSON.parse(raw); } catch { - return new Set(); // corrupt bytes → fail-open empty + return { status: "untrusted", reason: "corrupt", message: RECOVERY_MESSAGE }; } const keys = new Set(); - if (!Array.isArray(parsed)) return keys; // wrong shape → fail-open empty + if (!Array.isArray(parsed)) { + return { + status: "untrusted", + reason: "malformed", + message: RECOVERY_MESSAGE, + }; + } for (const item of parsed) { if (typeof item === "string" && item !== "") keys.add(item); } - return keys; + return { status: "trusted", keys }; } /** @@ -52,18 +92,23 @@ export function loadHiddenPrompts(stateDir: string): Set { * WRITE half of the hide-file contract (spec C4). The key is the shared * `promptDedupKey` (byte-match normative with the merge filter — never a * re-implementation); the set compacts on write and persists as a SORTED - * array via the shared atomic tmp+rename writer. Fail-open both ways: a - * corrupt or missing file reads as empty (this clean rewrite IS the - * recovery — the corrupt contents are untrustworthy by definition) and any - * write failure returns an error object for the delete-flow toast; the + * array via the shared atomic tmp+rename writer. An untrusted existing file + * is never silently reset (a clean rewrite would clear the blocked state + * one hide later): hidePrompt refuses with the recovery warning until the + * user restores or deletes the file. A missing file is the clean baseline; + * any write failure returns an error object for the delete-flow toast; the * call never throws. */ export function hidePrompt(stateDir: string, text: string): HideResult { - const keys = loadHiddenPrompts(stateDir); - keys.add(promptDedupKey(text)); + const read = readHiddenPrompts(stateDir); + if (read.status === "untrusted") { + // Refuse without writing: never reset the untrusted state silently. + return { status: "error", message: read.message }; + } + read.keys.add(promptDedupKey(text)); const written = writeJsonAtomic( path.join(stateDir, HIDE_FILE_NAME), - [...keys].sort(), + [...read.keys].sort(), ); return written ? { status: "written" } diff --git a/extensions/history/index.ts b/extensions/history/index.ts index 16bef52a3..240a2ead7 100644 --- a/extensions/history/index.ts +++ b/extensions/history/index.ts @@ -31,6 +31,7 @@ import { ensureRegistryEntry, migrateLegacyStores, openSessionWriter, + type DrainResult, type SessionWriterState, } from "./store.ts"; import { randomUUID } from "node:crypto"; @@ -41,8 +42,11 @@ import { type PromptEntry, clampPreviewOffset, clampSelectedIndex, + deleteConfirmFooterText, + deleteConfirmNext, deletionActionsFor, dedupePromptEntries, + EDITOR_HIDE_FAILED_TEXT, getVisiblePromptRecords, initialLoadedCount, loadedCountAfterDelete, @@ -52,6 +56,7 @@ import { nextLoadedCount, pageSelectedIndex, shouldGrowWindow, + STORE_DELETE_FAILED_TEXT, withExpandedHistoryGlobals, type PiHistoryGlobals, type PromptRecord, @@ -87,6 +92,11 @@ const LIST_WHEEL_Y_LAST = 14; const PREVIEW_WHEEL_Y_FIRST = 17; const PREVIEW_WHEEL_Y_LAST = 26; +// Default selector footer line (PR #1393): shown whenever a delete is not +// armed; the armed state swaps it for the scope-aware confirmation copy. +const SELECTOR_FOOTER_HELP = + "↑↓ move • PgUp/PgDn page • tab scope • enter select and quit • ctrl+shift+↑/↓ preview • ctrl+shift+backspace delete • esc cancel"; + // Legacy agent dir: pre-v1 editor-history files live directly here and are // migrated into the store root by migrateLegacyStores(). const AGENT_DIR = join(homedir(), ".pi", "agent"); @@ -250,6 +260,7 @@ class PromptHistorySelector extends Container implements Focusable { private readonly listContainer: Container; private readonly headerRow: FixedRowText; private readonly previewLabelRow: FixedRowText; + private readonly footerRow: FixedRowText; private records: PromptRecord[]; private readonly theme: Theme; private readonly tui: TUI; @@ -269,6 +280,12 @@ class PromptHistorySelector extends Container implements Focusable { private wrappedPreviewLines: string[] = []; /** Scroll offset into wrappedPreviewLines for the preview viewport. */ private previewScrollOffset = 0; + /** + * Two-step delete confirmation (PR #1393): armed by the first + * ctrl+shift+backspace press; the second press executes, and any other + * key or cancel disarms. Nothing is deleted on the arming press. + */ + private confirmArmed = false; /** Dispatch table: first match wins, fallthrough last. */ private readonly dispatch: readonly DispatchEntry[] = [ @@ -377,15 +394,11 @@ class PromptHistorySelector extends Container implements Focusable { this.addChild(this.previewContainer); this.addChild(new DynamicBorder((s: string) => theme.fg("dim", s))); - this.addChild( - new FixedRowText( - theme.fg( - "dim", - "↑↓ move • PgUp/PgDn page • tab scope • enter select and quit • ctrl+shift+↑/↓ preview • ctrl+shift+backspace delete • esc cancel", - ), - true /* centered */, - ), + this.footerRow = new FixedRowText( + theme.fg("dim", SELECTOR_FOOTER_HELP), + true /* centered */, ); + this.addChild(this.footerRow); this.addChild(new DynamicBorder((s: string) => theme.fg("accent", s))); this.applyFilter(""); @@ -469,7 +482,13 @@ class PromptHistorySelector extends Container implements Focusable { for (const { record, isSelected } of visible) { const prefix = isSelected ? "→ " : " "; - const color = isSelected ? "accent" : "text"; + // Armed delete (PR #1393): the armed row repaints in the error color + // while the confirmation is pending, then reverts on disarm. + const color = isSelected + ? this.confirmArmed + ? "error" + : "accent" + : "text"; const compacted = sanitizeForDisplay(record.text) .replace(/\s+/g, " ") .trim(); @@ -557,8 +576,16 @@ class PromptHistorySelector extends Container implements Focusable { * rebuild the merged records, reset the window. Tab's only role. */ private toggleScope(): void { + const previous = this.scope; this.scope = this.scope === "project" ? "global" : "project"; const entries = drainForScope(this.scope); + if (!Array.isArray(entries)) { + // Fail-closed drain (spec C4): stay on the working scope and surface + // the recovery warning instead of a blocked (entry-less) list. + this.scope = previous; + this.onNotify?.(entries.message, "error"); + return; + } this.records = recordsFromEntries(entries); this.loadedCount = initialLoadedCount(this.records.length, INITIAL_BATCH); this.applyFilter(this.searchInput.getValue()); @@ -569,30 +596,54 @@ class PromptHistorySelector extends Container implements Focusable { const selected = this.filteredRecords[this.selectedIndex]; if (!selected) return; + // Two-step confirm (PR #1393 review): the first ctrl+shift+backspace + // press ARMS the delete for the selected row — a scope-aware + // confirmation line in the footer plus an error-colored highlight — + // and executes NOTHING; the SECOND press runs the flow below. Any + // other key or cancel disarms (handleInput / handleMouse). + const step = deleteConfirmNext(this.confirmArmed, true); + this.confirmArmed = step.armed; + this.refreshDeleteFooter(); + if (!step.execute) { + this.rebuildList(); // repaint the armed-row highlight + return; + } + this.rebuildList(); // drop the highlight before the flow mutates rows + // C4 delete flows (design §F): the record's provenance decides the // actions via the pure planner; module constants are used directly. const actions = deletionActionsFor(selected.source ?? "editor"); if (actions.deleteFromEditorStore) { // Store path: physically remove EVERY copy from the JSONL store - // (memory + file in one atomic rewrite). - const { removed } = - this.scope === "global" - ? deleteFromGlobal(PI_HISTORY_ROOT, selected.text) - : deleteFromProject(PI_HISTORY_ROOT, CURRENT_CWD, selected.text); + // (memory + file in one atomic rewrite). A thrown store failure is + // contained here (PR #1393): toast + abort — nothing was removed and + // no tombstone is written, so the delete never lies about state. + let removed: number; + try { + ({ removed } = + this.scope === "global" + ? deleteFromGlobal(PI_HISTORY_ROOT, selected.text) + : deleteFromProject(PI_HISTORY_ROOT, CURRENT_CWD, selected.text)); + } catch { + this.onNotify?.(STORE_DELETE_FAILED_TEXT, "error"); + return; + } if (removed === 0) return; } // Tombstone ALWAYS: the session transcripts are immutable and would // re-supply the deleted prompt on the next merge (hide-file suppresses // the twin). Only the session path aborts on a hide error — the store - // row is already gone on the editor path, so the splice proceeds. + // row is already gone on the editor path, so the splice proceeds; its + // toast says exactly that (PR #1393). const hide = hidePrompt(PI_HISTORY_NAV_STATE_DIR, selected.text); if (hide.status === "error") { - this.onNotify?.(hide.message, "error"); if (!actions.deleteFromEditorStore) { + this.onNotify?.(hide.message, "error"); return; } + this.onNotify?.(EDITOR_HIDE_FAILED_TEXT, "error"); } // Remove from the master records array so a subsequent filter doesn't // bring it back. @@ -612,6 +663,26 @@ class PromptHistorySelector extends Container implements Focusable { this.applyFilter(this.searchInput.getValue()); } + /** Footer line: scope-aware confirm copy while armed, help otherwise. */ + private refreshDeleteFooter(): void { + if (!this.confirmArmed) { + this.footerRow.setText(this.theme.fg("dim", SELECTOR_FOOTER_HELP)); + return; + } + const selected = this.filteredRecords[this.selectedIndex]; + const source = selected?.source ?? "editor"; + this.footerRow.setText( + this.theme.fg("warning", deleteConfirmFooterText(source)), + ); + } + + /** Leave the armed state: restore the help footer and the plain row. */ + private disarmDeleteConfirm(): void { + this.confirmArmed = false; + this.refreshDeleteFooter(); + this.rebuildList(); + } + // -- Navigation --------------------------------------------------------- private moveUp(): void { @@ -755,6 +826,11 @@ class PromptHistorySelector extends Container implements Focusable { handleInput(data: string): void { const kb = getKeybindings(); + // Any key other than the delete combo disarms a pending confirmation + // (PR #1393) BEFORE its own action runs — esc, arrows, typing, tab. + if (this.confirmArmed && !matchesKey(data, "ctrl+shift+backspace")) { + this.disarmDeleteConfirm(); + } let handled = false; for (const { match, handler } of this.dispatch) { if (match(data, kb)) { @@ -780,6 +856,9 @@ class PromptHistorySelector extends Container implements Focusable { event: TuiMouseEvent, ): ReturnType { if (event.type !== "wheel") return undefined; + // A wheel scroll can move the selection off the armed row — disarm so + // the next delete press re-arms for the NEW row first (PR #1393). + if (this.confirmArmed) this.disarmDeleteConfirm(); const delta = event.wheelDelta ?? 0; if (event.y >= LIST_WHEEL_Y_FIRST && event.y <= LIST_WHEEL_Y_LAST) { const steps = Math.min(Math.abs(delta), this.filteredRecords.length); @@ -951,19 +1030,29 @@ function getWriter(): SessionWriterState { return writerState; } +/** + * A selector scope drain: the drained prompts, or the fail-closed blocked + * shape (spec C4) carrying the recovery message and NO prompts. + */ +type ScopeDrain = string[] | Extract; + /** * Scope drain for the selector: project scope drains the project's store * files; global scope is the store-only cross-project view (all project - * dirs + the legacy global seed). Both filter tombstoned prompts. + * dirs + the legacy global seed). Both filter tombstoned prompts and fail + * closed (spec C4): an untrusted hidden.json returns the blocked + * DrainResult with the recovery message instead of any prompts. */ -function drainForScope(scope: HistoryScope): string[] { +function drainForScope(scope: HistoryScope): ScopeDrain { // Defense in depth: a drain must never trigger init writes while the user // has capture disabled (the selector gate below is the first line). if (!captureEnabled()) return []; getWriter(); // ensure init ran - return scope === "project" - ? drainProject(PI_HISTORY_ROOT, CURRENT_CWD, 1000, PI_HISTORY_NAV_STATE_DIR) - : drainGlobal(PI_HISTORY_ROOT, 1000, PI_HISTORY_NAV_STATE_DIR); + const drain = + scope === "project" + ? drainProject(PI_HISTORY_ROOT, CURRENT_CWD, 1000, PI_HISTORY_NAV_STATE_DIR) + : drainGlobal(PI_HISTORY_ROOT, 1000, PI_HISTORY_NAV_STATE_DIR); + return drain.status === "ok" ? drain.prompts : drain; } async function openHistorySelector( @@ -983,6 +1072,12 @@ async function openHistorySelector( // symmetrically — no live transcript merge (the one-time seed bootstrap // covers pre-store history). const entries = drainForScope("project"); + if (!Array.isArray(entries)) { + // Fail-closed drain (spec C4): the tombstone file is untrusted, so NO + // entries are shown — surface the recovery warning instead. + ctx.ui.notify(entries.message, "error"); + return; + } if (entries.length === 0) { ctx.ui.notify("No prompt history available.", "warning"); return; diff --git a/extensions/history/selector-helpers.ts b/extensions/history/selector-helpers.ts index 292c05907..54364bd86 100644 --- a/extensions/history/selector-helpers.ts +++ b/extensions/history/selector-helpers.ts @@ -255,6 +255,60 @@ export function deletionActionsFor( return { deleteFromEditorStore: false, writeTombstone: true }; } +/** + * One transition of the two-step delete confirmation (PR #1393 review): + * the first delete-key press ARMS the delete for the selected row and + * executes nothing; the SECOND press executes; any other key disarms. The + * selector's deleteCurrent and handleInput both route through this pure + * step so the arm/execute/disarm machine has exactly one definition. + */ +export interface DeleteConfirmStep { + /** The armed state AFTER this transition. */ + armed: boolean; + /** True only on the second delete-key press — the executing press. */ + execute: boolean; +} + +export function deleteConfirmNext( + armed: boolean, + isDeleteKey: boolean, +): DeleteConfirmStep { + if (!isDeleteKey) return { armed: false, execute: false }; + if (armed) return { armed: false, execute: true }; + return { armed: true, execute: false }; +} + +/** + * Scope-aware confirmation copy shown in the footer while a delete is + * armed (PR #1393): the two provenances have different semantics and the + * copy must say which one is about to run, in one line. Editor-stored + * prompts are removed from the store physically AND hidden from history; + * session-derived prompts can only be hidden (transcripts are immutable), + * so the original stays in the session transcript. + */ +export function deleteConfirmFooterText(source: PromptSource): string { + if (source === "editor") { + return "Delete stored prompt? Removes every copy from the store and hides it from history. Session transcripts keep the original."; + } + return "Hide from history? The original stays in the session transcript; a tombstone keeps it out of this list."; +} + +/** + * Toast copy when the store delete THROWS (PR #1393): the flow aborts + * before any tombstone write, so nothing was removed — the store keeps the + * prompt and no tombstone is written. + */ +export const STORE_DELETE_FAILED_TEXT = + "Store delete failed; nothing was removed."; + +/** + * Toast copy when the tombstone write fails on the EDITOR path (PR + * #1393): the store row was already removed, so only the hide failed — + * the prompt may reappear from session transcripts. + */ +export const EDITOR_HIDE_FAILED_TEXT = + "Deleted from the store, but hiding failed — the prompt may reappear from session transcripts."; + export function getVisiblePromptRecords( records: PromptRecord[], selectedIndex: number, diff --git a/extensions/history/store.ts b/extensions/history/store.ts index 04626f48e..a2c192dc5 100644 --- a/extensions/history/store.ts +++ b/extensions/history/store.ts @@ -12,7 +12,7 @@ import { createHash } from "node:crypto"; import fs from "node:fs"; import path from "node:path"; -import { loadHiddenPrompts } from "./hide-prompts.ts"; +import { readHiddenPrompts } from "./hide-prompts.ts"; import { loadSharedHistory } from "./load-shared-history.ts"; import { extractPromptsFromFile, @@ -357,20 +357,53 @@ function sortFilesForDrain(files: string[]): string[] { .map((f) => f.file); } +/** + * Result of a scope drain: `ok` with the drained prompts, or `blocked` + * when the tombstone file is untrusted (fail-closed READ half). The + * blocked shape carries NO prompts field, so a caller cannot accidentally + * render prompts that may include hidden ones. + */ +export type DrainResult = + | { status: "ok"; prompts: string[] } + | { status: "blocked"; message: string }; + +/** + * Shared drain tail: without a `stateDir` the raw drain semantics hold (no + * filter). With one, the tombstone filter applies and fails CLOSED: an + * untrusted hidden.json (unreadable, corrupt, wrong shape) blocks the + * whole drain with the recovery message instead of resurfacing hidden + * prompts; a missing file is the safe empty tombstone set and drains + * normally. + */ +function drainWithHidden( + files: string[], + limit: number, + stateDir?: string, +): DrainResult { + if (!stateDir) return { status: "ok", prompts: drainFiles(files, limit) }; + const read = readHiddenPrompts(stateDir); + if (read.status === "untrusted") { + return { status: "blocked", message: read.message }; + } + return { status: "ok", prompts: drainFiles(files, limit, read.keys) }; +} + /** * Drain the PROJECT scope: all .jsonl files in the project dir (seed.jsonl * included), mtime-newest-first, deduped, capped at `limit` (default 1000). + * With a `stateDir`, the tombstone filter applies and fails closed: an + * untrusted hidden.json blocks the drain (see DrainResult). */ export function drainProject( root: string, cwd: string, limit: number = 1000, stateDir?: string, -): string[] { - return drainFiles( +): DrainResult { + return drainWithHidden( sortFilesForDrain(listProjectFiles(path.join(root, "projects", projectHash(cwd)))), limit, - stateDir ? loadHiddenPrompts(stateDir) : new Set(), + stateDir, ); } @@ -378,13 +411,15 @@ export function drainProject( * Drain the GLOBAL scope: every project dir's files, mtime-newest-first, * deduped, capped — with the legacy global seed appended LAST (deliberate: * it is the least specific, migrated source, so per-project entries win - * recency and keep-first dedup favors them). + * recency and keep-first dedup favors them). With a `stateDir`, the + * tombstone filter applies and fails closed: an untrusted hidden.json + * blocks the drain (see DrainResult). */ export function drainGlobal( root: string, limit: number = 1000, stateDir?: string, -): string[] { +): DrainResult { const files: string[] = []; const globalSeed = globalSeedPath(root); @@ -404,11 +439,7 @@ export function drainGlobal( } const sorted = sortFilesForDrain(files); if (fs.existsSync(globalSeed)) sorted.push(globalSeed); // legacy last - return drainFiles( - sorted, - limit, - stateDir ? loadHiddenPrompts(stateDir) : new Set(), - ); + return drainWithHidden(sorted, limit, stateDir); } // --------------------------------------------------------------------------- @@ -620,7 +651,16 @@ export function bootstrapProjectSeed( return { seeded: 0, ran: false }; } // Tombstones (user deletions) suppress transcript prompts from seeding. - const hidden = stateDir ? loadHiddenPrompts(stateDir) : new Set(); + // Fail closed (spec C4): an untrusted hidden.json leaves the tombstone + // set unknown, and a wrongly seeded prompt would be permanent (the seed + // is written once, never regenerated) — skip the bootstrap instead; a + // later open retries once the file is trusted again or deleted. + let hidden = new Set(); + if (stateDir !== undefined) { + const read = readHiddenPrompts(stateDir); + if (read.status === "untrusted") return { seeded: 0, ran: false }; + hidden = read.keys; + } // Scan transcripts: session files of THIS project's dir, newest first. let files: string[] = []; diff --git a/tests/history-delete-confirm.test.ts b/tests/history-delete-confirm.test.ts new file mode 100644 index 000000000..fe828f256 --- /dev/null +++ b/tests/history-delete-confirm.test.ts @@ -0,0 +1,300 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import { fileURLToPath } from "node:url"; +import { + deleteConfirmFooterText, + deleteConfirmNext, + deletionActionsFor, + EDITOR_HIDE_FAILED_TEXT, + STORE_DELETE_FAILED_TEXT, +} from "../extensions/history/selector-helpers.ts"; + +// PR #1393 review-fix tests: the two-step delete confirmation in the +// history selector. The first ctrl+shift+backspace press ARMS the delete +// for the selected row (scope-aware confirmation footer + highlighted +// record) and executes NOTHING; the second press executes the +// deletionActionsFor-driven flow; any other key or cancel disarms. +// +// PromptHistorySelector is private to extensions/history/index.ts and +// needs the pi-tui runtime graph (openflow-integration.test.ts +// discipline), and an executing delete writes the module-constant REAL +// store (~/.pi/agent/history — no injection point), so the confirmation +// DECISION is factored into pure helpers tested here directly, and the +// execution semantics are pinned by source-parse on deleteCurrent +// (delete-backfill.test.ts discipline). No test in this file touches the +// user's real store. + +// --------------------------------------------------------------------------- +// Pure decision machine: arm → execute, disarm on anything else. +// --------------------------------------------------------------------------- + +test("the first delete press arms only — nothing executes (PR #1393)", () => { + assert.deepEqual(deleteConfirmNext(false, true), { + armed: true, + execute: false, + }); +}); + +test("the second delete press executes and rearms-to-idle (PR #1393)", () => { + assert.deepEqual(deleteConfirmNext(true, true), { + armed: false, + execute: true, + }); +}); + +test("any other key disarms without executing; an idle stay stays idle", () => { + assert.deepEqual(deleteConfirmNext(true, false), { + armed: false, + execute: false, + }); + assert.deepEqual(deleteConfirmNext(false, false), { + armed: false, + execute: false, + }); +}); + +test("after an executed delete the machine is idle again — a fresh confirm per row", () => { + const first = deleteConfirmNext(false, true); + assert.equal(first.execute, false); + const second = deleteConfirmNext(first.armed, true); + assert.equal(second.execute, true); + // A THIRD press starts a NEW confirmation instead of executing blindly. + assert.deepEqual(deleteConfirmNext(second.armed, true), { + armed: true, + execute: false, + }); +}); + +// (b) + (c): the executing press composes with the pure planner — an +// editor-source record deletes from the store AND tombstones; a +// session-source record NEVER plans a store delete (tombstone only). + +test("second press executes the editor-source plan: store delete + tombstone", () => { + const armed = deleteConfirmNext(false, true); + const step = deleteConfirmNext(armed.armed, true); + assert.equal(step.execute, true); + assert.deepEqual(deletionActionsFor("editor"), { + deleteFromEditorStore: true, + writeTombstone: true, + }); +}); + +test("a session-source record never plans a store delete — tombstone only", () => { + const armed = deleteConfirmNext(false, true); + const step = deleteConfirmNext(armed.armed, true); + assert.equal(step.execute, true); + const actions = deletionActionsFor("session"); + assert.equal(actions.deleteFromEditorStore, false); + assert.equal(actions.writeTombstone, true); +}); + +// --------------------------------------------------------------------------- +// Copy: the armed footer distinguishes the two semantics in one line; the +// failure toasts state exactly what state remains. +// --------------------------------------------------------------------------- + +test("the editor confirmation names the physical delete AND the hide", () => { + const text = deleteConfirmFooterText("editor"); + assert.ok(!text.includes("\n"), "the confirmation stays on one line"); + assert.ok(text.includes("Delete stored prompt?")); + assert.ok(text.includes("Removes every copy from the store")); + assert.ok(text.includes("hides it from history")); + assert.ok( + text.includes("Session transcripts keep the original"), + "the immutability caveat must be stated", + ); +}); + +test("the session confirmation names the hide-only semantics", () => { + const text = deleteConfirmFooterText("session"); + assert.ok(!text.includes("\n"), "the confirmation stays on one line"); + assert.ok(text.includes("Hide from history?")); + assert.ok(text.includes("The original stays in the session transcript")); + assert.ok(text.includes("tombstone keeps it out of this list")); +}); + +test("failure toasts state the remaining state exactly (PR #1393)", () => { + // A thrown store delete aborts before any tombstone: nothing removed. + assert.equal( + STORE_DELETE_FAILED_TEXT, + "Store delete failed; nothing was removed.", + ); + // Editor-path hide failure: the store row is gone, the prompt may + // reappear from transcripts. + assert.equal( + EDITOR_HIDE_FAILED_TEXT, + "Deleted from the store, but hiding failed — the prompt may reappear from session transcripts.", + ); +}); + +// --------------------------------------------------------------------------- +// Source-parse: the execution semantics inside deleteCurrent (the selector +// class itself is not instantiable under node:test — see the header note). +// --------------------------------------------------------------------------- + +const selectorSource = fs.readFileSync( + fileURLToPath(new URL("../extensions/history/index.ts", import.meta.url)), + "utf8", +); + +function deleteCurrentBody(): string { + const decl = selectorSource.indexOf("private deleteCurrent("); + assert.ok(decl >= 0, "deleteCurrent should exist"); + const end = selectorSource.indexOf("\n }", decl); + assert.ok(end > decl, "deleteCurrent's body should close"); + return selectorSource.slice(decl, end); +} + +test("(b) the arming press returns before ANY mutation of rows or disk", () => { + const body = deleteCurrentBody(); + const stepAt = body.indexOf("const step = deleteConfirmNext(this.confirmArmed, true);"); + assert.ok(stepAt >= 0, "the transition must route through the pure helper"); + const armReturnAt = body.indexOf("if (!step.execute)"); + assert.ok(armReturnAt > stepAt, "the execute gate must follow the step"); + const editorGuardAt = body.indexOf("if (actions.deleteFromEditorStore)"); + const spliceAt = body.indexOf("this.records.splice("); + const hideAt = body.indexOf("hidePrompt("); + assert.ok( + armReturnAt < editorGuardAt && + armReturnAt < spliceAt && + armReturnAt < hideAt, + "arming must precede the store flow, the splice, and the tombstone", + ); +}); + +test("(c) the store deletes live only inside the editor-source guard", () => { + const body = deleteCurrentBody(); + const guardAt = body.indexOf("if (actions.deleteFromEditorStore)"); + assert.ok(guardAt >= 0, "the editor-store guard must exist"); + const guardCloseAt = body.indexOf("\n }", guardAt); + assert.ok(guardCloseAt > guardAt, "the editor-store guard must close"); + + for (const call of ["deleteFromGlobal(", "deleteFromProject("]) { + const at = body.indexOf(call); + assert.ok(at >= 0, `${call} must exist`); + assert.ok( + at > guardAt && at < guardCloseAt, + `${call} must sit inside the editor guard — a session record never reaches it`, + ); + } + // The tombstone write follows the guard: EVERY provenance lands one. + const hideAt = body.indexOf("hidePrompt("); + assert.ok( + hideAt > guardCloseAt, + "the tombstone must follow (not sit inside) the editor-store guard", + ); +}); + +test("(d) a thrown store delete toasts the failure copy and aborts", () => { + const body = deleteCurrentBody(); + const tryAt = body.indexOf("try {"); + const catchAt = body.indexOf("} catch {", tryAt); + assert.ok(tryAt >= 0 && catchAt > tryAt, "the store calls must be wrapped"); + const catchEnd = body.indexOf("\n }", catchAt); + const catchBody = body.slice(catchAt, catchEnd); + assert.ok( + catchBody.includes(`this.onNotify?.(STORE_DELETE_FAILED_TEXT, "error")`), + "the catch must toast the store-failure copy", + ); + assert.ok( + catchBody.includes("return;"), + "the catch must abort the flow", + ); + // The abort precedes the tombstone write: a failed store delete leaves + // NO tombstone behind. + const hideAt = body.indexOf("hidePrompt("); + assert.ok(catchAt < hideAt, "the catch must precede the hide write"); +}); + +test("(e) a hide error toasts the session message and aborts — the editor path proceeds to the splice", () => { + const body = deleteCurrentBody(); + const gateAt = body.indexOf('if (hide.status === "error")'); + assert.ok(gateAt >= 0, "hide errors must be gated"); + const spliceAt = body.indexOf("this.records.splice("); + assert.ok(gateAt < spliceAt, "the hide gate must precede the splice"); + const gate = body.slice(gateAt, spliceAt); + + // Session path: toast the recovery message and abort. + const abortGuardAt = gate.indexOf("if (!actions.deleteFromEditorStore)"); + assert.ok( + abortGuardAt >= 0, + "the session-path early return must be exclusive", + ); + const abortBody = gate.slice(abortGuardAt, gate.indexOf("}", abortGuardAt)); + assert.ok( + abortBody.includes('this.onNotify?.(hide.message, "error")'), + "the session path must toast the hide error itself", + ); + assert.ok(abortBody.includes("return;"), "the session path must abort"); + assert.ok( + !gate.slice(0, abortGuardAt).includes("return;"), + "no unconditional abort before the provenance split", + ); + + // Editor path: the store row is already gone — the toast says so, and + // control FALLS THROUGH to the splice (no return between the toast and + // the splice). + const editorToastAt = gate.indexOf(`this.onNotify?.(EDITOR_HIDE_FAILED_TEXT, "error")`); + assert.ok(editorToastAt >= 0, "the editor path must toast the hide failure"); + const gateToSplice = gate.slice(editorToastAt); + assert.ok( + !gateToSplice.includes("return;"), + "the editor path must NOT abort — the splice still runs", + ); +}); + +test("any other key disarms before its own action; a wheel scroll disarms too", () => { + const handleInputAt = selectorSource.indexOf("handleInput(data: string): void {"); + assert.ok(handleInputAt >= 0, "handleInput should exist"); + const inputEnd = selectorSource.indexOf("\n }", handleInputAt); + const inputBody = selectorSource.slice(handleInputAt, inputEnd); + const disarmAt = inputBody.indexOf("this.disarmDeleteConfirm()"); + assert.ok(disarmAt >= 0, "handleInput must disarm a pending confirmation"); + assert.ok( + inputBody.includes('!matchesKey(data, "ctrl+shift+backspace")'), + "the delete combo itself must NOT route through the disarm pre-pass", + ); + // The disarm must happen before the dispatch loop consumes the key. + const loopAt = inputBody.indexOf("for (const { match, handler } of this.dispatch) {"); + assert.ok(disarmAt < loopAt, "the disarm pre-pass must precede dispatch"); + + const handleMouseAt = selectorSource.indexOf("override handleMouse("); + assert.ok(handleMouseAt >= 0, "handleMouse should exist"); + const mouseEnd = selectorSource.indexOf("\n }", handleMouseAt); + const mouseBody = selectorSource.slice(handleMouseAt, mouseEnd); + assert.ok( + mouseBody.indexOf("this.disarmDeleteConfirm()") >= 0, + "a wheel scroll can move the selection off the armed row — it must disarm", + ); +}); + +test("the armed state drives the footer copy and the error-colored highlight", () => { + const body = deleteCurrentBody(); + assert.ok( + body.includes("this.refreshDeleteFooter()"), + "every delete press refreshes the footer", + ); + + const footerAt = selectorSource.indexOf("private refreshDeleteFooter(): void {"); + assert.ok(footerAt >= 0, "refreshDeleteFooter should exist"); + const footerEnd = selectorSource.indexOf("\n }", footerAt); + const footerBody = selectorSource.slice(footerAt, footerEnd); + assert.ok( + footerBody.includes("deleteConfirmFooterText(source)"), + "the armed footer uses the scope-aware pure copy", + ); + assert.ok( + footerBody.includes("SELECTOR_FOOTER_HELP"), + "disarming restores the help line", + ); + + const rebuildAt = selectorSource.indexOf("private rebuildListWithWidth(width: number): void {"); + assert.ok(rebuildAt >= 0, "rebuildListWithWidth should exist"); + const rebuildEnd = selectorSource.indexOf("\n }", rebuildAt); + const rebuildBody = selectorSource.slice(rebuildAt, rebuildEnd); + assert.ok( + rebuildBody.includes("this.confirmArmed"), + "the armed state repaints the selected row", + ); +}); diff --git a/tests/history-drain-hidden.test.ts b/tests/history-drain-hidden.test.ts index 90c479758..73a686b86 100644 --- a/tests/history-drain-hidden.test.ts +++ b/tests/history-drain-hidden.test.ts @@ -8,6 +8,7 @@ import { drainProject, globalSeedPath, projectHash, + type DrainResult, } from "../extensions/history/store.ts"; // Portable project identity: a never-existing literal. projectHash falls @@ -25,6 +26,14 @@ function write(file: string, texts: string[], ts = 100): void { ); } +// Unwrap the ok shape. Drains FAIL CLOSED: the blocked variant carries no +// prompts field at all (asserted in the blocked test below). +function okPrompts(result: DrainResult): string[] { + assert.equal(result.status, "ok"); + if (result.status !== "ok") throw new Error("unreachable"); + return result.prompts; +} + test("drains skip tombstoned prompts in seeds and session files", () => { const base = fs.mkdtempSync(path.join(os.tmpdir(), "hid-")); const root = path.join(base, "h"); @@ -40,15 +49,62 @@ test("drains skip tombstoned prompts in seeds and session files", () => { write(path.join(dir, "s1.jsonl"), ["also keep", "deleted from session"], 200); write(globalSeedPath(root), ["deleted from seed", "legacy keep"], 50); - assert.deepEqual(drainProject(root, CWD, 1000, stateDir), [ + assert.deepEqual(okPrompts(drainProject(root, CWD, 1000, stateDir)), [ "also keep", "keep", ]); - assert.deepEqual(drainGlobal(root, 1000, stateDir), [ + assert.deepEqual(okPrompts(drainGlobal(root, 1000, stateDir)), [ "also keep", "keep", "legacy keep", ]); // Without a stateDir the filter is off (raw drain semantics). - assert.equal(drainProject(root, CWD).includes("deleted from seed"), true); + assert.equal( + okPrompts(drainProject(root, CWD)).includes("deleted from seed"), + true, + ); +}); + +// Fail-closed seam: an untrusted hidden.json BLOCKS both drains with the +// recovery message and no prompts field; a stateDir whose hidden.json is +// MISSING stays the safe empty-tombstones case (the full expected prompts). +test("corrupt hidden.json blocks both drains with no prompts field; a missing file drains normally", () => { + const base = fs.mkdtempSync(path.join(os.tmpdir(), "hid-blocked-")); + const root = path.join(base, "h"); + const stateDir = path.join(base, "state"); + fs.mkdirSync(stateDir, { recursive: true }); + fs.writeFileSync( + path.join(stateDir, "hidden.json"), + "{corrupt bytes", + "utf8", + ); + const dir = path.join(root, "projects", projectHash(CWD)); + write(path.join(dir, "seed.jsonl"), ["secret prompt", "keeper"], 100); + + for (const result of [ + drainProject(root, CWD, 1000, stateDir), + drainGlobal(root, 1000, stateDir), + ]) { + assert.equal(result.status, "blocked"); + if (result.status !== "blocked") throw new Error("unreachable"); + assert.ok(result.message.includes("hidden.json")); + // The blocked shape carries no prompts to render. + assert.equal("prompts" in result, false); + } + + // Missing file: safe empty tombstones — the full drain comes back. + const missingBase = fs.mkdtempSync(path.join(os.tmpdir(), "hid-missing-")); + const missingRoot = path.join(missingBase, "h"); + const missingState = path.join(missingBase, "state"); + fs.mkdirSync(missingState, { recursive: true }); + const missingDir = path.join(missingRoot, "projects", projectHash(CWD)); + write(path.join(missingDir, "seed.jsonl"), ["kept", "shown"], 100); + assert.deepEqual( + okPrompts(drainProject(missingRoot, CWD, 1000, missingState)), + ["shown", "kept"], + ); + assert.deepEqual(okPrompts(drainGlobal(missingRoot, 1000, missingState)), [ + "shown", + "kept", + ]); }); diff --git a/tests/history-drain-order.test.ts b/tests/history-drain-order.test.ts index 86cfddca9..be2f8fdfc 100644 --- a/tests/history-drain-order.test.ts +++ b/tests/history-drain-order.test.ts @@ -8,6 +8,7 @@ import { drainProject, globalSeedPath, projectHash, + type DrainResult, } from "../extensions/history/store.ts"; // Portable project identity: a never-existing literal. projectHash falls @@ -25,18 +26,25 @@ function writeTs(file: string, texts: string[], ts: number): void { ); } +// Mechanical unwrap of the ok shape (drains can also return blocked). +function okPrompts(result: DrainResult): string[] { + assert.equal(result.status, "ok"); + if (result.status !== "ok") throw new Error("unreachable"); + return result.prompts; +} + test("atomic rewrite (delete) does not reshuffle the drain order", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "ord-")); const dir = path.join(root, "projects", projectHash(CWD)); writeTs(path.join(dir, "old.jsonl"), ["a-old"], 100); writeTs(path.join(dir, "new.jsonl"), ["z-new"], 200); - assert.deepEqual(drainProject(root, CWD), ["z-new", "a-old"]); + assert.deepEqual(okPrompts(drainProject(root, CWD)), ["z-new", "a-old"]); // Slice 5 ports deleteFromProject; its observable effect on the drain is // simulated directly here: an atomic rewrite of the affected file that // empties it — the mtime jumps to NOW, and the drain order must not move. fs.writeFileSync(path.join(dir, "old.jsonl"), "", "utf8"); fs.utimesSync(path.join(dir, "old.jsonl"), new Date(), new Date()); - assert.deepEqual(drainProject(root, CWD), ["z-new"]); + assert.deepEqual(okPrompts(drainProject(root, CWD)), ["z-new"]); // Re-add with an OLD ts via direct write: still ordered by ts, not mtime. writeTs(path.join(dir, "old2.jsonl"), ["b-old"], 150); fs.utimesSync( @@ -44,7 +52,7 @@ test("atomic rewrite (delete) does not reshuffle the drain order", () => { new Date(Date.now() + 99999), new Date(Date.now() + 99999), ); - assert.deepEqual(drainProject(root, CWD), ["z-new", "b-old"]); + assert.deepEqual(okPrompts(drainProject(root, CWD)), ["z-new", "b-old"]); }); test("global drain puts the legacy seed last regardless of its fresh mtime", () => { @@ -54,7 +62,11 @@ test("global drain puts the legacy seed last regardless of its fresh mtime", () const seed = globalSeedPath(root); writeTs(seed, ["legacy-1", "legacy-2"], 10); fs.utimesSync(seed, new Date(Date.now() + 5000), new Date(Date.now() + 5000)); - assert.deepEqual(drainGlobal(root), ["fresh", "legacy-2", "legacy-1"]); + assert.deepEqual(okPrompts(drainGlobal(root)), [ + "fresh", + "legacy-2", + "legacy-1", + ]); }); test( @@ -78,7 +90,7 @@ test( try { // An unreadable file reads as zero entries and drops out of the drain; // the readable files keep their ts order. No throw. - assert.deepEqual(drainProject(root, CWD), ["z-new", "a-old"]); + assert.deepEqual(okPrompts(drainProject(root, CWD)), ["z-new", "a-old"]); } finally { fs.chmodSync(sealed, 0o644); // restore before cleanup } diff --git a/tests/history-hide-prompts.test.ts b/tests/history-hide-prompts.test.ts index 03c054863..ccd1f8ea8 100644 --- a/tests/history-hide-prompts.test.ts +++ b/tests/history-hide-prompts.test.ts @@ -3,13 +3,20 @@ import assert from "node:assert/strict"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { hidePrompt, loadHiddenPrompts } from "../extensions/history/hide-prompts.ts"; +import { + hidePrompt, + readHiddenPrompts, +} from "../extensions/history/hide-prompts.ts"; import { promptDedupKey } from "../extensions/history/selector-helpers.ts"; // Unit WU4 — tombstone write half + read half (spec C4, design §D6). fs-only -// coverage. The dev suite's deleteCurrent source-parse pins (T27/T28) and -// the deletionActionsFor planner pins cover the slice-3 selector branch and -// the slice-5 delete flow; they port with those slices. +// coverage. The READ half FAILS CLOSED for history: a file that exists but +// cannot be trusted (unreadable, corrupt, wrong shape) reads `untrusted` +// with a recovery warning instead of an empty tombstone set, and the WRITE +// half refuses without a silent rewrite. The dev suite's deleteCurrent +// source-parse pins (T27/T28) and the deletionActionsFor planner pins cover +// the slice-3 selector branch and the slice-5 delete flow; they port with +// those slices. function makeStateDir(name: string): string { return fs.mkdtempSync(path.join(os.tmpdir(), `hide-prompts-${name}-`)); @@ -21,6 +28,29 @@ function readHideFile(stateDir: string) { ); } +/** Assert an untrusted read of the expected reason; returns its message. */ +function assertUntrusted( + stateDir: string, + reason: "unreadable" | "corrupt" | "malformed", +): string { + const read = readHiddenPrompts(stateDir); + assert.equal(read.status, "untrusted"); + if (read.status !== "untrusted") throw new Error("unreachable"); + assert.equal(read.reason, reason); + // The recovery warning names the file and offers restore-or-delete. + assert.ok(read.message.includes("hidden.json")); + assert.ok(/restore|delete/.test(read.message)); + return read.message; +} + +/** Assert a trusted read and return its key set. */ +function trustedKeys(stateDir: string): Set { + const read = readHiddenPrompts(stateDir); + assert.equal(read.status, "trusted"); + if (read.status !== "trusted") throw new Error("unreachable"); + return read.keys; +} + // T24 — AC-S4-1: hide-key fidelity. Tombstone keys must byte-match the // Change 2 dedup key for the same text — same imported helper, never a // re-implementation: the stored file content is compared against @@ -41,59 +71,138 @@ test("T24 (AC-S4-1): hide keys byte-match promptDedupKey across whitespace, case assert.ok(Array.isArray(stored), "hidden.json must hold a JSON array"); // Byte-match: the file holds EXACTLY the shared helper's output, sorted. assert.deepEqual(stored, texts.map((text) => promptDedupKey(text)).sort()); - // The loaded set agrees. - const loaded = loadHiddenPrompts(stateDir); + // The read half agrees. + const keys = trustedKeys(stateDir); + assert.equal(keys.size, stored.length); for (const key of stored) { - assert.ok(loaded.has(key)); + assert.ok(keys.has(key)); } }); // T25 — AC-S4-2: hide persistence and tolerance. Two deletes of the same -// text compact to ONE key; a missing hide file reads as an empty set; reads -// never throw. -test("T25 (AC-S4-2): duplicate hides compact to one key; a missing file reads as empty; reads never throw", () => { +// text compact to ONE key; a MISSING hide file (before any deletion) is the +// safe empty case — trusted with no keys; reads never throw. +test("T25 (AC-S4-2): duplicate hides compact to one key; a missing file reads trusted-empty; reads never throw", () => { const stateDir = makeStateDir("t25"); - // Missing file: empty set, no throw (before any write exists). - assert.equal(loadHiddenPrompts(stateDir).size, 0); + // Missing file: trusted empty tombstones (before any write exists). + assert.equal(trustedKeys(stateDir).size, 0); // Two deletes of the same text — variants differing by case + whitespace // runs normalize onto the same key. assert.deepEqual(hidePrompt(stateDir, "Same Text"), { status: "written" }); assert.deepEqual(hidePrompt(stateDir, "same text"), { status: "written" }); const stored = readHideFile(stateDir); assert.deepEqual(stored, [promptDedupKey("same text")]); - const loaded = loadHiddenPrompts(stateDir); - assert.equal(loaded.size, 1); - assert.ok(loaded.has(promptDedupKey("same text"))); + const keys = trustedKeys(stateDir); + assert.equal(keys.size, 1); + assert.ok(keys.has(promptDedupKey("same text"))); }); -// T26 — AC-S4-5: corrupt hidden.json is fail-open (READ half) AND the next -// hide rewrites the file clean as a sorted compact array — the rewrite half -// is the recovery path. -test("T26 (AC-S4-5): corrupt hidden.json loads as empty and the next hide rewrites it clean", () => { +// T26 — AC-S4-5: corrupt hidden.json FAILS CLOSED for history reads. The +// READ half reports untrusted (corrupt) so callers block history instead of +// resurfacing hidden prompts; the WRITE half refuses WITHOUT a silent clean +// rewrite (the old fail-open behavior cleared the blocked state one hide +// later). Recovery is manual — restore or delete the file; after deletion +// the next hide succeeds and reads are trusted again. +test("T26 (AC-S4-5): corrupt hidden.json reads untrusted; hide refuses without rewriting; deleting the file recovers", () => { const stateDir = makeStateDir("t26"); - fs.writeFileSync( - path.join(stateDir, "hidden.json"), - "{corrupt bytes", - "utf8", - ); - assert.equal(loadHiddenPrompts(stateDir).size, 0); + const hidePath = path.join(stateDir, "hidden.json"); + fs.writeFileSync(hidePath, "{corrupt bytes", "utf8"); + // READ half: untrusted/corrupt — never an empty trusted set. + const message = assertUntrusted(stateDir, "corrupt"); + // WRITE half: refused, and the corrupt bytes are UNCHANGED — the blocked + // state is never silently reset (no-silent-rewrite pin). + const before = fs.readFileSync(hidePath, "utf8"); + assert.deepEqual(hidePrompt(stateDir, "beta prompt"), { + status: "error", + message, + }); + assert.equal(fs.readFileSync(hidePath, "utf8"), before); + // Manual recovery: delete the file; the next hide succeeds and reads are + // trusted with exactly the new key. + fs.unlinkSync(hidePath); assert.deepEqual(hidePrompt(stateDir, "beta prompt"), { status: "written" }); - // The rewrite landed: clean JSON holding exactly the new key. - assert.deepEqual(readHideFile(stateDir), [promptDedupKey("beta prompt")]); - assert.equal(loadHiddenPrompts(stateDir).size, 1); + const keys = trustedKeys(stateDir); + assert.equal(keys.size, 1); + assert.ok(keys.has(promptDedupKey("beta prompt"))); }); -// WU4c — write-failure path (AC-S4-2 triangulation): a state dir that cannot -// be created (its parent is a regular file) makes the atomic write return -// false, and hidePrompt maps that to the toast-suitable error object — -// never a throw. -test("hide write failure returns the exact error shape for the delete-flow toast", () => { - const base = makeStateDir("fail"); - const blocker = path.join(base, "blocker"); - fs.writeFileSync(blocker, "regular file", "utf8"); - const stateDir = path.join(blocker, "sealed"); // parent is a file → ENOTDIR - assert.deepEqual(hidePrompt(stateDir, "kept prompt"), { +// Wrong-shaped file: valid JSON that is not an array fails closed too (both +// halves), while junk items inside a VALID array are ignored and the real +// keys stay trusted. +test("malformed hidden.json fails closed for reads and writes; junk items in a valid array are ignored", () => { + const malformed = makeStateDir("malformed"); + const hidePath = path.join(malformed, "hidden.json"); + for (const shape of ["{}", JSON.stringify({ keys: [] })]) { + fs.writeFileSync(hidePath, shape, "utf8"); + assertUntrusted(malformed, "malformed"); + } + // The write half refuses the malformed file as well. + const message = assertUntrusted(malformed, "malformed"); + assert.deepEqual(hidePrompt(malformed, "kept prompt"), { status: "error", - message: "Could not write the hide file; the prompt may reappear.", + message, }); + + // Junk items are ignored, never trusted; real keys survive. + const junk = makeStateDir("junk"); + fs.writeFileSync( + path.join(junk, "hidden.json"), + JSON.stringify([42, "", "real-key", null]), + "utf8", + ); + const keys = trustedKeys(junk); + assert.equal(keys.size, 1); + assert.ok(keys.has("real-key")); }); + +// Unreadable file: a hidden.json that cannot be read at all fails closed +// for reads, and the write half refuses too. Skipped as root, where chmod +// 000 does not block reads; permissions are restored in finally. +test( + "an unreadable hidden.json fails closed for reads and refuses writes", + { skip: process.getuid?.() === 0 }, + () => { + const stateDir = makeStateDir("sealed"); + const hidePath = path.join(stateDir, "hidden.json"); + fs.writeFileSync(hidePath, '["kept-key"]', "utf8"); + fs.chmodSync(hidePath, 0o000); + try { + const message = assertUntrusted(stateDir, "unreadable"); + assert.deepEqual(hidePrompt(stateDir, "beta prompt"), { + status: "error", + message, + }); + } finally { + fs.chmodSync(hidePath, 0o644); // restore before cleanup + } + }, +); + +// WU4c — write-failure path (AC-S4-2 triangulation): a trusted read whose +// atomic write fails makes hidePrompt return the toast-suitable error +// object — never a throw. The state dir is made non-writable while the +// existing hidden.json stays readable (a state dir whose PATH is blocked +// by a regular file is now the untrusted-refusal case instead — the READ +// half fails closed before any write). Skipped as root, where chmod-based +// write blocking does not apply; permissions are restored in finally. +test( + "hide write failure returns the exact error shape for the delete-flow toast", + { skip: process.getuid?.() === 0 }, + () => { + const stateDir = makeStateDir("fail"); + fs.writeFileSync( + path.join(stateDir, "hidden.json"), + '["kept-key"]', + "utf8", + ); + fs.chmodSync(stateDir, 0o555); // read+execute, no write → EACCES on tmp + try { + assert.deepEqual(hidePrompt(stateDir, "kept prompt"), { + status: "error", + message: "Could not write the hide file; the prompt may reappear.", + }); + } finally { + fs.chmodSync(stateDir, 0o700); // restore before cleanup + } + }, +); From e30233751f94c6a9e07045d65a2f8d20e2878d64 Mon Sep 17 00:00:00 2001 From: Carolina <26188349+carolitascl@users.noreply.github.com> Date: Fri, 25 Sep 2026 18:22:04 -0300 Subject: [PATCH 6/6] fix(history): modal delete confirm, read-only session rows, GENTLE_PI_HISTORY_ENABLE, hidden.json cap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rework of the delete affordance per the slice-5 review decisions: - Two-step modal: ctrl+shift+backspace arms the selected row with the footer copy; while armed ONLY y/Y (execute), n/N and Esc (cancel) are honored — every other key is swallowed and stays armed, so nothing is ever typed into the search input. Esc while armed cancels the confirmation without closing the overlay. Wheel still disarms then scrolls. - Session-derived rows are read-only: the delete key on a session row is a silent no-op — session transcripts are immutable input owned by Pi core, so no hide, no tombstone, no store write comes from them. - Confirm copy: "Delete this prompt from history (y/n)? Prompt stays in session log" (single variant; the per-provenance distinction lives in docs/prompt-history.md). - Env rename: GENTLE_PI_HISTORY_CAPTURE → GENTLE_PI_HISTORY_ENABLE (captureEnabled, open-flow warning, docs, tests). - hidden.json retention: capped at HIDE_FILE_MAX_ENTRIES = 1000 in insertion order (newest last) — re-hiding refreshes recency, past the cap the oldest entries drop; .sort() removed; the fail-closed reader is unchanged, so the tombstone still applies to seeded copies and no prompt is permanently fixed. - docs/prompt-history.md: §Delete rewritten (modal, read-only session rows, verbatim failure toasts, cap); env renamed across all sections; stale "deletion UI is not shipped yet" sentences fixed. --- docs/prompt-history.md | 79 ++--- extensions/history/hide-prompts.ts | 44 ++- extensions/history/index.ts | 99 ++++-- extensions/history/selector-helpers.ts | 69 +++-- tests/history-delete-backfill.test.ts | 52 ++-- tests/history-delete-confirm.test.ts | 414 ++++++++++++++----------- tests/history-hide-prompts.test.ts | 87 +++++- tests/history-off-path.test.ts | 4 +- tests/history-session-writer.test.ts | 20 +- 9 files changed, 536 insertions(+), 332 deletions(-) diff --git a/docs/prompt-history.md b/docs/prompt-history.md index 6a62d81e8..d5ea5734b 100644 --- a/docs/prompt-history.md +++ b/docs/prompt-history.md @@ -1,17 +1,17 @@ # Prompt history -Slice 1 of the prompt-history extension (#819 split) ships the storage layer only: -a per-instance JSONL capture store, project identity, and the read/write -primitives later slices build on. The selector UI, deletion/scope drains, and GC -arrive in later slices of the chain. +Slice 1 of the prompt-history extension (#819 split) shipped the storage +layer: a per-instance JSONL capture store, project identity, and the +read/write primitives later slices build on. The selector UI and deletion +shipped in later slices; GC is the one part that still arrives later. ## Capture is opt-in -Recording is **off by default**. Delivered prompts can contain secrets, and the -deletion UI is not shipped yet, so nothing is stored unless you explicitly opt in: +Recording is **off by default**. Delivered prompts can contain secrets, so +nothing is stored unless you explicitly opt in: ```bash -GENTLE_PI_HISTORY_CAPTURE=1 pi +GENTLE_PI_HISTORY_ENABLE=1 pi ``` - Enabled by `1`, `true`, or `on` (case-insensitive). Unset, empty, or any other @@ -51,7 +51,8 @@ cwd; `` is a per-process UUID. Each line is one delivered prompt: ``` UI command-like prompts (`/name ...`) and empty lines are never stored. Later -slices add the rebuildable `seed.jsonl`, scope drains/deletes, and GC. +slices added the rebuildable `seed.jsonl` and the scope drains/deletes behind +the selector; GC is still to come. ## Who can read them @@ -64,41 +65,49 @@ Treat the store as sensitive: it holds your prompts verbatim. ## What disabling capture does Turning the switch off only stops **new** captures. Nothing is deleted: files -already written — and the registry entry — stay on disk until you remove them or -the deletion UI ships. To erase the store manually while capture is off (or pi -is not running): +already written — and the registry entry — stay on disk until you remove them. +Individual prompts can be deleted from the history selector while capture is +on (see "Delete" below); the store directory itself is removed by hand: ```bash rm -rf ~/.pi/agent/history # whole store rm -rf ~/.pi/agent/history/projects/ # one project (see registry.json) ``` -## Delete vs hide +## Delete -The selector's delete key (`ctrl+shift+backspace`) is a two-step -confirmation: the first press **arms** the delete for the selected row and -shows what it will do in the footer (the row highlights); the second press -executes it. Any other key or cancel disarms without deleting. +The selector's delete key (`ctrl+shift+backspace`) is a two-step y/n +confirmation: + +1. The first press **arms** the delete for the selected row: the footer + shows "Delete this prompt from history (y/n)? Prompt stays in session + log" and the row highlights in red. +2. While armed, the next key decides: `y` executes the delete, `n` or + `Esc` cancels, and any other key is ignored — nothing is typed into the + search box and the overlay stays open. What a delete does depends on where the prompt came from: - **Editor-stored prompts** (captured into the store's `.jsonl` files) are - deleted physically: every copy is removed from the store in one atomic - rewrite per affected file. -- **Session-derived prompts** (seeded from past transcripts) can only be - hidden: session transcripts are immutable, so the delete writes a - **tombstone** (`hidden.json`) that keeps the prompt out of the list. The - original stays in the transcript file. - -Both flows therefore end with a tombstone — otherwise the next merge would -re-supply the prompt from transcripts. Write failures surface an error -toast and never lie about state: a failed store delete removes nothing and -aborts ("Store delete failed; nothing was removed."), while a failed -tombstone write after a store delete leaves the store row removed but the -prompt may reappear from session transcripts. - -The tombstone file fails closed: if `hidden.json` exists but cannot be -trusted (unreadable, corrupt, wrong shape), history is blocked with a -recovery warning instead of resurfacing hidden prompts, and deletes refuse -to silently rewrite it. Recovery is explicit — restore the file or delete -it yourself (hidden prompts may then reappear). + deleted: every stored copy is removed from the store in one atomic + rewrite per affected file. The session transcript keeps the original. +- **Session-derived prompts** (seeded from past transcripts) are + read-only: a delete press on them does nothing. Session transcripts are + immutable and owned by Pi core — the extension never writes them. + +Failures surface an error toast and never lie about state: a failed store +delete removes nothing and aborts ("Store delete failed; nothing was +removed."), while a failed tombstone write after a store delete leaves the +store row removed but the prompt may reappear from session transcripts +("Deleted from the store, but hiding failed — the prompt may reappear +from session transcripts."). + +The tombstone file (`hidden.json`) is a bounded cache, not a retention +guarantee: it holds at most **1000 keys** in recency order (oldest first, +newest last); hiding a 1001st prompt drops the oldest key, and that prompt +may reappear in the list and can be deleted again. The file still fails +closed: if `hidden.json` exists but cannot be trusted (unreadable, corrupt, +wrong shape), history is blocked with a recovery warning instead of +resurfacing hidden prompts, and deletes refuse to silently rewrite it. +Recovery is explicit — restore the file or delete it yourself (hidden +prompts may then reappear). diff --git a/extensions/history/hide-prompts.ts b/extensions/history/hide-prompts.ts index 6d91a57d6..08d39eb7e 100644 --- a/extensions/history/hide-prompts.ts +++ b/extensions/history/hide-prompts.ts @@ -9,6 +9,14 @@ import { promptDedupKey } from "./selector-helpers.ts"; /** Name of the tombstone file inside the injected state dir (spec C4). */ const HIDE_FILE_NAME = "hidden.json"; +/** + * Retention cap for hidden.json (slice-05 D5): the tombstone file is a + * rebuildable derived cache, not a retention guarantee, so it holds at + * most this many keys in recency order; hiding past the cap drops the + * OLDEST keys from the front. + */ +export const HIDE_FILE_MAX_ENTRIES = 1000; + /** * Shared recovery warning for a file that exists but cannot be trusted * (spec C4, fail-closed READ half): toast-suitable, names hidden.json, and @@ -50,6 +58,8 @@ export type HiddenRead = * safe empty case and reads `trusted` with no keys. A valid array is * trusted; junk items inside it are ignored, never trusted. Keys are * `promptDedupKey` strings written by `hidePrompt`; the call never throws. + * A valid array's stored order is preserved (the recency order — oldest + * first — that `hidePrompt` maintains and caps). */ export function readHiddenPrompts(stateDir: string): HiddenRead { let raw: string; @@ -91,13 +101,20 @@ export function readHiddenPrompts(stateDir: string): HiddenRead { * Write the tombstone key for `text` into `stateDir/hidden.json` — the * WRITE half of the hide-file contract (spec C4). The key is the shared * `promptDedupKey` (byte-match normative with the merge filter — never a - * re-implementation); the set compacts on write and persists as a SORTED - * array via the shared atomic tmp+rename writer. An untrusted existing file - * is never silently reset (a clean rewrite would clear the blocked state - * one hide later): hidePrompt refuses with the recovery warning until the - * user restores or deletes the file. A missing file is the clean baseline; - * any write failure returns an error object for the delete-flow toast; the - * call never throws. + * re-implementation). The file array is RECENCY-ordered — oldest key + * first, newest key appended last — and re-hiding an existing key + * refreshes it to the end (delete + add, since Set.add on a present + * member keeps its old position). The file is capped at + * `HIDE_FILE_MAX_ENTRIES` (1000): after the append, keys drop from the + * FRONT until the file fits, so hidden.json stays a bounded cache — a + * dropped (oldest) prompt may reappear in the list and can be deleted + * again. Keys persist in that insertion order — NO sort — via the shared + * atomic tmp+rename writer. An untrusted existing file is never silently + * reset (a clean rewrite would clear the blocked state one hide later): + * hidePrompt refuses with the recovery warning until the user restores or + * deletes the file. A missing file is the clean baseline; any write + * failure returns an error object for the delete-flow toast; the call + * never throws. */ export function hidePrompt(stateDir: string, text: string): HideResult { const read = readHiddenPrompts(stateDir); @@ -105,10 +122,19 @@ export function hidePrompt(stateDir: string, text: string): HideResult { // Refuse without writing: never reset the untrusted state silently. return { status: "error", message: read.message }; } - read.keys.add(promptDedupKey(text)); + // Recency order (slice-05 D5): the set iterates in stored file order + // (oldest first); delete+add refreshes a re-hidden key to the END. + const key = promptDedupKey(text); + read.keys.delete(key); + read.keys.add(key); + // Cap: drop the OLDEST keys from the front once over the limit. + const ordered = [...read.keys]; + if (ordered.length > HIDE_FILE_MAX_ENTRIES) { + ordered.splice(0, ordered.length - HIDE_FILE_MAX_ENTRIES); + } const written = writeJsonAtomic( path.join(stateDir, HIDE_FILE_NAME), - [...read.keys].sort(), + ordered, ); return written ? { status: "written" } diff --git a/extensions/history/index.ts b/extensions/history/index.ts index 240a2ead7..4f91729a7 100644 --- a/extensions/history/index.ts +++ b/extensions/history/index.ts @@ -7,11 +7,11 @@ // inside getWriter). Deletion (slice 5) is wired here; GC/compaction // (slice 6) arrives in a later slice. // -// Capture is OPT-IN while the deletion/privacy behavior is unshipped: -// nothing is recorded unless GENTLE_PI_HISTORY_CAPTURE=1|true|on. With the -// switch off the handler is a no-op — no registry entry, no files, and -// prompts are never written. Unsetting the switch only stops NEW captures; -// files already written stay on disk (docs/prompt-history.md). +// Capture is OPT-IN: nothing is recorded unless +// GENTLE_PI_HISTORY_ENABLE=1|true|on. With the switch off the handler is a +// no-op — no registry entry, no files, and prompts are never written. +// Unsetting the switch only stops NEW captures; files already written stay +// on disk (docs/prompt-history.md). import { join } from "node:path"; import { homedir } from "node:os"; @@ -43,7 +43,7 @@ import { clampPreviewOffset, clampSelectedIndex, deleteConfirmFooterText, - deleteConfirmNext, + deleteConfirmStep, deletionActionsFor, dedupePromptEntries, EDITOR_HIDE_FAILED_TEXT, @@ -93,7 +93,7 @@ const PREVIEW_WHEEL_Y_FIRST = 17; const PREVIEW_WHEEL_Y_LAST = 26; // Default selector footer line (PR #1393): shown whenever a delete is not -// armed; the armed state swaps it for the scope-aware confirmation copy. +// armed; the armed state swaps it for the confirmation copy. const SELECTOR_FOOTER_HELP = "↑↓ move • PgUp/PgDn page • tab scope • enter select and quit • ctrl+shift+↑/↓ preview • ctrl+shift+backspace delete • esc cancel"; @@ -281,9 +281,10 @@ class PromptHistorySelector extends Container implements Focusable { /** Scroll offset into wrappedPreviewLines for the preview viewport. */ private previewScrollOffset = 0; /** - * Two-step delete confirmation (PR #1393): armed by the first - * ctrl+shift+backspace press; the second press executes, and any other - * key or cancel disarms. Nothing is deleted on the arming press. + * Modal delete confirmation (PR #1393 follow-up): armed by the first + * ctrl+shift+backspace press; while armed, y executes, n/Esc cancels, + * and every other key is swallowed. Nothing is deleted on the arming + * press. */ private confirmArmed = false; @@ -591,25 +592,45 @@ class PromptHistorySelector extends Container implements Focusable { this.applyFilter(this.searchInput.getValue()); } - /** Delete the currently selected prompt from disk and refresh the list. */ + /** + * Delete-combo entry (slice-05 D3): the FIRST press arms the modal + * confirm for the selected row; while armed, the modal router in + * handleInput calls executeDelete() on `y`. Session-derived rows are + * read-only (slice-05 D1): a delete press on one is a silent no-op. + */ private deleteCurrent(): void { const selected = this.filteredRecords[this.selectedIndex]; if (!selected) return; - // Two-step confirm (PR #1393 review): the first ctrl+shift+backspace - // press ARMS the delete for the selected row — a scope-aware - // confirmation line in the footer plus an error-colored highlight — - // and executes NOTHING; the SECOND press runs the flow below. Any - // other key or cancel disarms (handleInput / handleMouse). - const step = deleteConfirmNext(this.confirmArmed, true); - this.confirmArmed = step.armed; - this.refreshDeleteFooter(); - if (!step.execute) { - this.rebuildList(); // repaint the armed-row highlight + // Session rows are read-only: session transcripts are immutable and + // owned by Pi core — the extension never deletes from or writes to + // them. Silent no-op: no arm, no footer change, no tombstone. + if ((selected.source ?? "editor") === "session") return; + + if (!this.confirmArmed) { + this.armDelete(); return; } + this.executeDelete(); + } + + /** Arm the confirm: footer copy + error-colored row, nothing executes. */ + private armDelete(): void { + this.confirmArmed = true; + this.refreshDeleteFooter(); + this.rebuildList(); // repaint the armed-row highlight + } + + /** The executing half of the delete: leave the armed state, then mutate. */ + private executeDelete(): void { + // Leave the armed state first: help footer back, highlight dropped. + this.confirmArmed = false; + this.refreshDeleteFooter(); this.rebuildList(); // drop the highlight before the flow mutates rows + const selected = this.filteredRecords[this.selectedIndex]; + if (!selected) return; + // C4 delete flows (design §F): the record's provenance decides the // actions via the pure planner; module constants are used directly. const actions = deletionActionsFor(selected.source ?? "editor"); @@ -663,16 +684,14 @@ class PromptHistorySelector extends Container implements Focusable { this.applyFilter(this.searchInput.getValue()); } - /** Footer line: scope-aware confirm copy while armed, help otherwise. */ + /** Footer line: confirm copy while armed, help otherwise. */ private refreshDeleteFooter(): void { if (!this.confirmArmed) { this.footerRow.setText(this.theme.fg("dim", SELECTOR_FOOTER_HELP)); return; } - const selected = this.filteredRecords[this.selectedIndex]; - const source = selected?.source ?? "editor"; this.footerRow.setText( - this.theme.fg("warning", deleteConfirmFooterText(source)), + this.theme.fg("warning", deleteConfirmFooterText()), ); } @@ -825,12 +844,24 @@ class PromptHistorySelector extends Container implements Focusable { } handleInput(data: string): void { - const kb = getKeybindings(); - // Any key other than the delete combo disarms a pending confirmation - // (PR #1393) BEFORE its own action runs — esc, arrows, typing, tab. - if (this.confirmArmed && !matchesKey(data, "ctrl+shift+backspace")) { - this.disarmDeleteConfirm(); + // Modal armed confirm (slice-05 D3): while a delete is armed the pure + // router consumes EVERY key — y executes, n/Esc cancels (esc must NOT + // close the overlay here), anything else stays armed and is swallowed + // — so no key reaches the dispatch table or the search input. When not + // armed, behavior is unchanged. + if (this.confirmArmed) { + const step = deleteConfirmStep( + this.confirmArmed, + matchesKey(data, "ctrl+shift+backspace"), + matchesKey(data, "escape"), + data, + ); + if (step.execute) this.executeDelete(); + else if (step.cancel) this.disarmDeleteConfirm(); + this.tui.requestRender(); + return; } + const kb = getKeybindings(); let handled = false; for (const { match, handler } of this.dispatch) { if (match(data, kb)) { @@ -1062,7 +1093,7 @@ async function openHistorySelector( // bootstrap, or any store/registry write as a side effect of opening it. if (!captureEnabled()) { ctx.ui.notify( - "Prompt history capture is off — set GENTLE_PI_HISTORY_CAPTURE=1 to enable it.", + "Prompt history capture is off — set GENTLE_PI_HISTORY_ENABLE=1 to enable it.", "warning", ); return; @@ -1114,13 +1145,13 @@ export interface HistoryDeps { } /** - * Strict opt-in: capture stays off unless GENTLE_PI_HISTORY_CAPTURE is + * Strict opt-in: capture stays off unless GENTLE_PI_HISTORY_ENABLE is * explicitly 1, true, or on (case-insensitive). The same switch is the * disable path — unsetting it stops new captures; files already on disk - * are left untouched until the deletion tooling lands. + * are left untouched (deletes run from the selector while capture is on). */ export function captureEnabled(env: NodeJS.ProcessEnv = process.env): boolean { - const value = env.GENTLE_PI_HISTORY_CAPTURE?.trim().toLowerCase(); + const value = env.GENTLE_PI_HISTORY_ENABLE?.trim().toLowerCase(); return value === "1" || value === "true" || value === "on"; } diff --git a/extensions/history/selector-helpers.ts b/extensions/history/selector-helpers.ts index 54364bd86..e26b0fa48 100644 --- a/extensions/history/selector-helpers.ts +++ b/extensions/history/selector-helpers.ts @@ -240,11 +240,13 @@ export function loadedCountAfterDelete( * Pure delete-flow planner (spec C4, design §F): maps a record's provenance * to the two delete actions. "editor" deletes from the editor store on disk * AND writes the tombstone (twin suppression — the session copy of the same - * text would otherwise resurface next open); "session" writes the tombstone - * only (session transcripts are NEVER written). Takes source as a plain - * parameter (no member reads — the T23 provenance pin keeps overlay - * consumers source-agnostic outside deleteCurrent); the only consumer is - * deleteCurrent in history/index.ts. + * text would otherwise resurface next open); "session" plans NOTHING — + * session-derived rows are read-only (slice-05 D1): transcripts are + * immutable and owned by Pi core, so the extension never deletes from or + * writes to them, and deleteCurrent guards the source before the flow. + * Takes source as a plain parameter (no member reads — the T23 provenance + * pin keeps overlay consumers source-agnostic outside deleteCurrent); the + * only consumer is the delete flow in history/index.ts. */ export function deletionActionsFor( source: PromptSource, @@ -252,45 +254,58 @@ export function deletionActionsFor( if (source === "editor") { return { deleteFromEditorStore: true, writeTombstone: true }; } - return { deleteFromEditorStore: false, writeTombstone: true }; + return { deleteFromEditorStore: false, writeTombstone: false }; } /** - * One transition of the two-step delete confirmation (PR #1393 review): - * the first delete-key press ARMS the delete for the selected row and - * executes nothing; the SECOND press executes; any other key disarms. The - * selector's deleteCurrent and handleInput both route through this pure - * step so the arm/execute/disarm machine has exactly one definition. + * One transition of the modal delete confirmation (PR #1393 follow-up, + * slice-05 D3). Disarmed, only the delete combo matters: it ARMS the + * confirm and executes nothing. While armed the confirm is MODAL: `y`/`Y` + * executes, `n`/`N`/Esc cancels, and every other key — including a second + * press of the combo — is swallowed with the confirm still armed (nothing + * reaches the dispatch table or the search input). The TUI keybinding + * matches (ctrl+shift+backspace, escape) are computed by the caller via + * matchesKey and passed as plain booleans so this router stays pure and + * testable without the TUI; the y/n semantics read the raw data here. + * ONE definition: the selector's handleInput routes every armed-state key + * through this function. */ export interface DeleteConfirmStep { /** The armed state AFTER this transition. */ armed: boolean; - /** True only on the second delete-key press — the executing press. */ + /** True only when `y`/`Y` confirms the armed delete — run the flow. */ execute: boolean; + /** True when `n`/`N`/Esc cancels — disarm and resume normal input. */ + cancel: boolean; } -export function deleteConfirmNext( +export function deleteConfirmStep( armed: boolean, isDeleteKey: boolean, + isEscapeKey: boolean, + data: string, ): DeleteConfirmStep { - if (!isDeleteKey) return { armed: false, execute: false }; - if (armed) return { armed: false, execute: true }; - return { armed: true, execute: false }; + if (!armed) { + return isDeleteKey + ? { armed: true, execute: false, cancel: false } + : { armed: false, execute: false, cancel: false }; + } + if (data === "y" || data === "Y") { + return { armed: false, execute: true, cancel: false }; + } + if (data === "n" || data === "N" || isEscapeKey) { + return { armed: false, execute: false, cancel: true }; + } + return { armed: true, execute: false, cancel: false }; } /** - * Scope-aware confirmation copy shown in the footer while a delete is - * armed (PR #1393): the two provenances have different semantics and the - * copy must say which one is about to run, in one line. Editor-stored - * prompts are removed from the store physically AND hidden from history; - * session-derived prompts can only be hidden (transcripts are immutable), - * so the original stays in the session transcript. + * Confirmation copy shown in the footer while a delete is armed (PR + * #1393): one line, one variant — a y/n question carrying the standing + * guarantee that the session log keeps the original either way. */ -export function deleteConfirmFooterText(source: PromptSource): string { - if (source === "editor") { - return "Delete stored prompt? Removes every copy from the store and hides it from history. Session transcripts keep the original."; - } - return "Hide from history? The original stays in the session transcript; a tombstone keeps it out of this list."; +export function deleteConfirmFooterText(): string { + return "Delete this prompt from history (y/n)? Prompt stays in session log"; } /** diff --git a/tests/history-delete-backfill.test.ts b/tests/history-delete-backfill.test.ts index e5fcd3bd8..5abd6c1ad 100644 --- a/tests/history-delete-backfill.test.ts +++ b/tests/history-delete-backfill.test.ts @@ -47,14 +47,14 @@ test("loadedCountAfterDelete bottoms out at 0 on the terminal delete (AC-L4-3)", // T11 — defensive degenerate row: an empty window stays 0 even when counts // disagree: (0, 5) decrements to −1, −1 < 5, so min(−1 + 1, 5) = 0. -// Unreachable via deleteCurrent (a delete implies a selected row inside the +// Unreachable via executeDelete (a delete implies a selected row inside the // loaded prefix) — pinned as C4's defensive bound. test("loadedCountAfterDelete is defensive for an empty window (AC-L4-1)", () => { assert.equal(loadedCountAfterDelete(0, 5), 0); }); -// T11 — AC-L4-1 + AC-L4-4 (source-parse): ordering shape inside deleteCurrent +// T11 — AC-L4-1 + AC-L4-4 (source-parse): ordering shape inside executeDelete // — the bookkeeping call sits strictly between the existing splice and the // trailing applyFilter, INSIDE the existing `if (idx !== -1)` guarded block, // and the non-`deleted` early return still precedes every mutation @@ -65,11 +65,11 @@ const selectorSource = fs.readFileSync( "utf8", ); -test("deleteCurrent splices, backfills, then re-filters — inside the guarded block (AC-L4-1, AC-L4-4)", () => { - const decl = selectorSource.indexOf("private deleteCurrent("); - assert.ok(decl >= 0, "deleteCurrent should exist"); +test("executeDelete splices, backfills, then re-filters — inside the guarded block (AC-L4-1, AC-L4-4)", () => { + const decl = selectorSource.indexOf("private executeDelete(): void {"); + assert.ok(decl >= 0, "executeDelete should exist"); const end = selectorSource.indexOf("\n }", decl); - assert.ok(end > decl, "deleteCurrent's body should close"); + assert.ok(end > decl, "executeDelete's body should close"); const body = selectorSource.slice(decl, end); const earlyReturnAt = body.indexOf("if (removed === 0) return;"); @@ -114,19 +114,21 @@ test("deleteCurrent splices, backfills, then re-filters — inside the guarded b ); }); -// Slice 5 scenario pins (porting contract): the tombstone-always rule and -// the partial-failure toast path. The dev suite pins the planner + these -// deleteCurrent branch shapes in hide-prompts.test.ts (T27/T28); this file +// Slice 5 scenario pins (porting contract): the editor-path tombstone rule +// and the partial-failure toast path. The dev suite pins the planner + +// these delete-flow branch shapes in delete-confirm.test.ts; this file // carries the delete-flow source-parse half so the slice-5 branch stays -// pinned inside the delete slice's own tests. - -test("deletionActionsFor always plans a tombstone — session provenance deletes nothing from disk", () => { - // Session/seed-born records: tombstone ONLY (transcripts and the seed are - // never rewritten by a delete) — the tombstone is what keeps the deleted - // prompt from resurfacing on the next drain. +// pinned inside the delete slice's own tests. The mutation flow lives in +// executeDelete() (slice-05 D3 split), so the parse targets that method. + +test("deletionActionsFor plans a store delete + tombstone for editor rows and NOTHING for session rows", () => { + // Session/seed-born records are READ-ONLY (slice-05 D1): no store delete + // and no tombstone — deleteCurrent guards the source before the flow, so + // a transcript-born prompt is never written or deleted by this + // extension. assert.deepEqual(deletionActionsFor("session"), { deleteFromEditorStore: false, - writeTombstone: true, + writeTombstone: false, }); // Editor records: disk delete AND tombstone (twin suppression). assert.deepEqual(deletionActionsFor("editor"), { @@ -134,15 +136,15 @@ test("deletionActionsFor always plans a tombstone — session provenance deletes writeTombstone: true, }); - const decl = selectorSource.indexOf("private deleteCurrent("); - assert.ok(decl >= 0, "deleteCurrent should exist"); + const decl = selectorSource.indexOf("private executeDelete(): void {"); + assert.ok(decl >= 0, "executeDelete should exist"); const end = selectorSource.indexOf("\n }", decl); - assert.ok(end > decl, "deleteCurrent's body should close"); + assert.ok(end > decl, "executeDelete's body should close"); const body = selectorSource.slice(decl, end); - // Branch shape: the tombstone write sits OUTSIDE the editor-store guard — - // every provenance lands a tombstone, so an entry that came from the - // seed or a transcript cannot resurface after its delete. + // Branch shape: the tombstone write follows (never sits inside) the + // editor-store guard — the executing path is editor-only, and its hide + // suppresses the session twin that would re-supply the prompt. const editorGuardAt = body.indexOf("if (actions.deleteFromEditorStore)"); assert.ok(editorGuardAt >= 0, "the editor-store guard must exist"); const guardCloseAt = body.indexOf("\n }", editorGuardAt); @@ -156,10 +158,10 @@ test("deletionActionsFor always plans a tombstone — session provenance deletes }); test("a failed hide toasts and only the session path aborts — the editor path still splices", () => { - const decl = selectorSource.indexOf("private deleteCurrent("); - assert.ok(decl >= 0, "deleteCurrent should exist"); + const decl = selectorSource.indexOf("private executeDelete(): void {"); + assert.ok(decl >= 0, "executeDelete should exist"); const end = selectorSource.indexOf("\n }", decl); - assert.ok(end > decl, "deleteCurrent's body should close"); + assert.ok(end > decl, "executeDelete's body should close"); const body = selectorSource.slice(decl, end); const gateAt = body.indexOf('if (hide.status === "error")'); diff --git a/tests/history-delete-confirm.test.ts b/tests/history-delete-confirm.test.ts index fe828f256..c755364d7 100644 --- a/tests/history-delete-confirm.test.ts +++ b/tests/history-delete-confirm.test.ts @@ -4,75 +4,93 @@ import fs from "node:fs"; import { fileURLToPath } from "node:url"; import { deleteConfirmFooterText, - deleteConfirmNext, + deleteConfirmStep, deletionActionsFor, EDITOR_HIDE_FAILED_TEXT, STORE_DELETE_FAILED_TEXT, } from "../extensions/history/selector-helpers.ts"; -// PR #1393 review-fix tests: the two-step delete confirmation in the -// history selector. The first ctrl+shift+backspace press ARMS the delete -// for the selected row (scope-aware confirmation footer + highlighted -// record) and executes NOTHING; the second press executes the -// deletionActionsFor-driven flow; any other key or cancel disarms. +// Slice-05 delete-confirm tests (PR #1393 follow-up): the delete +// confirmation is a MODAL y/n step. The first ctrl+shift+backspace press +// ARMS the delete for the selected row (confirmation footer + highlighted +// record) and executes NOTHING; while armed, y executes, n/Esc cancels, +// and every other key is swallowed with the confirm still armed — nothing +// reaches the dispatch table or the search input. Session-derived rows +// are read-only: a delete press on one is a silent no-op. // // PromptHistorySelector is private to extensions/history/index.ts and // needs the pi-tui runtime graph (openflow-integration.test.ts // discipline), and an executing delete writes the module-constant REAL -// store (~/.pi/agent/history — no injection point), so the confirmation -// DECISION is factored into pure helpers tested here directly, and the -// execution semantics are pinned by source-parse on deleteCurrent -// (delete-backfill.test.ts discipline). No test in this file touches the -// user's real store. +// store (~/.pi/agent/history — no injection point), so the confirm +// DECISION is factored into the pure deleteConfirmStep router tested here +// directly, and the wiring semantics are pinned by source-parse on +// deleteCurrent/armDelete/executeDelete/handleInput (delete-backfill +// discipline). No test in this file touches the user's real store. // --------------------------------------------------------------------------- -// Pure decision machine: arm → execute, disarm on anything else. +// Pure modal router: arm → y executes / n·Esc cancels / rest swallowed. // --------------------------------------------------------------------------- -test("the first delete press arms only — nothing executes (PR #1393)", () => { - assert.deepEqual(deleteConfirmNext(false, true), { - armed: true, - execute: false, - }); +const ARM = { armed: true, execute: false, cancel: false }; +const IDLE = { armed: false, execute: false, cancel: false }; +const EXECUTE = { armed: false, execute: true, cancel: false }; +const CANCEL = { armed: false, execute: false, cancel: true }; + +test("the first delete press arms only — nothing executes, nothing cancels", () => { + assert.deepEqual(deleteConfirmStep(false, true, false, ""), ARM); }); -test("the second delete press executes and rearms-to-idle (PR #1393)", () => { - assert.deepEqual(deleteConfirmNext(true, true), { - armed: false, - execute: true, - }); +test("an unarmed non-delete key is a no-op — the confirm stays out of the way", () => { + assert.deepEqual(deleteConfirmStep(false, false, false, "x"), IDLE); }); -test("any other key disarms without executing; an idle stay stays idle", () => { - assert.deepEqual(deleteConfirmNext(true, false), { - armed: false, - execute: false, - }); - assert.deepEqual(deleteConfirmNext(false, false), { - armed: false, - execute: false, - }); +test("while armed, y (and Y) executes the delete", () => { + assert.deepEqual(deleteConfirmStep(true, false, false, "y"), EXECUTE); + assert.deepEqual(deleteConfirmStep(true, false, false, "Y"), EXECUTE); }); -test("after an executed delete the machine is idle again — a fresh confirm per row", () => { - const first = deleteConfirmNext(false, true); - assert.equal(first.execute, false); - const second = deleteConfirmNext(first.armed, true); - assert.equal(second.execute, true); - // A THIRD press starts a NEW confirmation instead of executing blindly. - assert.deepEqual(deleteConfirmNext(second.armed, true), { - armed: true, - execute: false, - }); +test("while armed, n / N / Esc cancel — the confirm disarms without executing", () => { + assert.deepEqual(deleteConfirmStep(true, false, false, "n"), CANCEL); + assert.deepEqual(deleteConfirmStep(true, false, false, "N"), CANCEL); + assert.deepEqual(deleteConfirmStep(true, false, true, "\x1b"), CANCEL); +}); + +test("while armed, any other key is swallowed and the confirm STAYS armed", () => { + // Plain typing, digits, empty data, arrow-key bytes, and a SECOND + // delete-combo press: none of them execute or cancel. + assert.deepEqual(deleteConfirmStep(true, false, false, "x"), ARM); + assert.deepEqual(deleteConfirmStep(true, false, false, "1"), ARM); + assert.deepEqual(deleteConfirmStep(true, false, false, ""), ARM); + assert.deepEqual(deleteConfirmStep(true, false, false, "\x1b[A"), ARM); + assert.deepEqual(deleteConfirmStep(true, true, false, "\x1b[27;6~"), ARM); +}); + +test("full machine: arm → y executes; a fresh arm is needed per delete", () => { + const armed = deleteConfirmStep(false, true, false, ""); + assert.equal(armed.armed, true); + assert.equal(armed.execute, false); + const done = deleteConfirmStep(armed.armed, false, false, "y"); + assert.equal(done.execute, true); + assert.equal(done.armed, false, "executing leaves the confirm disarmed"); + // After execution the confirm is idle: typing resumes as usual. + assert.deepEqual(deleteConfirmStep(done.armed, false, false, "x"), IDLE); +}); + +test("full machine: arm → n cancels → disarmed without executing", () => { + const armed = deleteConfirmStep(false, true, false, ""); + const cancelled = deleteConfirmStep(armed.armed, false, true, "\x1b"); + assert.equal(cancelled.cancel, true); + assert.equal(cancelled.armed, false); + assert.equal(cancelled.execute, false); }); -// (b) + (c): the executing press composes with the pure planner — an -// editor-source record deletes from the store AND tombstones; a -// session-source record NEVER plans a store delete (tombstone only). +// The executing press composes with the pure planner: an editor-source +// record deletes from the store AND tombstones; a session-source record is +// read-only — the planner plans NOTHING for it (slice-05 D1). -test("second press executes the editor-source plan: store delete + tombstone", () => { - const armed = deleteConfirmNext(false, true); - const step = deleteConfirmNext(armed.armed, true); +test("y on an editor row runs the store-delete + tombstone plan", () => { + const armed = deleteConfirmStep(false, true, false, ""); + const step = deleteConfirmStep(armed.armed, false, false, "y"); assert.equal(step.execute, true); assert.deepEqual(deletionActionsFor("editor"), { deleteFromEditorStore: true, @@ -80,38 +98,23 @@ test("second press executes the editor-source plan: store delete + tombstone", ( }); }); -test("a session-source record never plans a store delete — tombstone only", () => { - const armed = deleteConfirmNext(false, true); - const step = deleteConfirmNext(armed.armed, true); - assert.equal(step.execute, true); - const actions = deletionActionsFor("session"); - assert.equal(actions.deleteFromEditorStore, false); - assert.equal(actions.writeTombstone, true); +test("session rows are read-only: the planner plans nothing for them", () => { + assert.deepEqual(deletionActionsFor("session"), { + deleteFromEditorStore: false, + writeTombstone: false, + }); }); // --------------------------------------------------------------------------- -// Copy: the armed footer distinguishes the two semantics in one line; the -// failure toasts state exactly what state remains. +// Copy: one confirmation line for every row; the failure toasts state +// exactly what state remains. // --------------------------------------------------------------------------- -test("the editor confirmation names the physical delete AND the hide", () => { - const text = deleteConfirmFooterText("editor"); - assert.ok(!text.includes("\n"), "the confirmation stays on one line"); - assert.ok(text.includes("Delete stored prompt?")); - assert.ok(text.includes("Removes every copy from the store")); - assert.ok(text.includes("hides it from history")); - assert.ok( - text.includes("Session transcripts keep the original"), - "the immutability caveat must be stated", - ); -}); - -test("the session confirmation names the hide-only semantics", () => { - const text = deleteConfirmFooterText("session"); +test("the confirmation footer is the single y/n line (PR #1393)", () => { + const text = deleteConfirmFooterText(); assert.ok(!text.includes("\n"), "the confirmation stays on one line"); - assert.ok(text.includes("Hide from history?")); - assert.ok(text.includes("The original stays in the session transcript")); - assert.ok(text.includes("tombstone keeps it out of this list")); + assert.ok(text.includes("Delete this prompt from history (y/n)?")); + assert.ok(text.includes("Prompt stays in session log")); }); test("failure toasts state the remaining state exactly (PR #1393)", () => { @@ -129,8 +132,8 @@ test("failure toasts state the remaining state exactly (PR #1393)", () => { }); // --------------------------------------------------------------------------- -// Source-parse: the execution semantics inside deleteCurrent (the selector -// class itself is not instantiable under node:test — see the header note). +// Source-parse: the wiring inside the selector (the class itself is not +// instantiable under node:test — see the header note). // --------------------------------------------------------------------------- const selectorSource = fs.readFileSync( @@ -138,127 +141,132 @@ const selectorSource = fs.readFileSync( "utf8", ); -function deleteCurrentBody(): string { - const decl = selectorSource.indexOf("private deleteCurrent("); - assert.ok(decl >= 0, "deleteCurrent should exist"); +/** Slice out a 2-space-indented method body by its exact signature. */ +function methodBodyOf(signature: string): string { + const decl = selectorSource.indexOf(signature); + assert.ok(decl >= 0, `${signature} should exist`); const end = selectorSource.indexOf("\n }", decl); - assert.ok(end > decl, "deleteCurrent's body should close"); + assert.ok(end > decl, `${signature}'s body should close`); return selectorSource.slice(decl, end); } -test("(b) the arming press returns before ANY mutation of rows or disk", () => { +function deleteCurrentBody(): string { + return methodBodyOf("private deleteCurrent(): void {"); +} + +function executeDeleteBody(): string { + return methodBodyOf("private executeDelete(): void {"); +} + +function handleInputBody(): string { + return methodBodyOf("handleInput(data: string): void {"); +} + +test("deleteCurrent: session rows no-op FIRST — before any arm or mutation", () => { const body = deleteCurrentBody(); - const stepAt = body.indexOf("const step = deleteConfirmNext(this.confirmArmed, true);"); - assert.ok(stepAt >= 0, "the transition must route through the pure helper"); - const armReturnAt = body.indexOf("if (!step.execute)"); - assert.ok(armReturnAt > stepAt, "the execute gate must follow the step"); - const editorGuardAt = body.indexOf("if (actions.deleteFromEditorStore)"); - const spliceAt = body.indexOf("this.records.splice("); - const hideAt = body.indexOf("hidePrompt("); + const guardAt = body.indexOf('(selected.source ?? "editor") === "session"'); + assert.ok(guardAt >= 0, "the session read-only guard must exist"); + const guardReturnAt = body.indexOf("return;", guardAt); + assert.ok(guardReturnAt > guardAt, "the session guard must return"); + // The guard precedes the arm/execute split and every mutation helper. + const armAt = body.indexOf("this.armDelete()"); + const executeAt = body.indexOf("this.executeDelete()"); + assert.ok(armAt > guardAt, "the session guard must precede arming"); + assert.ok(executeAt > guardAt, "the session guard must precede executing"); + // The combo entry stays two-step: unarmed arms, armed executes. assert.ok( - armReturnAt < editorGuardAt && - armReturnAt < spliceAt && - armReturnAt < hideAt, - "arming must precede the store flow, the splice, and the tombstone", + body.includes("if (!this.confirmArmed)"), + "the unarmed press must arm", ); -}); - -test("(c) the store deletes live only inside the editor-source guard", () => { - const body = deleteCurrentBody(); - const guardAt = body.indexOf("if (actions.deleteFromEditorStore)"); - assert.ok(guardAt >= 0, "the editor-store guard must exist"); - const guardCloseAt = body.indexOf("\n }", guardAt); - assert.ok(guardCloseAt > guardAt, "the editor-store guard must close"); - - for (const call of ["deleteFromGlobal(", "deleteFromProject("]) { - const at = body.indexOf(call); - assert.ok(at >= 0, `${call} must exist`); - assert.ok( - at > guardAt && at < guardCloseAt, - `${call} must sit inside the editor guard — a session record never reaches it`, - ); - } - // The tombstone write follows the guard: EVERY provenance lands one. - const hideAt = body.indexOf("hidePrompt("); assert.ok( - hideAt > guardCloseAt, - "the tombstone must follow (not sit inside) the editor-store guard", + armAt < executeAt, + "armDelete is the unarmed branch, executeDelete the armed one", ); }); -test("(d) a thrown store delete toasts the failure copy and aborts", () => { - const body = deleteCurrentBody(); - const tryAt = body.indexOf("try {"); - const catchAt = body.indexOf("} catch {", tryAt); - assert.ok(tryAt >= 0 && catchAt > tryAt, "the store calls must be wrapped"); - const catchEnd = body.indexOf("\n }", catchAt); - const catchBody = body.slice(catchAt, catchEnd); - assert.ok( - catchBody.includes(`this.onNotify?.(STORE_DELETE_FAILED_TEXT, "error")`), - "the catch must toast the store-failure copy", - ); +test("armDelete only paints: armed + footer + rebuild — never mutates", () => { + const body = methodBodyOf("private armDelete(): void {"); + assert.ok(body.includes("this.confirmArmed = true;")); + assert.ok(body.includes("this.refreshDeleteFooter()")); + assert.ok(body.includes("this.rebuildList()")); + assert.ok(!body.includes("hidePrompt("), "arming never writes a tombstone"); assert.ok( - catchBody.includes("return;"), - "the catch must abort the flow", + !body.includes("this.records.splice("), + "arming never mutates rows", ); - // The abort precedes the tombstone write: a failed store delete leaves - // NO tombstone behind. - const hideAt = body.indexOf("hidePrompt("); - assert.ok(catchAt < hideAt, "the catch must precede the hide write"); }); -test("(e) a hide error toasts the session message and aborts — the editor path proceeds to the splice", () => { - const body = deleteCurrentBody(); - const gateAt = body.indexOf('if (hide.status === "error")'); - assert.ok(gateAt >= 0, "hide errors must be gated"); +test("executeDelete leaves the armed state before any mutation", () => { + const body = executeDeleteBody(); + const disarmAt = body.indexOf("this.confirmArmed = false;"); + assert.ok(disarmAt >= 0, "executing must leave the armed state"); + assert.ok(body.includes("this.refreshDeleteFooter()")); + const actionsAt = body.indexOf("deletionActionsFor("); + const hideAt = body.indexOf("hidePrompt("); const spliceAt = body.indexOf("this.records.splice("); - assert.ok(gateAt < spliceAt, "the hide gate must precede the splice"); - const gate = body.slice(gateAt, spliceAt); - - // Session path: toast the recovery message and abort. - const abortGuardAt = gate.indexOf("if (!actions.deleteFromEditorStore)"); assert.ok( - abortGuardAt >= 0, - "the session-path early return must be exclusive", + disarmAt < actionsAt && actionsAt < hideAt && hideAt < spliceAt, + "disarm → plan → tombstone → splice ordering", ); - const abortBody = gate.slice(abortGuardAt, gate.indexOf("}", abortGuardAt)); +}); + +test("while armed, handleInput is modal: the router runs FIRST and returns", () => { + const body = handleInputBody(); + const modalAt = body.indexOf("if (this.confirmArmed) {"); + assert.ok(modalAt >= 0, "the modal branch must exist"); assert.ok( - abortBody.includes('this.onNotify?.(hide.message, "error")'), - "the session path must toast the hide error itself", + body.includes("deleteConfirmStep("), + "the armed branch routes through the pure router", ); - assert.ok(abortBody.includes("return;"), "the session path must abort"); assert.ok( - !gate.slice(0, abortGuardAt).includes("return;"), - "no unconditional abort before the provenance split", + body.includes('matchesKey(data, "ctrl+shift+backspace")') && + body.includes('matchesKey(data, "escape")'), + "the combo and escape matches come from the TUI keymap", + ); + const executeAt = body.indexOf("this.executeDelete()"); + const cancelAt = body.indexOf("this.disarmDeleteConfirm()"); + assert.ok(executeAt > modalAt, "y must execute inside the modal branch"); + assert.ok(cancelAt > modalAt, "n/Esc must disarm inside the modal branch"); + // Full swallow: the modal branch RETURNS before the dispatch loop and + // the search fallthrough can see the key — esc cannot close the overlay. + const modalReturnAt = body.indexOf("return;", modalAt); + assert.ok(modalReturnAt > modalAt, "the modal branch must return"); + const loopAt = body.indexOf( + "for (const { match, handler } of this.dispatch) {", ); + const fallthroughAt = body.indexOf( + "if (!handled) this.forwardToSearch(data);", + ); + assert.ok(loopAt > modalReturnAt, "armed keys never reach dispatch"); + assert.ok(fallthroughAt > modalReturnAt, "armed keys never reach search"); +}); - // Editor path: the store row is already gone — the toast says so, and - // control FALLS THROUGH to the splice (no return between the toast and - // the splice). - const editorToastAt = gate.indexOf(`this.onNotify?.(EDITOR_HIDE_FAILED_TEXT, "error")`); - assert.ok(editorToastAt >= 0, "the editor path must toast the hide failure"); - const gateToSplice = gate.slice(editorToastAt); +test("the old disarm pre-pass is superseded — disarm only on the modal cancel path", () => { + const body = handleInputBody(); assert.ok( - !gateToSplice.includes("return;"), - "the editor path must NOT abort — the splice still runs", + !body.includes('!matchesKey(data, "ctrl+shift+backspace")'), + "the unconditional disarm pre-pass must be gone", ); + const modalAt = body.indexOf("if (this.confirmArmed) {"); + const disarmAt = body.indexOf("this.disarmDeleteConfirm()"); + assert.ok(disarmAt > modalAt, "disarm must sit inside the modal branch"); }); -test("any other key disarms before its own action; a wheel scroll disarms too", () => { - const handleInputAt = selectorSource.indexOf("handleInput(data: string): void {"); - assert.ok(handleInputAt >= 0, "handleInput should exist"); - const inputEnd = selectorSource.indexOf("\n }", handleInputAt); - const inputBody = selectorSource.slice(handleInputAt, inputEnd); - const disarmAt = inputBody.indexOf("this.disarmDeleteConfirm()"); - assert.ok(disarmAt >= 0, "handleInput must disarm a pending confirmation"); +test("Esc while DISARMED still cancels the overlay via the dispatch entry", () => { + const table = selectorSource.slice( + selectorSource.indexOf("private readonly dispatch"), + selectorSource.indexOf("\n ];"), + ); + const cancelAt = table.indexOf('kb.matches(_d, "tui.select.cancel")'); + assert.ok(cancelAt >= 0, "the cancel dispatch entry must stay"); + const entry = table.slice(cancelAt, table.indexOf("},", cancelAt)); assert.ok( - inputBody.includes('!matchesKey(data, "ctrl+shift+backspace")'), - "the delete combo itself must NOT route through the disarm pre-pass", + entry.includes("this.onCancel()"), + "disarmed esc must still close the overlay", ); - // The disarm must happen before the dispatch loop consumes the key. - const loopAt = inputBody.indexOf("for (const { match, handler } of this.dispatch) {"); - assert.ok(disarmAt < loopAt, "the disarm pre-pass must precede dispatch"); +}); +test("a wheel scroll disarms (and never executes) the armed delete", () => { const handleMouseAt = selectorSource.indexOf("override handleMouse("); assert.ok(handleMouseAt >= 0, "handleMouse should exist"); const mouseEnd = selectorSource.indexOf("\n }", handleMouseAt); @@ -267,34 +275,80 @@ test("any other key disarms before its own action; a wheel scroll disarms too", mouseBody.indexOf("this.disarmDeleteConfirm()") >= 0, "a wheel scroll can move the selection off the armed row — it must disarm", ); + assert.ok( + !mouseBody.includes("this.executeDelete()"), + "a wheel scroll must never execute the delete", + ); }); test("the armed state drives the footer copy and the error-colored highlight", () => { - const body = deleteCurrentBody(); + const footerBody = methodBodyOf("private refreshDeleteFooter(): void {"); assert.ok( - body.includes("this.refreshDeleteFooter()"), - "every delete press refreshes the footer", + footerBody.includes("deleteConfirmFooterText()"), + "the armed footer uses the single pure copy (no source argument)", ); - - const footerAt = selectorSource.indexOf("private refreshDeleteFooter(): void {"); - assert.ok(footerAt >= 0, "refreshDeleteFooter should exist"); - const footerEnd = selectorSource.indexOf("\n }", footerAt); - const footerBody = selectorSource.slice(footerAt, footerEnd); assert.ok( - footerBody.includes("deleteConfirmFooterText(source)"), - "the armed footer uses the scope-aware pure copy", + !footerBody.includes("deleteConfirmFooterText" + "(source)"), + "the footer must not route through a source variant", ); assert.ok( footerBody.includes("SELECTOR_FOOTER_HELP"), "disarming restores the help line", ); - const rebuildAt = selectorSource.indexOf("private rebuildListWithWidth(width: number): void {"); - assert.ok(rebuildAt >= 0, "rebuildListWithWidth should exist"); - const rebuildEnd = selectorSource.indexOf("\n }", rebuildAt); - const rebuildBody = selectorSource.slice(rebuildAt, rebuildEnd); + const rebuildBody = methodBodyOf( + "private rebuildListWithWidth(width: number): void {", + ); assert.ok( rebuildBody.includes("this.confirmArmed"), "the armed state repaints the selected row", ); }); + +// --------------------------------------------------------------------------- +// Env rename (slice-05 D4): the opt-in switch is GENTLE_PI_HISTORY_ENABLE. +// --------------------------------------------------------------------------- + +// The old switch name must be gone everywhere; assemble the literal from +// parts so this file stays grep-clean for the rename proof (rg for the old +// env var must return 0 matches). +const legacySwitch = `GENTLE_PI_HISTORY_${"CAPTURE"}`; + +test("captureEnabled reads GENTLE_PI_HISTORY_ENABLE (strict 1/true/on unchanged)", () => { + const decl = selectorSource.indexOf("export function captureEnabled("); + assert.ok(decl >= 0, "captureEnabled should exist"); + const end = selectorSource.indexOf("\n}", decl); + assert.ok(end > decl, "captureEnabled's body should close"); + const body = selectorSource.slice(decl, end); + assert.ok( + body.includes("env.GENTLE_PI_HISTORY_ENABLE"), + "the renamed switch must be read", + ); + assert.ok( + !body.includes(legacySwitch), + "the old switch name must be gone", + ); + assert.ok( + body.includes('?.trim().toLowerCase()'), + "whitespace + case normalization unchanged", + ); + assert.ok( + body.includes('value === "1" || value === "true" || value === "on"'), + "strict 1/true/on opt-in unchanged", + ); +}); + +test("the open-flow warning names GENTLE_PI_HISTORY_ENABLE", () => { + const at = selectorSource.indexOf("Prompt history capture is off"); + assert.ok(at >= 0, "the off-gate warning must exist"); + const lineEnd = selectorSource.indexOf("\n", at); + const line = selectorSource.slice(at, lineEnd); + assert.ok( + line.includes("GENTLE_PI_HISTORY_ENABLE=1"), + `the warning must name the new switch, got: ${line.trim()}`, + ); + assert.ok( + !line.includes(legacySwitch), + "the warning must not name the old switch", + ); +}); diff --git a/tests/history-hide-prompts.test.ts b/tests/history-hide-prompts.test.ts index ccd1f8ea8..88db3e005 100644 --- a/tests/history-hide-prompts.test.ts +++ b/tests/history-hide-prompts.test.ts @@ -9,14 +9,16 @@ import { } from "../extensions/history/hide-prompts.ts"; import { promptDedupKey } from "../extensions/history/selector-helpers.ts"; -// Unit WU4 — tombstone write half + read half (spec C4, design §D6). fs-only -// coverage. The READ half FAILS CLOSED for history: a file that exists but -// cannot be trusted (unreadable, corrupt, wrong shape) reads `untrusted` -// with a recovery warning instead of an empty tombstone set, and the WRITE -// half refuses without a silent rewrite. The dev suite's deleteCurrent -// source-parse pins (T27/T28) and the deletionActionsFor planner pins cover -// the slice-3 selector branch and the slice-5 delete flow; they port with -// those slices. +// Unit WU4 — tombstone write half + read half (spec C4, design §D6; slice-05 +// D5 recency cap). fs-only coverage. The READ half FAILS CLOSED for history: +// a file that exists but cannot be trusted (unreadable, corrupt, wrong +// shape) reads `untrusted` with a recovery warning instead of an empty +// tombstone set, and the WRITE half refuses without a silent rewrite. The +// WRITE half persists keys in RECENCY order (oldest first, newest last, +// never sorted) capped at HIDE_FILE_MAX_ENTRIES (1000, oldest dropped). +// The dev suite's deleteCurrent source-parse pins (T27/T28) and the +// deletionActionsFor planner pins cover the slice-3 selector branch and +// the slice-5 delete flow; they port with those slices. function makeStateDir(name: string): string { return fs.mkdtempSync(path.join(os.tmpdir(), `hide-prompts-${name}-`)); @@ -69,8 +71,9 @@ test("T24 (AC-S4-1): hide keys byte-match promptDedupKey across whitespace, case } const stored = readHideFile(stateDir); assert.ok(Array.isArray(stored), "hidden.json must hold a JSON array"); - // Byte-match: the file holds EXACTLY the shared helper's output, sorted. - assert.deepEqual(stored, texts.map((text) => promptDedupKey(text)).sort()); + // Byte-match: the file holds EXACTLY the shared helper's output, in + // insertion (recency) order — the write half never sorts. + assert.deepEqual(stored, texts.map((text) => promptDedupKey(text))); // The read half agrees. const keys = trustedKeys(stateDir); assert.equal(keys.size, stored.length); @@ -97,6 +100,70 @@ test("T25 (AC-S4-2): duplicate hides compact to one key; a missing file reads tr assert.ok(keys.has(promptDedupKey("same text"))); }); +// D5 — recency order: re-hiding an existing key REFRESHES it to the end +// (newest); the file array is oldest-first, newest-appended-last — the +// write half never sorts. +test("re-hiding an existing key refreshes it to the end (recency order, no sort)", () => { + const stateDir = makeStateDir("recency"); + const keysOf = (texts: string[]) => texts.map((text) => promptDedupKey(text)); + for (const text of ["alpha prompt", "beta prompt", "gamma prompt"]) { + assert.deepEqual(hidePrompt(stateDir, text), { status: "written" }); + } + assert.deepEqual(readHideFile(stateDir), keysOf([ + "alpha prompt", + "beta prompt", + "gamma prompt", + ])); + // Re-hide the oldest key: it moves to the END; the others keep order. + assert.deepEqual(hidePrompt(stateDir, "alpha prompt"), { + status: "written", + }); + assert.deepEqual(readHideFile(stateDir), keysOf([ + "beta prompt", + "gamma prompt", + "alpha prompt", + ])); + assert.equal(trustedKeys(stateDir).size, 3); +}); + +// D5 — cap: hidden.json keeps at most 1000 keys in recency order; the +// 1001st distinct prompt drops the OLDEST key from the front. The file is +// a rebuildable cache, not a retention guarantee — a dropped prompt may +// reappear and be deleted again. +test("the 1001st distinct prompt drops the oldest key — the file keeps exactly 1000", () => { + const stateDir = makeStateDir("cap"); + const oldest = "oldest prompt"; + assert.deepEqual(hidePrompt(stateDir, oldest), { status: "written" }); + for (let i = 1; i <= 999; i++) { + assert.deepEqual(hidePrompt(stateDir, `prompt number ${i}`), { + status: "written", + }); + } + // Exactly at the cap: 1000 keys, oldest first, newest last. + const atCap = readHideFile(stateDir); + assert.equal(atCap.length, 1000); + assert.equal(atCap[0], promptDedupKey(oldest)); + assert.equal(atCap[atCap.length - 1], promptDedupKey("prompt number 999")); + // The 1001st distinct prompt: the front (oldest) drops, the newest lands. + assert.deepEqual(hidePrompt(stateDir, "prompt number 1000"), { + status: "written", + }); + const after = readHideFile(stateDir); + assert.equal(after.length, 1000, "the cap holds the file at exactly 1000"); + assert.equal( + after.includes(promptDedupKey(oldest)), + false, + "the oldest key must be dropped from the front", + ); + assert.equal( + after[after.length - 1], + promptDedupKey("prompt number 1000"), + "the newest key must sit at the end", + ); + // The read half agrees with the capped file. + assert.equal(trustedKeys(stateDir).size, 1000); +}); + // T26 — AC-S4-5: corrupt hidden.json FAILS CLOSED for history reads. The // READ half reports untrusted (corrupt) so callers block history instead of // resurfacing hidden prompts; the WRITE half refuses WITHOUT a silent clean diff --git a/tests/history-off-path.test.ts b/tests/history-off-path.test.ts index ef0eb0701..a9d4f8884 100644 --- a/tests/history-off-path.test.ts +++ b/tests/history-off-path.test.ts @@ -8,7 +8,7 @@ import promptHistoryExtension from "../extensions/history/index.ts"; // The module-level selector gate reads process.env directly (that path has // no deps.env injection); keep the suite hermetic regardless of the ambient // shell so the off-path assertions cannot be flipped by the environment. -delete process.env.GENTLE_PI_HISTORY_CAPTURE; +delete process.env.GENTLE_PI_HISTORY_ENABLE; function makeRoot(): string { return fs.mkdtempSync(path.join(os.tmpdir(), "pi-history-off-")); @@ -86,7 +86,7 @@ test("with capture disabled, the history command imports nothing and warns", asy assert.equal(notifyCalls.length, 1); assert.equal(notifyCalls[0][1], "warning"); assert.ok( - notifyCalls[0][0].includes("GENTLE_PI_HISTORY_CAPTURE"), + notifyCalls[0][0].includes("GENTLE_PI_HISTORY_ENABLE"), `the warning must name the switch, got: ${notifyCalls[0][0]}`, ); // The gate must fire before the drain: no migration, no seed, no store. diff --git a/tests/history-session-writer.test.ts b/tests/history-session-writer.test.ts index bb135582a..2d44a1389 100644 --- a/tests/history-session-writer.test.ts +++ b/tests/history-session-writer.test.ts @@ -147,13 +147,13 @@ test("the extension entry registers exactly the slice-3 wiring surface", () => { test("captureEnabled is a strict opt-in", () => { assert.equal(captureEnabled({}), false); - assert.equal(captureEnabled({ GENTLE_PI_HISTORY_CAPTURE: "0" }), false); - assert.equal(captureEnabled({ GENTLE_PI_HISTORY_CAPTURE: "false" }), false); - assert.equal(captureEnabled({ GENTLE_PI_HISTORY_CAPTURE: "off" }), false); - assert.equal(captureEnabled({ GENTLE_PI_HISTORY_CAPTURE: "yes" }), false); - assert.equal(captureEnabled({ GENTLE_PI_HISTORY_CAPTURE: " 1 " }), true); - assert.equal(captureEnabled({ GENTLE_PI_HISTORY_CAPTURE: "TRUE" }), true); - assert.equal(captureEnabled({ GENTLE_PI_HISTORY_CAPTURE: "On" }), true); + assert.equal(captureEnabled({ GENTLE_PI_HISTORY_ENABLE: "0" }), false); + assert.equal(captureEnabled({ GENTLE_PI_HISTORY_ENABLE: "false" }), false); + assert.equal(captureEnabled({ GENTLE_PI_HISTORY_ENABLE: "off" }), false); + assert.equal(captureEnabled({ GENTLE_PI_HISTORY_ENABLE: "yes" }), false); + assert.equal(captureEnabled({ GENTLE_PI_HISTORY_ENABLE: " 1 " }), true); + assert.equal(captureEnabled({ GENTLE_PI_HISTORY_ENABLE: "TRUE" }), true); + assert.equal(captureEnabled({ GENTLE_PI_HISTORY_ENABLE: "On" }), true); }); test("the capture handler is a no-op unless the user opts in", () => { @@ -167,7 +167,7 @@ test("the capture handler is a no-op unless the user opts in", () => { test("an opted-in session captures delivered prompts", () => { const root = makeRoot(); - const handler = captureHandlerWith({ GENTLE_PI_HISTORY_CAPTURE: "1" }, root); + const handler = captureHandlerWith({ GENTLE_PI_HISTORY_ENABLE: "1" }, root); handler({ prompt: "hello store" }); assert.deepEqual(fileTexts(sessionFilePath(root, CWD, "inst-entry")), [ "hello store", @@ -176,12 +176,12 @@ test("an opted-in session captures delivered prompts", () => { test("disabling capture stops new lines and leaves existing files alone", () => { const root = makeRoot(); - const env: NodeJS.ProcessEnv = { GENTLE_PI_HISTORY_CAPTURE: "true" }; + const env: NodeJS.ProcessEnv = { GENTLE_PI_HISTORY_ENABLE: "true" }; const handler = captureHandlerWith(env, root); handler({ prompt: "kept" }); const file = sessionFilePath(root, CWD, "inst-entry"); assert.equal(fs.existsSync(file), true); - delete env.GENTLE_PI_HISTORY_CAPTURE; + delete env.GENTLE_PI_HISTORY_ENABLE; handler({ prompt: "never written" }); assert.deepEqual(fileTexts(file), ["kept"]); });