From 366b17945cc752ebb6b858fe367b74b3502a043f Mon Sep 17 00:00:00 2001 From: Carolina <26188349+carolitascl@users.noreply.github.com> Date: Fri, 18 Sep 2026 16:11:48 -0300 Subject: [PATCH 01/16] feat(history): history selector TUI and command/shortcut wiring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slice 3/6 of the PR #819 split (maintainer-requested review slices). - selector-helpers: windowing/navigation subset — clamp/visible-range math, move/page selection, lazy-window growth (initial batch, grow triggers, target loading, query full-snapshot), visible-record projection, expanded-history globals hook - index.ts: PromptHistorySelector TUI (fixed-row layout, centered preview pane, search filter, Tab project/global scope toggle, grow-before-move navigation, PgDn catch-up, End full jump, wheel handling over fixed 30-row geometry, width-change pre-clamp), overlay glue (bottom-center anchored ctx.ui.custom factory), drainForScope + recordsFromEntries, wiring for ctrl+shift+r shortcut, history command, and tool_call overlay dismissal - upstream dead code dropped: notifyIndexProgress/activeIndexProgress sink pair (never fired) and unused fs import - deletion is slice 5: no deleteCurrent, no delete dispatch entry, no delete affordance in the footer hint yet - getWriter still performs no migration/seed bootstrap (slice 4); the selector drains live stores only - tests: 57 new node:test cases (cumulative 110/110): windowing math, lazy window growth contracts, preview layout, 11-entry dispatch table, wheel routing, expanded globals, shortcut/command registration surface, open-close flow with fake ctx; superseded slice-1 registration pin updated to the slice-3 wiring surface Gates: cumulative scoped history tests 110/110 green. esbuild bundle parse of the full extension graph clean. Known pre-existing environmental gate failures unchanged. --- extensions/history/index.ts | 889 ++++++++++++++++++++- extensions/history/selector-helpers.ts | 172 ++++ tests/history-command-registration.test.ts | 84 ++ tests/history-dispatch.test.ts | 179 +++++ tests/history-expanded-globals.test.ts | 62 ++ tests/history-lazy-windowing.test.ts | 508 ++++++++++++ tests/history-openflow-integration.test.ts | 143 ++++ tests/history-preview-layout.test.ts | 56 ++ tests/history-selector-windowing.test.ts | 94 +++ tests/history-session-writer.test.ts | 26 +- tests/history-wheel-mouse.test.ts | 242 ++++++ 11 files changed, 2442 insertions(+), 13 deletions(-) create mode 100644 tests/history-command-registration.test.ts create mode 100644 tests/history-dispatch.test.ts create mode 100644 tests/history-expanded-globals.test.ts create mode 100644 tests/history-lazy-windowing.test.ts create mode 100644 tests/history-openflow-integration.test.ts create mode 100644 tests/history-preview-layout.test.ts create mode 100644 tests/history-selector-windowing.test.ts create mode 100644 tests/history-wheel-mouse.test.ts diff --git a/extensions/history/index.ts b/extensions/history/index.ts index 26616733f..9fe2d7893 100644 --- a/extensions/history/index.ts +++ b/extensions/history/index.ts @@ -1,21 +1,77 @@ // SPDX-FileCopyrightText: 2026 ExoPro. Inspired by @jasonish/pi-prompt-history // SPDX-License-Identifier: MIT -// Prompt-history extension entry (slice 1): identity constants, the -// per-instance writer lifecycle, and the before_agent_start capture -// handler. Selector UI, shortcut/command, scope drains, legacy migration -// and seed bootstrap, and GC arrive in later slices. +// Prompt-history extension entry (slice 3): the selector TUI, overlay glue, +// and the shortcut/command wiring over the slice-1 writer and slice-2 +// drains. Legacy migration and seed bootstrap (slice 4), deletion (slice 5), +// and GC/compaction (slice 6) arrive in later slices. -import { randomUUID } from "node:crypto"; -import { homedir } from "node:os"; import { join } from "node:path"; -import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; +import { homedir } from "node:os"; +import { + DynamicBorder, + type ExtensionAPI, + type ShortcutContext, + type Theme, +} from "@earendil-works/pi-coding-agent"; import { appendSessionCapture, + drainGlobal, + drainProject, ensureRegistryEntry, openSessionWriter, type SessionWriterState, } from "./store.ts"; +import { randomUUID } from "node:crypto"; +import { + buildPromptRecords, + filterPrompts, + type PromptEntry, + clampPreviewOffset, + clampSelectedIndex, + dedupePromptEntries, + getVisiblePromptRecords, + initialLoadedCount, + loadedCountForQuery, + loadedCountForTarget, + moveSelectedIndex, + nextLoadedCount, + pageSelectedIndex, + shouldGrowWindow, + withExpandedHistoryGlobals, + type PiHistoryGlobals, + type PromptRecord, +} from "./selector-helpers.ts"; +import { + Container, + type Focusable, + getKeybindings, + Input, + matchesKey, + Text, + type TUI, + type TuiMouseEvent, + truncateToWidth, +} from "@earendil-works/pi-tui"; + +const SHORTCUT = "ctrl+shift+r"; +const MAX_VISIBLE = 10; +const PREVIEW_ROWS = 10; +// Lazy windowing (design §D3; user-tuned 2026-09-08). PRELOAD_BUFFER=2 +// fires growth as the cursor enters the final 2 loaded rows; BATCH_SIZE=10 +// loads exactly one viewport per growth; INITIAL_BATCH=10 paints one +// viewport at open. PRELOAD_BUFFER <= MAX_VISIBLE keeps a jump within one +// viewport covered by the catch-up loop; review all three together. +const INITIAL_BATCH = 10; +const BATCH_SIZE = 10; +const PRELOAD_BUFFER = 3; +// Wheel regions over the fixed 30-row overlay geometry (design §D6): the +// list container renders at rows 5-14 and the preview container at rows +// 17-26; every other row is a consumed no-op. +const LIST_WHEEL_Y_FIRST = 5; +const LIST_WHEEL_Y_LAST = 14; +const PREVIEW_WHEEL_Y_FIRST = 17; +const PREVIEW_WHEEL_Y_LAST = 26; // v2 multi-concurrency store root (design: tmp/multi-concurrency-design.md). const PI_HISTORY_ROOT = join(homedir(), ".pi", "agent", "history"); @@ -24,6 +80,764 @@ const CURRENT_CWD = process.cwd(); // Instance identity: one exclusive capture file per pi process. const INSTANCE_ID = randomUUID(); +// Tombstone state dir: the store root itself (user-directed FINAL): +// ~/.pi/agent/history/hidden.json — one directory for everything. +// Derived state only — deleting the directory restores cold start and +// unhides every prompt; transcripts and the editor store are never written +// here. +const PI_HISTORY_NAV_STATE_DIR = join( + homedir(), + ".pi", + "agent", + "history", +); + +/** Width of the "→ " / " " prefix on each entry line. */ +const ENTRY_PREFIX_WIDTH = 2; + +// --------------------------------------------------------------------------- +// Sanitization +// --------------------------------------------------------------------------- + +/** + * Replace control characters with visible escape notation so the terminal + * renders them as text instead of interpreting them as commands. + * Preserves \n (newlines) and \t (tabs). + */ +function sanitizeForDisplay(text: string): string { + let out = ""; + for (let i = 0; i < text.length; i++) { + const cp = text.codePointAt(i)!; + if (cp === 0x0a) { + out += "\n"; + } else if (cp === 0x09) { + out += "\t"; + } else if (cp < 0x20 || cp === 0x7f) { + out += "\\x" + cp.toString(16).padStart(2, "0"); + } else if (cp >= 0x80 && cp < 0xa0) { + out += "\\x" + cp.toString(16).padStart(2, "0"); + } else { + out += text[i]; + } + if (cp > 0xffff) i++; // skip low surrogate of astral pair + } + return out; +} + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** Keybinding lookup returned by getKeybindings(). */ +interface Keybindings { + matches(data: string, action: string): boolean; +} + +type InputMatcher = (data: string, kb: Keybindings) => boolean; +type InputHandler = () => void; + +interface DispatchEntry { + match: InputMatcher; + handler: InputHandler; +} + +/** Notification sink for selector feedback; an absent callback drops notifications. */ +type SelectorNotify = (message: string, level: "error" | "warning" | "info") => void; + +/** Single rendered row; always occupies exactly one terminal row. */ +class FixedRowText { + private text: string; + private readonly centered: boolean; + + constructor(text: string = "", centered = false) { + this.text = text; + this.centered = centered; + } + + /** Replace the row content in place; padding contract comes from render(). */ + setText(next: string): void { + this.text = next; + } + + invalidate(): void {} + + render(width: number): string[] { + if (width <= 0) return [" "] as string[]; + if (this.text.length === 0) { + // Use a space so the terminal always renders this as a visible row + // and differential rendering correctly detects it as a changed line. + return [" ".repeat(width)] as string[]; + } + const rendered = this.centered + ? (() => { + // Truncate first so an overlong help row can never exceed width, + // then center the truncated copy (design §C hardening). + const truncated = truncateToWidth(this.text, width, "…"); + const visible = truncated.replace(/\x1b\[[0-9;]*m/g, ""); + const pad = Math.max(0, Math.floor((width - visible.length) / 2)); + return " ".repeat(pad) + truncated; + })() + : truncateToWidth(this.text, width, "…"); + // Pad to full terminal width so the overlay fully overwrites + // whatever is beneath it and leaves no ghost characters on dismiss. + return [rendered + " ".repeat(Math.max(0, width - rendered.length))]; + } +} + +/** Word-wrap plain text so each line fits within maxWidth characters. */ +function wordWrapText(text: string, maxWidth: number): string[] { + if (maxWidth <= 0) return [text || " "]; + const paragraphs = text.split("\n"); + const result: string[] = []; + for (const para of paragraphs) { + if (para.length === 0) { + result.push(""); + continue; + } + let remaining = para; + while (remaining.length > 0) { + if (remaining.length <= maxWidth) { + result.push(remaining); + break; + } + const breakAt = remaining.lastIndexOf(" ", maxWidth); + if (breakAt <= 0) { + result.push(remaining.substring(0, maxWidth)); + remaining = remaining.substring(maxWidth); + } else { + result.push(remaining.substring(0, breakAt)); + remaining = remaining.substring(breakAt + 1); + } + } + } + return result.length > 0 ? result : [""]; +} + +// --------------------------------------------------------------------------- +// TUI Selector +// --------------------------------------------------------------------------- + +class PromptHistorySelector extends Container implements Focusable { + private readonly searchInput: Input; + private readonly previewContainer: Container; + private readonly listContainer: Container; + private readonly headerRow: FixedRowText; + private readonly previewLabelRow: FixedRowText; + private records: PromptRecord[]; + private readonly theme: Theme; + private readonly tui: TUI; + private readonly onSelect: (record: PromptRecord) => void; + private readonly onCancel: () => void; + /** Notification sink for selector feedback (wired by the factory). */ + private readonly onNotify?: SelectorNotify; + private filteredRecords: PromptRecord[] = []; + private selectedIndex = 0; + /** Number of records loaded (newest-first) from the top of `records`. */ + private loadedCount = 0; + /** Active scope (design v2): project (default) or global. */ + private scope: "project" | "global" = "project"; + /** Last render width, used for entry truncation. */ + private lastWidth = 800; + /** Word-wrapped lines of the currently selected prompt. */ + private wrappedPreviewLines: string[] = []; + /** Scroll offset into wrappedPreviewLines for the preview viewport. */ + private previewScrollOffset = 0; + + /** Dispatch table: first match wins, fallthrough last. */ + private readonly dispatch: readonly DispatchEntry[] = [ + { + match: (_d, kb) => kb.matches(_d, "tui.select.up"), + handler: () => this.moveUp(), + }, + { + match: (_d, kb) => kb.matches(_d, "tui.select.down"), + handler: () => this.moveDown(), + }, + { + match: (_d, kb) => kb.matches(_d, "tui.select.pageUp"), + handler: () => this.pageListUp(), + }, + { + match: (_d, kb) => kb.matches(_d, "tui.select.pageDown"), + handler: () => this.pageListDown(), + }, + { + match: (d, kb) => d === "\r" || kb.matches(d, "tui.select.confirm"), + handler: () => this.selectCurrent(), + }, + { match: (d, _kb) => d === "\t", handler: () => this.toggleScope() }, + { + match: (_d, kb) => kb.matches(_d, "tui.select.cancel"), + handler: () => this.onCancel(), + }, + { + match: (d, _kb) => matchesKey(d, "home"), + handler: () => this.jumpToFirst(), + }, + { + match: (d, _kb) => matchesKey(d, "end"), + handler: () => this.jumpToLast(), + }, + { + match: (d, _kb) => matchesKey(d, "ctrl+shift+up"), + handler: () => this.previewPageUp(), + }, + { + match: (d, _kb) => matchesKey(d, "ctrl+shift+down"), + handler: () => this.previewPageDown(), + }, + ]; + + private _focused = false; + get focused(): boolean { + return this._focused; + } + set focused(value: boolean) { + this._focused = value; + this.searchInput.focused = value; + } + + constructor( + tui: TUI, + theme: Theme, + records: PromptRecord[], + onSelect: (record: PromptRecord) => void, + onCancel: () => void, + onNotify?: SelectorNotify, + ) { + super(); + this.tui = tui; + this.theme = theme; + this.records = records; + this.loadedCount = initialLoadedCount(records.length, INITIAL_BATCH); + this.onSelect = onSelect; + this.onCancel = onCancel; + this.onNotify = onNotify; + + // ── Search panel (top) ── + this.addChild(new DynamicBorder((s: string) => theme.fg("accent", s))); + this.headerRow = new FixedRowText( + theme.fg("accent", theme.bold(" History Search ")), + ); + this.addChild(this.headerRow); + this.addChild( + new Text( + theme.fg("dim", "Type to filter (multi-word AND substring, case-insensitive)"), + 0, + 0, + ), + ); + this.searchInput = new Input(); + this.searchInput.onSubmit = () => this.selectCurrent(); + this.searchInput.onEscape = () => this.onCancel(); + this.addChild(this.searchInput); + this.addChild(new DynamicBorder((s: string) => theme.fg("dim", s))); + + this.listContainer = new Container(); + this.addChild(this.listContainer); + + // ── Preview panel (bottom) ── + this.addChild(new DynamicBorder((s: string) => theme.fg("accent", s))); + this.previewLabelRow = new FixedRowText( + theme.fg("accent", theme.bold(" Preview ")), + ); + this.addChild(this.previewLabelRow); + this.previewContainer = new Container(); + 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 • esc cancel", + ), + true /* centered */, + ), + ); + this.addChild(new DynamicBorder((s: string) => theme.fg("accent", s))); + + this.applyFilter(""); + } + + // -- Filtering & list building ------------------------------------------ + + private applyFilter(query: string): void { + // AC-L2-3r (user-directed 2026-09-08): a non-empty query implies + // full-snapshot visibility — one-shot and idempotent, never a batch — + // so per-keypress incremental loads remain impossible (C2). + this.loadedCount = loadedCountForQuery( + this.loadedCount, + this.records.length, + query, + ); + this.filteredRecords = filterPrompts( + this.records.slice(0, this.loadedCount), + query, + ); + this.selectedIndex = clampSelectedIndex( + this.selectedIndex, + this.filteredRecords.length, + ); + this.previewScrollOffset = 0; + this.rebuildList(); + this.rebuildPreview(); + } + + private rebuildList(): void { + this.rebuildListWithWidth(this.lastWidth); + } + + /** Rebuild list rows: header counter + entries. Always MAX_VISIBLE rows. */ + private rebuildListWithWidth(width: number): void { + const count = this.filteredRecords.length; + const position = count === 0 ? 0 : this.selectedIndex + 1; + this.headerRow.setText( + this.theme.fg("accent", this.theme.bold(" History Search ")) + + this.theme.fg("dim", ` · ${position} of ${count} `) + + this.theme.fg( + "dim", + ` · loaded ${this.loadedCount} of ${this.records.length} `, + ) + + // Right-aligned scope radio: pad from plain-text lengths so the + // radio ends flush at the header's last column at any width. + (() => { + const scopeRadio = + this.scope === "project" + ? "◉ Current project | ○ All projects" + : "○ Current project | ◉ All projects"; + const leftWidth = + " History Search ".length + + ` · ${position} of ${count} `.length + + ` · loaded ${this.loadedCount} of ${this.records.length} `.length; + return ( + " ".repeat(Math.max(1, width - leftWidth - scopeRadio.length)) + + this.theme.fg("dim", scopeRadio) + ); + })(), + ); + this.listContainer.clear(); + + if (count === 0) { + this.listContainer.addChild( + new FixedRowText(this.theme.fg("warning", "No matching prompts")), + ); + for (let i = 1; i < MAX_VISIBLE; i++) { + this.listContainer.addChild(new FixedRowText()); + } + return; + } + + const entryMax = Math.floor(width * 0.95) - ENTRY_PREFIX_WIDTH; + + const visible = getVisiblePromptRecords( + this.filteredRecords, + this.selectedIndex, + MAX_VISIBLE, + ); + + for (const { record, isSelected } of visible) { + const prefix = isSelected ? "→ " : " "; + const color = isSelected ? "accent" : "text"; + const compacted = sanitizeForDisplay(record.text) + .replace(/\s+/g, " ") + .trim(); + const truncated = truncateToWidth(compacted, entryMax, "…"); + const line = prefix + this.theme.fg(color, truncated); + this.listContainer.addChild(new FixedRowText(line)); + } + + for (let i = visible.length; i < MAX_VISIBLE; i++) { + this.listContainer.addChild(new FixedRowText()); + } + } + + /** + * Rebuild preview: word-wrap the full selected prompt text and show + * a PREVIEW_ROWS-tall viewport starting at previewScrollOffset. + * Content starts immediately below the "Preview" label (no top padding). + * PgUp/PgDn scroll through the wrapped lines. + */ + private rebuildPreviewWithWidth(width: number): void { + this.previewContainer.clear(); + + const wrapWidth = Math.max(1, width - 2); + const selected = this.filteredRecords[this.selectedIndex]; + if (selected) { + const safeText = sanitizeForDisplay(selected.text); + this.wrappedPreviewLines = wordWrapText(safeText, wrapWidth); + this.previewScrollOffset = clampPreviewOffset( + this.previewScrollOffset, + this.wrappedPreviewLines.length, + PREVIEW_ROWS, + ); + } else { + this.wrappedPreviewLines = []; + this.previewScrollOffset = 0; + } + + // P1-3 indicator: fresh wrap is known here — one update site covers all + // paths; the label appends the 1-based range only when content overflows. + this.previewLabelRow.setText(this.previewLabelRowText()); + + for (let i = 0; i < PREVIEW_ROWS; i++) { + const lineIdx = this.previewScrollOffset + i; + if (lineIdx < this.wrappedPreviewLines.length) { + // Pad the plain text to wrapWidth so FixedRowText.render() + // never truncates — the visible width is always ≤ width-2. + const raw = this.wrappedPreviewLines[lineIdx]; + const padded = raw + " ".repeat(Math.max(0, wrapWidth - raw.length)); + this.previewContainer.addChild( + new FixedRowText(this.theme.fg("text", padded)), + ); + } else { + this.previewContainer.addChild(new FixedRowText()); + } + } + } + + /** " Preview " label; appends the 1-based visible range only on overflow. */ + private previewLabelRowText(): string { + const total = this.wrappedPreviewLines.length; + if (total <= PREVIEW_ROWS) { + return this.theme.fg("accent", this.theme.bold(" Preview ")); + } + const start = this.previewScrollOffset + 1; + const end = Math.min(this.previewScrollOffset + PREVIEW_ROWS, total); + return this.theme.fg( + "accent", + this.theme.bold(` Preview — ${start}–${end}/${total} `), + ); + } + + private rebuildPreview(): void { + this.rebuildPreviewWithWidth(this.lastWidth); + } + + // -- Selection actions -------------------------------------------------- + + private selectCurrent(): void { + const selected = this.filteredRecords[this.selectedIndex]; + if (selected) this.onSelect(selected); + } + + /** + * Toggle project <-> global (design v2): re-drain the other scope, + * rebuild the merged records, reset the window. Tab's only role. + */ + private toggleScope(): void { + this.scope = this.scope === "project" ? "global" : "project"; + const entries = drainForScope(this.scope); + this.records = recordsFromEntries(entries); + this.loadedCount = initialLoadedCount(this.records.length, INITIAL_BATCH); + this.applyFilter(this.searchInput.getValue()); + } + + // -- Navigation --------------------------------------------------------- + + private moveUp(): void { + this.selectedIndex = moveSelectedIndex( + this.selectedIndex, + this.filteredRecords.length, + -1, + ); + if ( + shouldGrowWindow( + this.selectedIndex, + this.loadedCount, + this.records.length, + PRELOAD_BUFFER, + ) + ) { + this.loadedCount = nextLoadedCount( + this.loadedCount, + this.records.length, + BATCH_SIZE, + ); + this.applyFilter(this.searchInput.getValue()); + } + this.previewScrollOffset = 0; + this.rebuildList(); + this.rebuildPreview(); + } + + private moveDown(): void { + // Grow-before-move (design §D1): the C2 trigger fires while the cursor + // sits in the final PRELOAD_BUFFER rows of the loaded window, so the + // modulo below moves into freshly loaded rows — a wrap to index 0 is + // reachable only on the exhausted set. + if ( + shouldGrowWindow( + this.selectedIndex, + this.loadedCount, + this.records.length, + PRELOAD_BUFFER, + ) + ) { + this.loadedCount = nextLoadedCount( + this.loadedCount, + this.records.length, + BATCH_SIZE, + ); + this.applyFilter(this.searchInput.getValue()); + } + this.selectedIndex = moveSelectedIndex( + this.selectedIndex, + this.filteredRecords.length, + 1, + ); + this.previewScrollOffset = 0; + this.rebuildList(); + this.rebuildPreview(); + } + + /** Page the LIST up by MAX_VISIBLE with clamping (no wrap). */ + private pageListUp(): void { + this.selectedIndex = pageSelectedIndex( + this.selectedIndex, + this.filteredRecords.length, + -MAX_VISIBLE, + ); + this.previewScrollOffset = 0; + this.rebuildList(); + this.rebuildPreview(); + } + + /** Page the LIST down by MAX_VISIBLE with clamping (no wrap). */ + private pageListDown(): void { + // PgDn catch-up (design §D7): grow in whole batches until the paged-to + // row is loaded BEFORE the selection lands on it. + const grown = loadedCountForTarget( + this.loadedCount, + this.records.length, + this.selectedIndex + MAX_VISIBLE, + BATCH_SIZE, + ); + if (grown !== this.loadedCount) { + this.loadedCount = grown; + this.applyFilter(this.searchInput.getValue()); + } + this.selectedIndex = pageSelectedIndex( + this.selectedIndex, + this.filteredRecords.length, + MAX_VISIBLE, + ); + this.previewScrollOffset = 0; + this.rebuildList(); + this.rebuildPreview(); + } + + private previewPageUp(): void { + this.previewScrollOffset = Math.max( + 0, + this.previewScrollOffset - PREVIEW_ROWS, + ); + this.rebuildPreview(); + } + + private previewPageDown(): void { + this.previewScrollOffset = clampPreviewOffset( + this.previewScrollOffset + PREVIEW_ROWS, + this.wrappedPreviewLines.length, + PREVIEW_ROWS, + ); + this.rebuildPreview(); + } + + private jumpToFirst(): void { + if (this.filteredRecords.length === 0) return; + this.selectedIndex = 0; + this.previewScrollOffset = 0; + this.rebuildList(); + this.rebuildPreview(); + } + + private jumpToLast(): void { + // End full-jump (design §D7): one-shot load of everything BEFORE the + // empty guard, so End also surfaces matches beyond the window. + if (this.loadedCount < this.records.length) { + this.loadedCount = this.records.length; + this.applyFilter(this.searchInput.getValue()); + } + if (this.filteredRecords.length === 0) return; + this.selectedIndex = this.filteredRecords.length - 1; + this.previewScrollOffset = 0; + this.rebuildList(); + this.rebuildPreview(); + } + + // -- Input handling ----------------------------------------------------- + + private forwardToSearch(data: string): void { + this.searchInput.handleInput(data); + this.selectedIndex = 0; + this.applyFilter(this.searchInput.getValue()); + } + + handleInput(data: string): void { + const kb = getKeybindings(); + let handled = false; + for (const { match, handler } of this.dispatch) { + if (match(data, kb)) { + handler(); + handled = true; + break; + } + } + if (!handled) this.forwardToSearch(data); + this.tui.requestRender(); + } + + // -- Mouse (wheel-only) ------------------------------------------------- + + /** + * Wheel-only mouse handling over the fixed 30-row geometry (design + * §D6). Non-wheel events stay host-owned (undefined = Container child + * dispatch); EVERY wheel path — including the no-op regions — reaches + * the single consumed return, closing the pre-existing SGR-fallthrough + * hazard where raw wheel bytes were typed into the search box. + */ + override handleMouse( + event: TuiMouseEvent, + ): ReturnType { + if (event.type !== "wheel") return undefined; + 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); + for (let i = 0; i < steps; i++) { + if (delta > 0) this.moveDown(); + else this.moveUp(); + } + } else if ( + event.y >= PREVIEW_WHEEL_Y_FIRST && + event.y <= PREVIEW_WHEEL_Y_LAST + ) { + if (delta !== 0) { + this.previewScrollOffset = clampPreviewOffset( + this.previewScrollOffset + (delta > 0 ? 1 : -1), + this.wrappedPreviewLines.length, + PREVIEW_ROWS, + ); + this.rebuildPreview(); + } + } + return { + handled: true, + target: { + component: this, + originX: event.screenX - event.x, + originY: event.screenY - event.y, + width: event.width, + height: event.height, + }, + }; + } + + // -- Render override for dynamic entry width --------------------------- + + /** Fixed overlay height so the TUI never repositions the panel. */ + private static readonly OVERLAY_LINES = 30; + + override render(width: number): string[] { + if (width !== this.lastWidth) { + // Pre-clamp against the previous wrap so a width change can never + // drive the rebuilds with a stale selection/offset (AC-P1-4.1/4.2). + this.selectedIndex = clampSelectedIndex( + this.selectedIndex, + this.filteredRecords.length, + ); + this.previewScrollOffset = clampPreviewOffset( + this.previewScrollOffset, + this.wrappedPreviewLines.length, + PREVIEW_ROWS, + ); + } + this.lastWidth = width; + this.rebuildListWithWidth(width); + this.rebuildPreviewWithWidth(width); + const raw = super.render(width); + // Pad or trim to exactly OVERLAY_LINES so the overlay never shifts. + const blank = " ".repeat(Math.max(1, width)); + while (raw.length < PromptHistorySelector.OVERLAY_LINES) raw.push(blank); + return raw.slice(0, PromptHistorySelector.OVERLAY_LINES); + } +} + +// --------------------------------------------------------------------------- +// Overlay glue +// --------------------------------------------------------------------------- + +type SelectorDone = (result: PromptRecord | null) => void; + +type SelectorFactory = ( + tui: unknown, + theme: unknown, + keybindings: unknown, + done: SelectorDone, +) => PromptHistorySelector; + +function castSelectorArgs(tui: unknown, theme: unknown): [TUI, Theme] { + return [tui as TUI, theme as Theme]; +} + +/** Stored close callback for the currently-open overlay. Null when closed. */ +let activeOverlayClose: (() => void) | null = null; + +function createPromptHistorySelectorFactory( + records: PromptRecord[], + onNotify?: SelectorNotify, +): SelectorFactory { + return (tui, theme, _keybindings, done) => { + selectorTui = tui as { requestRender(): void }; + const finish = (result: PromptRecord | null) => { + activeOverlayClose = null; + done(result); + }; + // Expose close so the tool_call handler can dismiss the overlay. + activeOverlayClose = () => finish(null); + const [typedTui, typedTheme] = castSelectorArgs(tui, theme); + const selector = new PromptHistorySelector( + typedTui, + typedTheme, + records, + (record) => finish(record), + () => finish(null), + onNotify, + ); + return selector; + }; +} + +async function runPromptHistorySelection( + ctx: ShortcutContext, + records: PromptRecord[], +): Promise { + const historyGlobals: PiHistoryGlobals = globalThis as Record< + string, + unknown + >; + return withExpandedHistoryGlobals(historyGlobals, async () => + ctx.ui.custom( + createPromptHistorySelectorFactory(records, (message, level) => + ctx.ui.notify(message, level), + ), + { + overlay: true, + overlayOptions: { anchor: "bottom-center", width: "100%", offsetY: 5 }, + }, + ), + ); +} + +// --------------------------------------------------------------------------- +// Multi-concurrency store (v2): per-session writes, scope drains +// --------------------------------------------------------------------------- + +type HistoryScope = "project" | "global"; + +/** TUI handle captured when the selector overlay mounts. */ +let selectorTui: { requestRender(): void } | null = null; + let writerState: SessionWriterState | null = null; /** @@ -43,6 +857,51 @@ function getWriter(): SessionWriterState { return writerState; } +/** + * 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. + */ +function drainForScope(scope: HistoryScope): string[] { + 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); +} + +async function openHistorySelector( + ctx: Pick, +): Promise { + // Store-only drain (user-directed): both scopes read the store files + // symmetrically — no live transcript merge (the one-time seed bootstrap + // covers pre-store history). + const entries = drainForScope("project"); + if (entries.length === 0) { + ctx.ui.notify("No prompt history available.", "warning"); + return; + } + + const records = recordsFromEntries(entries); + const selected = await runPromptHistorySelection(ctx, records); + if (selected) { + // pasteToEditor routes through the editor's input pipeline + // (bracketed paste), so the text renders immediately. Plain + // setText left the editor stale until the next keypress after + // overlay close. + ctx.ui.pasteToEditor(selected.text); + // The overlay teardown can race the paste render: force one more + // frame on the next tick so the editor box shows the text at once. + setTimeout(() => selectorTui?.requestRender(), 0); + } +} + +/** Build selector records from merged/drain entries (shared by both scopes). */ +function recordsFromEntries( + entries: Array, +): PromptRecord[] { + return buildPromptRecords(dedupePromptEntries(entries)); +} + export default function promptHistoryExtension(pi: ExtensionAPI) { // One writer per extension load; see getWriter() for the init order. @@ -57,4 +916,20 @@ export default function promptHistoryExtension(pi: ExtensionAPI) { // the handler - swallow and keep the next prompt capturable. } }); + + // When a tool asks for user input while the history overlay is open, + // dismiss the overlay so the tool can take over the UI. + pi.on("tool_call", () => { + activeOverlayClose?.(); + }); + + pi.registerShortcut(SHORTCUT, { + description: "Search prompt history", + handler: async (ctx) => openHistorySelector(ctx), + }); + + pi.registerCommand("history", { + description: "Search prompt history", + handler: async (_args, ctx) => openHistorySelector(ctx), + }); } diff --git a/extensions/history/selector-helpers.ts b/extensions/history/selector-helpers.ts index c5257bafe..fd02400ce 100644 --- a/extensions/history/selector-helpers.ts +++ b/extensions/history/selector-helpers.ts @@ -25,6 +25,22 @@ export interface PromptEntry { ts?: number; } +export interface VisibleRange { + start: number; + end: number; +} + +export interface VisiblePromptRecord { + index: number; + record: PromptRecord; + isSelected: boolean; +} + +export interface PiHistoryGlobals { + __piHistoryExpand?: () => void; + __piHistoryTrim?: () => void; +} + export function buildPromptRecords( entries: ReadonlyArray, ): PromptRecord[] { @@ -46,6 +62,56 @@ export function buildPromptRecords( }); } +export function clampSelectedIndex( + selectedIndex: number, + total: number, +): number { + return Math.max(0, Math.min(selectedIndex, Math.max(0, total - 1))); +} + +export function clampPreviewOffset( + offset: number, + totalLines: number, + viewportRows: number, +): number { + return Math.max(0, Math.min(offset, Math.max(0, totalLines - viewportRows))); +} + +export function computeVisibleRange( + selectedIndex: number, + total: number, + maxVisible: number, +): VisibleRange { + if (total <= 0 || maxVisible <= 0) return { start: 0, end: 0 }; + if (total <= maxVisible) return { start: 0, end: total }; + + const half = Math.floor(maxVisible / 2); + const start = Math.max(0, Math.min(selectedIndex - half, total - maxVisible)); + + return { + start, + end: Math.min(start + maxVisible, total), + }; +} + +export function moveSelectedIndex( + selectedIndex: number, + total: number, + delta: number, +): number { + if (total === 0) return 0; + return (selectedIndex + delta + total) % total; +} + +export function pageSelectedIndex( + selectedIndex: number, + total: number, + pageSize: number, +): number { + if (total === 0) return 0; + return clampSelectedIndex(selectedIndex + pageSize, total); +} + /** * Normalization key for read-time dedup (spec C3): byte-matches the * APPLIED patch key in nav/patches/editor.cjs (:480-:586) — whitespace @@ -87,6 +153,112 @@ export function dedupePromptEntries( return deduped; } +/** + * First-paint window size (spec C1, AC-L1-1): min(initialBatch, total), + * floored at 0 — small stores open fully loaded (exhausted at open), + * identical to today's behavior for R <= INITIAL_BATCH. + */ +export function initialLoadedCount( + total: number, + initialBatch: number, +): number { + return Math.max(0, Math.min(initialBatch, total)); +} + +/** + * Prefetch trigger (spec C2's normative expression, AC-L2-1): growth fires + * iff rows remain unloaded AND the 0-based cursor sits within the final + * preloadBuffer rows of the loaded window. Reads UNFILTERED counts only — + * filteredRecords.length appears in no trigger arithmetic (AC-L2-2). + */ +export function shouldGrowWindow( + selectedIndex: number, + loadedCount: number, + totalCount: number, + preloadBuffer: number, +): boolean { + return ( + loadedCount < totalCount && selectedIndex + preloadBuffer >= loadedCount + ); +} + +/** + * One growth step (spec C2, AC-L2-1): min(L + max(1, batchSize), R). The + * max(1, ·) guard also keeps loadedCountForTarget's loop terminating on a + * degenerate batch size. + */ +export function nextLoadedCount( + loadedCount: number, + totalCount: number, + batchSize: number, +): number { + const step = Math.max(1, batchSize); + return Math.min(loadedCount + step, totalCount); +} + +/** + * PgDn catch-up (spec C1, AC-L1-5): the smallest whole-batch count that + * strictly covers targetIndex (a 0-based master row), clamped at totalCount. + * No-op when the target is already covered or the window is exhausted. + * Terminates by construction: each step adds ≥ 1, bounded by totalCount. + */ +export function loadedCountForTarget( + loadedCount: number, + totalCount: number, + targetIndex: number, + batchSize: number, +): number { + let next = loadedCount; + while (next <= targetIndex && next < totalCount) { + next = nextLoadedCount(next, totalCount, batchSize); + } + return next; +} + +export function getVisiblePromptRecords( + records: PromptRecord[], + selectedIndex: number, + maxVisible: number, +): VisiblePromptRecord[] { + const { start, end } = computeVisibleRange( + selectedIndex, + records.length, + maxVisible, + ); + return records.slice(start, end).map((record, offset) => ({ + index: start + offset, + record, + isSelected: start + offset === selectedIndex, + })); +} + +export async function withExpandedHistoryGlobals( + globals: PiHistoryGlobals, + run: () => Promise, +): Promise { + globals.__piHistoryExpand?.(); + try { + return await run(); + } finally { + globals.__piHistoryTrim?.(); + } +} + +/** + * Full-snapshot visibility for non-empty queries (AC-L2-3r, user-directed + * 2026-09-08): searching must see the whole deduped snapshot, not just the + * loaded prefix. One-shot and idempotent — returns the total, never an + * incremental batch — so per-keypress growth stays impossible. Empty or + * whitespace-only queries leave the lazy window untouched. + */ +export function loadedCountForQuery( + loadedCount: number, + totalCount: number, + query: string, +): number { + return query.trim().length > 0 ? totalCount : loadedCount; +} + const MAX_RESULTS = 10000; export function filterPrompts( diff --git a/tests/history-command-registration.test.ts b/tests/history-command-registration.test.ts new file mode 100644 index 000000000..e4aa9bf6b --- /dev/null +++ b/tests/history-command-registration.test.ts @@ -0,0 +1,84 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import { fileURLToPath } from "node:url"; + +// Source-parsing tests (preview-layout.test.ts pattern): never import +// extensions/history/index.ts — it pulls the pi-tui runtime graph (§D3). + +const sourcePath = fileURLToPath( + new URL("../extensions/history/index.ts", import.meta.url), +); +const source = fs.readFileSync(sourcePath, "utf8"); + +test("openHistorySelector is extracted once and shared by both entry points", () => { + const definitions = + source.split("async function openHistorySelector(").length - 1; + assert.strictEqual( + definitions, + 1, + "openHistorySelector should be defined exactly once", + ); + + const calls = source.split("openHistorySelector(ctx)").length - 1; + assert.strictEqual( + calls, + 2, + "registerShortcut and registerCommand handlers should both call openHistorySelector(ctx)", + ); + + // PR-branch (slice 3) behavior: the store-only drain keeps the empty + // guard — no history means a warning, not an empty overlay. (The dev + // repo's later always-open selector dropped this guard; the PR branch is + // the API truth here.) + const start = source.indexOf("async function openHistorySelector("); + const end = source.indexOf("export default function", start); + assert.notStrictEqual(end, -1, "extension entry point should follow"); + const body = source.slice(start, end); + assert.ok( + body.includes("if (entries.length === 0)") && + body.includes('"No prompt history available."'), + "an empty history warns and skips the overlay (PR-branch drain guard)", + ); +}); + +test("the /history command is registered beside the shortcut", () => { + const index = source.indexOf('pi.registerCommand("history"'); + assert.ok(index >= 0, 'pi.registerCommand("history", ...) should exist'); + + const slice = source.slice(index, index + 200); + assert.ok( + slice.includes('"Search prompt history"'), + "command should carry the same description as the shortcut", + ); + assert.ok( + slice.includes("openHistorySelector(ctx)"), + "command handler should route through the shared entry point", + ); +}); + +test("the ctrl+shift+r shortcut is registered with the shared description", () => { + const index = source.indexOf("pi.registerShortcut(SHORTCUT"); + assert.ok(index >= 0, "pi.registerShortcut(SHORTCUT, ...) should exist"); + + const slice = source.slice(index, index + 200); + assert.ok( + slice.includes('"Search prompt history"'), + "shortcut should carry the shared description", + ); + assert.ok( + slice.includes("openHistorySelector(ctx)"), + "shortcut handler should route through the shared entry point", + ); +}); + +test("in-UI hint describes multi-word AND substring matching, not fuzzy", () => { + assert.ok( + !source.includes("fzf-style fuzzy match"), + "the fzf-style fuzzy match claim must be removed (AC-P1-6.1)", + ); + assert.ok( + source.includes("multi-word AND substring"), + "hint should describe multi-word AND substring filtering (AC-P1-6.1)", + ); +}); diff --git a/tests/history-dispatch.test.ts b/tests/history-dispatch.test.ts new file mode 100644 index 000000000..575a4d5a1 --- /dev/null +++ b/tests/history-dispatch.test.ts @@ -0,0 +1,179 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import { fileURLToPath } from "node:url"; + +/** + * Dispatch table structural tests — source-parsed (AC-P2-1.3, AC-P2-2.1, + * AC-P2-4.1), following the preview-layout.test.ts pattern. + * + * 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 + * forwardToSearch fallthrough inside handleInput. + */ + +const sourcePath = fileURLToPath( + new URL("../extensions/history/index.ts", import.meta.url), +); +const source = fs.readFileSync(sourcePath, "utf8"); + +const DISPATCH_DECL = "private readonly dispatch: readonly DispatchEntry[] = ["; +const TABLE_CLOSE = "\n ];"; + +/** §B2 normative matcher order — the exact literal as it appears per entry. */ +const EXPECTED_MATCHERS = [ + 'kb.matches(_d, "tui.select.up")', + 'kb.matches(_d, "tui.select.down")', + 'kb.matches(_d, "tui.select.pageUp")', + 'kb.matches(_d, "tui.select.pageDown")', + 'd === "\\r" || kb.matches(d, "tui.select.confirm")', + 'd === "\\t"', + 'kb.matches(_d, "tui.select.cancel")', + 'matchesKey(d, "home")', + 'matchesKey(d, "end")', + 'matchesKey(d, "ctrl+shift+up")', + 'matchesKey(d, "ctrl+shift+down")', +]; + +/** Handler each entry must invoke (searched within the entry's body). */ +const EXPECTED_HANDLERS = [ + "this.moveUp()", + "this.moveDown()", + "this.pageListUp()", + "this.pageListDown()", + "this.selectCurrent()", + "this.toggleScope()", + "this.onCancel()", + "this.jumpToFirst()", + "this.jumpToLast()", + "this.previewPageUp()", + "this.previewPageDown()", +]; + +function dispatchTable(): string { + const start = source.indexOf(DISPATCH_DECL); + assert.notStrictEqual( + start, + -1, + "dispatch table declaration should exist in extensions/history/index.ts", + ); + const end = source.indexOf(TABLE_CLOSE, start); + assert.notStrictEqual(end, -1, "dispatch table closing should exist"); + return source.slice(start, end); +} + +/** Entry i's body: from its matcher literal to the next matcher (or table end). */ +function entryBody(table: string, index: number): string { + const start = table.indexOf(EXPECTED_MATCHERS[index]); + const next = + index + 1 < EXPECTED_MATCHERS.length + ? table.indexOf(EXPECTED_MATCHERS[index + 1]) + : table.length; + return table.slice(start, next === -1 ? table.length : next); +} + +function methodBody(name: string): string { + const start = source.indexOf(`private ${name}(): void {`); + assert.notStrictEqual(start, -1, `private ${name}() should exist`); + const end = source.indexOf("\n }", start); + assert.notStrictEqual(end, -1, `private ${name}() body should close`); + return source.slice(start, end); +} + +describe("dispatch table (source-parsed, §B2)", () => { + it("has exactly 11 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}`, + ); + }); + + it("keeps the exact §B2 matcher order", () => { + const table = dispatchTable(); + let cursor = -1; + EXPECTED_MATCHERS.forEach((matcher, i) => { + const at = table.indexOf(matcher); + assert.notStrictEqual( + at, + -1, + `entry #${i + 1} matcher missing: ${matcher}`, + ); + assert.ok( + at > cursor, + `entry #${i + 1} matcher out of order: ${matcher}`, + ); + cursor = at; + }); + }); + + it("wires every entry handler per §B2", () => { + const table = dispatchTable(); + EXPECTED_HANDLERS.forEach((handler, i) => { + const body = entryBody(table, i); + assert.ok( + body.includes(handler), + `entry #${i + 1} should call ${handler}`, + ); + }); + }); + + it("pages the LIST via pageSelectedIndex and resets the preview offset (AC-P2-1.3)", () => { + const up = methodBody("pageListUp"); + assert.ok( + up.includes("pageSelectedIndex("), + "pageListUp must clamp via pageSelectedIndex", + ); + assert.ok( + up.includes("-MAX_VISIBLE"), + "pageListUp must page up by one page", + ); + assert.ok( + up.includes("previewScrollOffset = 0"), + "pageListUp must reset the preview offset", + ); + const down = methodBody("pageListDown"); + assert.ok( + down.includes("pageSelectedIndex("), + "pageListDown must clamp via pageSelectedIndex", + ); + assert.ok( + down.includes("MAX_VISIBLE"), + "pageListDown must page down by one page", + ); + assert.ok( + down.includes("previewScrollOffset = 0"), + "pageListDown must reset the preview offset", + ); + }); + + it("runs the ctrl+shift combos before the implicit fallthrough (AC-P2-2.1)", () => { + const table = dispatchTable(); + const lastMatch = table.lastIndexOf("match:"); + assert.ok( + table.slice(lastMatch).includes('matchesKey(d, "ctrl+shift+down")'), + "the final table entry must be the ctrl+shift+down combo", + ); + const loopAt = source.indexOf( + "for (const { match, handler } of this.dispatch) {", + ); + const fallthroughAt = source.indexOf( + "if (!handled) this.forwardToSearch(data);", + ); + assert.notStrictEqual(loopAt, -1, "dispatch loop should exist"); + assert.notStrictEqual( + fallthroughAt, + -1, + "forwardToSearch fallthrough should exist", + ); + assert.ok( + fallthroughAt > loopAt, + "fallthrough must run after the dispatch loop", + ); + }); +}); diff --git a/tests/history-expanded-globals.test.ts b/tests/history-expanded-globals.test.ts new file mode 100644 index 000000000..e66d92163 --- /dev/null +++ b/tests/history-expanded-globals.test.ts @@ -0,0 +1,62 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + type PiHistoryGlobals, + withExpandedHistoryGlobals, +} from "../extensions/history/selector-helpers.ts"; + +// withExpandedHistoryGlobals contract: optional hooks invoked via `?.` — +// expand exactly once BEFORE the run starts, trim exactly once in the +// finally (success and rejection alike), and the run's resolution passes +// through untouched. Fixtures track call order in one array so the +// before/after ordering and the call counts are pinned together. + +function trackingGlobals(): { globals: PiHistoryGlobals; events: string[] } { + const events: string[] = []; + return { + events, + globals: { + __piHistoryExpand: () => { + events.push("expand"); + }, + __piHistoryTrim: () => { + events.push("trim"); + }, + }, + }; +} + +test("expand runs once before the run; trim once after; the result passes through", async () => { + const { globals, events } = trackingGlobals(); + let ran = 0; + const value = await withExpandedHistoryGlobals(globals, async () => { + // By the time the body executes, expand already ran — exactly once. + ran += 1; + assert.deepEqual(events, ["expand"]); + return 42; + }); + assert.equal(value, 42); + assert.equal(ran, 1); + assert.deepEqual(events, ["expand", "trim"]); +}); + +test("a rejected run still trims (finally) and the rejection propagates unchanged", async () => { + const { globals, events } = trackingGlobals(); + const boom = new Error("boom"); + let caught: unknown; + try { + await withExpandedHistoryGlobals(globals, async () => { + throw boom; + }); + } catch (error) { + caught = error; + } + assert.equal(caught, boom); + assert.deepEqual(events, ["expand", "trim"]); +}); + +test("absent hooks are tolerated: the run executes with no throw", async () => { + const empty: PiHistoryGlobals = {}; + const value = await withExpandedHistoryGlobals(empty, async () => "ok"); + assert.equal(value, "ok"); +}); diff --git a/tests/history-lazy-windowing.test.ts b/tests/history-lazy-windowing.test.ts new file mode 100644 index 000000000..5ee40838a --- /dev/null +++ b/tests/history-lazy-windowing.test.ts @@ -0,0 +1,508 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import { fileURLToPath } from "node:url"; +import { + buildPromptRecords, + filterPrompts, + initialLoadedCount, + loadedCountForQuery, + loadedCountForTarget, + moveSelectedIndex, + nextLoadedCount, + shouldGrowWindow, +} from "../extensions/history/selector-helpers.ts"; + +// Unit 2a — L1+L2 windowing helpers (spec C1/C2, design §D3/§D4). +// +// The ratified constant VALUES (design R3) are pinned here as test literals +// while the named constants themselves land in extensions/history/index.ts: +// +// INITIAL_BATCH = 10 · BATCH_SIZE = 10 · PRELOAD_BUFFER = 3 (trigger at 8th; milestones 10/20/30) +// +// Every helper is a parameterized pure function over UNFILTERED counts only: +// `filteredRecords.length` appears in no trigger or growth expression (the +// §8a regression pin, AC-L2-2). All behaviors below use the helpers exactly +// as the §B2 wiring does in the selector — grow-before-move, one batch per +// threshold crossing, derived exhaustion (no stored flag). + +// T4 — AC-L1-1: initial window clamp, min(INITIAL_BATCH, records.length). + +test("initialLoadedCount clamps the first-paint window to min(INITIAL_BATCH, records.length) (AC-L1-1)", () => { + const initialBatch = 30; + assert.equal(initialLoadedCount(0, initialBatch), 0); + assert.equal(initialLoadedCount(12, initialBatch), 12); + assert.equal(initialLoadedCount(30, initialBatch), 30); + assert.equal(initialLoadedCount(200, initialBatch), 30); +}); + +// T4 — AC-L1-2: filtering windows the loaded prefix — filterPrompts over +// records.slice(0, loadedCount) derives exclusively from that prefix; a +// match beyond the loaded count stays invisible until growth. filterPrompts +// itself is untouched (imported read-only from selector-helpers.ts). + +test("filterPrompts over the loaded prefix hides matches beyond L until growth (AC-L1-2)", () => { + const records: { text: string; searchText: string }[] = []; + for (let i = 0; i < 200; i++) { + const text = + i === 40 ? "needle40 special prompt" : `plain prompt number ${i}`; + const [record] = buildPromptRecords([text]); + assert.ok(record, "buildPromptRecords yields one record per entry"); + records.push(record); + } + + const loadedPrefix = records.slice(0, initialLoadedCount(200, 30)); + assert.equal(loadedPrefix.length, 30); + assert.equal( + filterPrompts(loadedPrefix, "needle40").length, + 0, + "the match at master index 40 sits beyond the loaded prefix — invisible until growth", + ); + assert.equal( + filterPrompts(records.slice(0, 60), "needle40").length, + 1, + "after growth to cover index 40, the match surfaces", + ); + assert.equal( + filterPrompts(loadedPrefix, "").length, + 30, + "the empty query derives exclusively from the loaded prefix", + ); +}); + +// T5 — AC-L2-1: trigger truth table with the off-by-one edges. The predicate +// is exactly `selectedIndex + preloadBuffer >= loadedCount` (0-based cursor +// within the final PRELOAD_BUFFER rows of the loaded window). + +test("shouldGrowWindow fires exactly when the cursor enters the final PRELOAD_BUFFER rows (AC-L2-1)", () => { + const totalCount = 200; + const preloadBuffer = 10; + + // One row early — a naive selected+1 paraphrase would already fire here + // (spec risk: trigger-expression off-by-one drift). + assert.equal(shouldGrowWindow(19, 30, totalCount, preloadBuffer), false); + // Exact boundary: 20 + 10 >= 30. + assert.equal(shouldGrowWindow(20, 30, totalCount, preloadBuffer), true); + assert.equal(shouldGrowWindow(29, 30, totalCount, preloadBuffer), true); + + // Grown window: cursor mid-window does not fire, the next final-buffer + // band does (exact boundary 50 + 10 >= 60). + assert.equal(shouldGrowWindow(0, 60, totalCount, preloadBuffer), false); + assert.equal(shouldGrowWindow(49, 60, totalCount, preloadBuffer), false); + assert.equal(shouldGrowWindow(50, 60, totalCount, preloadBuffer), true); + assert.equal(shouldGrowWindow(59, 60, totalCount, preloadBuffer), true); +}); + +// T5 — AC-L2-4 + AC-L1-3: exhaustion is derived — the predicate is false at +// EVERY cursor position once loadedCount equals totalCount, no stored latch. + +test("shouldGrowWindow is false at every cursor position once exhausted (AC-L2-4, AC-L1-3)", () => { + const totalCount = 200; + for (const cursor of [0, 1, 100, 189, 190, 191, 199, 500]) { + assert.equal( + shouldGrowWindow(cursor, 200, totalCount, 10), + false, + `cursor ${cursor} on the exhausted window`, + ); + } +}); + +// T5 — AC-L1-3/AC-L2-4 (source-parse): exhaustion is derivation-only — the +// selector's class-fields region stores no `exhausted`/`isLoaded` boolean +// that could go stale across query changes. + +const selectorSource = fs.readFileSync( + fileURLToPath(new URL("../extensions/history/index.ts", import.meta.url)), + "utf8", +); + +test("the selector stores no exhausted/isLoaded flag — exhaustion is derivation-only (AC-L1-3, AC-L2-4)", () => { + const classStart = selectorSource.indexOf("class PromptHistorySelector"); + assert.ok(classStart >= 0, "PromptHistorySelector should exist"); + const dispatchStart = selectorSource.indexOf( + "private readonly dispatch", + classStart, + ); + assert.ok(dispatchStart > classStart, "dispatch table should follow"); + const fieldsRegion = selectorSource.slice(classStart, dispatchStart); + assert.ok( + !fieldsRegion.includes("exhausted"), + "no stored `exhausted` flag may exist in the class fields", + ); + assert.ok( + !fieldsRegion.includes("isLoaded"), + "no stored `isLoaded` flag may exist in the class fields", + ); +}); + +// T6 — AC-L2-2 (§8a regression pin, helper half): the trigger/growth +// arithmetic lives in pure helpers over UNFILTERED counts only. The helpers +// file must carry no filteredRecords reference and must contain C2's exact +// normative predicate expression. + +test("trigger arithmetic is unfiltered-only: exact C2 predicate, no filteredRecords in growth helpers (AC-L2-2)", () => { + const helpersSource = fs.readFileSync( + fileURLToPath( + new URL("../extensions/history/selector-helpers.ts", import.meta.url), + ), + "utf8", + ); + const bodyOf = (name: string): string => { + const fnStart = helpersSource.indexOf(`export function ${name}`); + assert.ok(fnStart >= 0, `${name} should exist`); + const bodyStart = helpersSource.indexOf("{", fnStart); + const bodyEnd = helpersSource.indexOf("\n}", fnStart); + assert.ok(bodyStart >= 0 && bodyEnd > bodyStart); + return helpersSource.slice(bodyStart, bodyEnd); + }; + for (const name of [ + "shouldGrowWindow", + "nextLoadedCount", + "loadedCountForTarget", + ]) { + assert.ok( + !bodyOf(name).includes("filteredRecords"), + `${name} must read unfiltered counts only`, + ); + } + assert.ok( + bodyOf("shouldGrowWindow").includes( + "loadedCount < totalCount && selectedIndex + preloadBuffer >= loadedCount", + ), + "the predicate must be C2's exact normative expression", + ); +}); + +// T6 — AC-L2-1/AC-L2-2 (§8a walk): cursor 0→29 over total=200 with the +// ratified constants produces exactly ONE grow (+30 clamped) — one batch +// per threshold crossing, never per-keypress re-triggering. + +test("§8a walk: cursor 0→29 over total=200 produces exactly one grow (AC-L2-1, AC-L2-2)", () => { + const total = 200; + const batchSize = 30; + const preloadBuffer = 10; + let loadedCount = initialLoadedCount(total, 30); + let grows = 0; + for (let cursor = 0; cursor <= 29; cursor++) { + if (shouldGrowWindow(cursor, loadedCount, total, preloadBuffer)) { + loadedCount = nextLoadedCount(loadedCount, total, batchSize); + grows++; + } + } + assert.equal(grows, 1, "exactly one batch per threshold crossing"); + assert.equal(loadedCount, 60, "one +30 batch clamped by nothing here"); +}); + +// T6 — AC-L2-2: after that crossing the predicate stays quiet for at least +// 20 more presses — PRELOAD_BUFFER leaves a full-viewport margin (§D3). + +test("§8a walk: the next threshold crossing is at least 20 presses away (AC-L2-2)", () => { + const total = 200; + const loadedCount = 60; // state right after the first crossing (cursor 20) + let pressesToNextCrossing: number | null = null; + for (let cursor = 21; cursor <= total; cursor++) { + if (shouldGrowWindow(cursor, loadedCount, total, 10)) { + pressesToNextCrossing = cursor - 21; + break; + } + } + assert.ok( + pressesToNextCrossing !== null && pressesToNextCrossing >= 20, + "the next crossing fires at cursor 50 — 29 presses after the first (a crossing must exist)", + ); +}); + +// T7 — AC-L1-7: wrap reachability invariant as a pure simulation of the §B2 +// wiring: grow-before-move through the helpers, modulo over the loaded set. +// A wrap to index 0 occurs ONLY on the exhausted set; every record index is +// reached (no unloaded row skipped); the walk terminates. + +test("wrap-invariant walk: wrap to 0 only when exhausted, every index reached, walk terminates (AC-L1-7)", () => { + const total = 75; + const batchSize = 30; + const preloadBuffer = 10; + let loadedCount = initialLoadedCount(total, 30); + let cursor = 0; + const visited = new Set(); + let wraps = 0; + + for (let step = 0; step < 500; step++) { + visited.add(cursor); + // §B2 grow-before-move: fire the C2 trigger, one batch per crossing. + if (shouldGrowWindow(cursor, loadedCount, total, preloadBuffer)) { + loadedCount = nextLoadedCount(loadedCount, total, batchSize); + } + const next = moveSelectedIndex(cursor, loadedCount, 1); + if (next === 0) { + wraps++; + assert.ok( + loadedCount >= total, + "wrap to index 0 must occur only when loadedCount >= records.length", + ); + break; + } + cursor = next; + } + + assert.equal(wraps, 1, "the walk must terminate via a single full wrap"); + assert.equal(loadedCount, total, "the window must be exhausted at wrap time"); + assert.equal( + visited.size, + total, + "every record index 0..74 must be reached — no unloaded row skipped", + ); + for (let i = 0; i < total; i++) { + assert.ok(visited.has(i), `record index ${i} must be reachable`); + } +}); + +// T8 — AC-L1-5: loadedCountForTarget table (PgDn catch-up semantics). + +test("loadedCountForTarget: covered target is a no-op (AC-L1-5)", () => { + const total = 200; + assert.equal(loadedCountForTarget(60, total, 35, 30), 60); + assert.equal(loadedCountForTarget(30, total, 29, 30), 30); +}); + +test("loadedCountForTarget: uncovered target grows in whole batches strictly covering it (AC-L1-5)", () => { + const total = 200; + // Target row 30 is NOT loaded by loadedCount=30 (rows 0..29) — one batch. + assert.equal(loadedCountForTarget(30, total, 30, 30), 60); + assert.equal(loadedCountForTarget(30, total, 35, 30), 60); + // Strictly covers: row 60 needs rows 0..60, so two batches. + assert.equal(loadedCountForTarget(30, total, 60, 30), 90); + assert.equal(loadedCountForTarget(30, total, 61, 30), 90); +}); + +test("loadedCountForTarget: target past total clamps; exhausted window unchanged (AC-L1-5)", () => { + assert.equal(loadedCountForTarget(30, 75, 500, 30), 75); + assert.equal(loadedCountForTarget(75, 75, 500, 30), 75); + assert.equal(loadedCountForTarget(200, 200, 10, 30), 200); +}); + +// T8 — AC-L2-1: the growth step is min(L + max(1, batchSize), R); the +// max(1, ·) guard is what keeps loadedCountForTarget's loop terminating on +// a degenerate (or negative) batch size. + +test("nextLoadedCount steps min(L + max(1, batchSize), R) including the degenerate-batch guard (AC-L2-1)", () => { + assert.equal(nextLoadedCount(30, 200, 30), 60); + assert.equal(nextLoadedCount(90, 200, 30), 120); + assert.equal(nextLoadedCount(180, 200, 30), 200, "clamped at total"); + assert.equal(nextLoadedCount(200, 200, 30), 200, "exhausted: no-op clamp"); + assert.equal(nextLoadedCount(30, 200, 0), 31, "degenerate batch adds 1"); + assert.equal(nextLoadedCount(30, 200, -5), 31, "negative batch adds 1"); +}); + +// --------------------------------------------------------------------------- +// Unit 2b — §B2 wiring pins (T9) + headerRow-only constraint (T10). +// +// Source-parse tests over extensions/history/index.ts. The body extractor +// mirrors dispatch.test.ts's methodBody(): slice from the method declaration +// to the first "\n }" — which is exactly why every nested if added by the +// §B2 wiring must close at 4-space indent (a 4-space closer cannot match the +// first-close slice, so the method close is still found). +// +// Slice-3 adaptation note: upstream wires the growth trigger INLINE in +// moveUp/moveDown (no shared growLoadedWindowIfNeeded helper — that shape is +// dev-repo drift). The pins below assert the same AC contracts against the +// inline form. + +function methodBodyOf(name: string): string { + const decl = selectorSource.indexOf(`private ${name}(`); + assert.ok(decl >= 0, `private ${name}() should exist in extensions/history/index.ts`); + const end = selectorSource.indexOf("\n }", decl); + assert.ok(end > decl, `private ${name}() body should close`); + return selectorSource.slice(decl, end); +} + +// T9 — AC-L1-4: batch append points — growth wiring in the three downward +// paths ONLY (moveUp carries the older-direction growth check); every other +// upward site and applyFilter stay pure. + +test("growth wiring appears in moveDown, moveUp, pageListDown, jumpToLast (AC-L1-4)", () => { + const down = methodBodyOf("moveDown"); + assert.ok( + down.includes("shouldGrowWindow("), + "moveDown must evaluate the C2 trigger", + ); + assert.ok( + down.includes("nextLoadedCount("), + "moveDown must grow via nextLoadedCount", + ); + const up = methodBodyOf("moveUp"); + assert.ok( + up.includes("shouldGrowWindow(") && up.includes("nextLoadedCount("), + "moveUp must carry the older-direction growth check", + ); + const pageDown = methodBodyOf("pageListDown"); + assert.ok( + pageDown.includes("loadedCountForTarget("), + "pageListDown must catch up via loadedCountForTarget", + ); + const jumpLast = methodBodyOf("jumpToLast"); + assert.ok( + jumpLast.includes("this.loadedCount = this.records.length;"), + "jumpToLast must one-shot the full load (End)", + ); + for (const name of ["pageListUp", "jumpToFirst", "applyFilter"]) { + const body = methodBodyOf(name); + for (const grow of [ + "shouldGrowWindow(", + "nextLoadedCount(", + "loadedCountForTarget(", + ]) { + assert.ok( + !body.includes(grow), + `${name} must never grow (found ${grow})`, + ); + } + } +}); + +// T9 — AC-L1-7 / AC-L1-5 / AC-L1-6 ordering: growth runs BEFORE the index +// computation in every downward path (grow-before-move, design §B2/§D1). + +test("growth runs BEFORE the index computation in every downward path (AC-L1-7, AC-L1-5, AC-L1-6)", () => { + const down = methodBodyOf("moveDown"); + const growAt = down.indexOf("shouldGrowWindow("); + assert.notEqual(growAt, -1, "moveDown must evaluate the C2 trigger"); + assert.ok( + growAt < down.indexOf("moveSelectedIndex("), + "moveDown must grow before the modulo — wrap-to-0 only on the exhausted set", + ); + const page = methodBodyOf("pageListDown"); + const catchUpAt = page.indexOf("loadedCountForTarget("); + assert.notEqual(catchUpAt, -1, "pageListDown must run the PgDn catch-up"); + assert.ok( + catchUpAt < page.indexOf("pageSelectedIndex("), + "pageListDown must load the paged-to row before the selection lands", + ); + const last = methodBodyOf("jumpToLast"); + const fullLoad = last.indexOf("this.loadedCount = this.records.length;"); + const guard = last.indexOf("if (this.filteredRecords.length === 0) return;"); + assert.ok( + fullLoad !== -1 && guard !== -1 && fullLoad < guard, + "jumpToLast must full-load before the empty guard so End surfaces unloaded matches", + ); +}); + +// T9 — AC-L2-2 (§8a regression pin, wiring half): the trigger/growth call +// arguments read ONLY the unfiltered counts — `filteredRecords` appears in +// no growth region of any downward body. + +test("growth arithmetic names only this.loadedCount and this.records.length (AC-L2-2)", () => { + const down = methodBodyOf("moveDown"); + const downGrow = down.slice(0, down.indexOf("moveSelectedIndex(")); + assert.ok( + downGrow.includes("shouldGrowWindow(") && + downGrow.includes("nextLoadedCount("), + "moveDown's growth region must run the trigger + one batch before the modulo", + ); + assert.ok( + !downGrow.includes("filteredRecords"), + "moveDown's pre-modulo region must read UNFILTERED counts only", + ); + const page = methodBodyOf("pageListDown"); + const pageGrow = page.slice(0, page.indexOf("pageSelectedIndex(")); + assert.ok( + pageGrow.includes("loadedCountForTarget(") && + pageGrow.includes("this.loadedCount") && + pageGrow.includes("this.records.length"), + "pageListDown's catch-up must pass the unfiltered counts", + ); + assert.ok( + !pageGrow.includes("filteredRecords"), + "pageListDown's growth arithmetic must read UNFILTERED counts only", + ); + const last = methodBodyOf("jumpToLast"); + const guardAt = last.indexOf( + "if (this.filteredRecords.length === 0) return;", + ); + const lastGrow = last.slice(0, guardAt); + assert.ok( + lastGrow.includes("this.loadedCount = this.records.length;") && + !lastGrow.includes("filteredRecords"), + "jumpToLast's full-load region must be unfiltered-only", + ); +}); + +// T9 — AC-L2-3 (typing never loads) + AC-L1-2: applyFilter derives matches +// from the loaded prefix and contains no grow call. + +test("applyFilter windows the loaded prefix and grows only via loadedCountForQuery (AC-L2-3r, AC-L1-2)", () => { + const body = methodBodyOf("applyFilter"); + assert.ok( + body.includes("filterPrompts(") && + body.includes(".slice(0, this.loadedCount)"), + "applyFilter must derive matches from records.slice(0, loadedCount)", + ); + assert.ok( + body.includes("loadedCountForQuery("), + "applyFilter must route visibility through loadedCountForQuery (AC-L2-3r)", + ); + assert.ok( + !body.includes("nextLoadedCount("), + "typing implies one-shot full visibility via loadedCountForQuery; incremental loads stay banned (C2)", + ); + assert.ok( + !body.includes("shouldGrowWindow("), + "the filter path must never trigger growth", + ); +}); + +// T10 — AC-L3-2: headerRow-only constraint — the suffix is produced inside +// rebuildListWithWidth's existing headerRow.setText argument, adds no row +// (no new addChild in the method, constructor child sequence unchanged) and +// OVERLAY_LINES = 30 stays intact. + +test("the header keeps the position segment plus the loaded suffix on the existing headerRow.setText path (AC-L3-2)", () => { + const body = methodBodyOf("rebuildListWithWidth"); + const setTextAt = body.indexOf("headerRow.setText("); + assert.ok( + setTextAt >= 0, + "the suffix must extend the existing headerRow.setText call", + ); + const setTextRegion = body.slice( + setTextAt, + body.indexOf("this.listContainer.clear()"), + ); + assert.ok( + setTextRegion.includes("loaded ") && + setTextRegion.includes("this.loadedCount") && + setTextRegion.includes("this.records.length"), + "the ` · loaded M of T ` suffix must be produced inside the setText argument", + ); + assert.ok( + !setTextRegion.includes("indexing "), + "no indexing segment — removed by user decision", + ); + const addChildCount = body.split("addChild(").length - 1; + assert.equal( + addChildCount, + 4, + "the suffix adds no addChild call — today's 4 list-row sites unchanged", + ); + assert.ok( + selectorSource.includes("private static readonly OVERLAY_LINES = 30;"), + "OVERLAY_LINES = 30 must stay intact", + ); + const classAt = selectorSource.indexOf("class PromptHistorySelector"); + const ctorAt = selectorSource.indexOf("constructor(", classAt); + const ctorEnd = selectorSource.indexOf('this.applyFilter("")', ctorAt); + const ctorAddChild = + selectorSource.slice(ctorAt, ctorEnd).split("this.addChild(").length - 1; + assert.equal(ctorAddChild, 12, "the constructor child sequence is unchanged"); +}); + +// T14 — AC-L2-3 revision (user-directed 2026-09-08): a non-empty query +// implies full-snapshot visibility, one-shot and idempotent; an empty or +// whitespace-only query leaves the window untouched. Per-keypress +// incremental growth remains banned (the helper returns total, never +BATCH). + +test("loadedCountForQuery: non-empty query returns total, empty keeps window (AC-L2-3r)", () => { + assert.equal(loadedCountForQuery(10, 512, "deploy"), 512); + assert.equal(loadedCountForQuery(10, 512, ""), 10); + assert.equal(loadedCountForQuery(10, 512, " "), 10); + assert.equal(loadedCountForQuery(512, 512, "deploy"), 512); + assert.equal(loadedCountForQuery(10, 10, "x"), 10); +}); diff --git a/tests/history-openflow-integration.test.ts b/tests/history-openflow-integration.test.ts new file mode 100644 index 000000000..1737f887a --- /dev/null +++ b/tests/history-openflow-integration.test.ts @@ -0,0 +1,143 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import { fileURLToPath } from "node:url"; + +/** + * WU5 tests (AC-S6-1..3): the open-flow wiring in extensions/history/index.ts. + * NEVER import it — it pulls the pi-tui runtime graph (design §D3). The + * wiring is pinned by source-parse (command-registration pattern); loader + * behavior uses fs-only fixtures under the OS temp dir — NEVER the user's + * real ~/.pi/agent/history. + */ + +const indexSource = fs.readFileSync( + fileURLToPath(new URL("../extensions/history/index.ts", import.meta.url)), + "utf8", +); + +function openHistorySelectorBody(): string { + const start = indexSource.indexOf("async function openHistorySelector("); + assert.ok(start >= 0, "openHistorySelector should exist"); + const end = indexSource.indexOf("export default function", start); + assert.ok(end > start, "extension entry point should follow"); + return indexSource.slice(start, end); +} + +/** Method body slice (lazy-windowing.test.ts pattern; first "\n }" close). */ +function methodBodyOf(name: string): string { + const decl = indexSource.indexOf(`private ${name}(`); + assert.ok(decl >= 0, `private ${name}() should exist in extensions/history/index.ts`); + const end = indexSource.indexOf("\n }", decl); + assert.ok(end > decl, `private ${name}() body should close`); + return indexSource.slice(decl, end); +} + +// --------------------------------------------------------------------------- +// T31 — AC-S6-1: store-only drain wiring (source-parse, §I load-bearing shape). +// --------------------------------------------------------------------------- + +test("T31 (AC-S6-1): the store drain is the entries source — no live transcript merge (§I pin 1)", () => { + const body = openHistorySelectorBody(); + const drainIdx = body.indexOf('const entries = drainForScope("project")'); + assert.ok( + drainIdx >= 0, + "the load step must drain the store directly (wiring RED seam)", + ); + assert.ok( + !body.includes("mergeHistoryEntries("), + "the live transcript merge is GONE from the open flow (user-directed store-only scopes)", + ); + assert.ok( + body.indexOf("if (entries.length === 0)") >= 0, + "the PR-branch empty guard stands: no history warns instead of opening an empty overlay", + ); +}); + +test("T31 (AC-S6-1): records are built via recordsFromEntries over the drained entries (§I pins 2+3)", () => { + const body = openHistorySelectorBody(); + const recIdx = body.indexOf("recordsFromEntries(entries)"); + assert.ok( + recIdx >= 0, + "records build through the shared recordsFromEntries helper", + ); +}); + +test("T31 (AC-S6-1): the three command-registration pins hold beside the swap", () => { + const definitions = + indexSource.split("async function openHistorySelector(").length - 1; + assert.equal(definitions, 1, "openHistorySelector defined exactly once"); + const calls = indexSource.split("openHistorySelector(ctx)").length - 1; + assert.equal( + calls, + 2, + "exactly the two entry-point call sites — the swap adds no occurrence", + ); +}); + +// --------------------------------------------------------------------------- +// T32 — AC-S6-2: cold-start wiring (no-await source-parse). +// --------------------------------------------------------------------------- + +test("T32 (AC-S6-2): NO await on any records build inside openHistorySelector (source-parse)", () => { + const body = openHistorySelectorBody(); + assert.ok( + body.includes('const entries = drainForScope("project")'), + "wiring present (RED seam before GREEN)", + ); + assert.ok( + !body.includes("startBackgroundIndexBuild"), + "the build kick lives inside the loader — never in the selector", + ); + assert.ok( + !/await\s+mergeHistoryEntries/.test(body), + "the open path never awaits the loader (sync const declaration)", + ); +}); + +// --------------------------------------------------------------------------- +// T33 — AC-S6-3: merged header totals + third transient dim indexing segment. +// --------------------------------------------------------------------------- + +test("T33 (AC-S6-3): header totals derive from filteredRecords — derivation untouched", () => { + const body = methodBodyOf("rebuildListWithWidth"); + assert.ok( + body.includes("const count = this.filteredRecords.length;"), + "N derives from filteredRecords (merged by construction)", + ); +}); + +test("T33 (AC-S6-3): loaded segment present, indexing segment removed", () => { + const body = methodBodyOf("rebuildListWithWidth"); + const setTextAt = body.indexOf("headerRow.setText("); + assert.ok(setTextAt >= 0, "the header must keep the existing setText call"); + const setTextRegion = body.slice( + setTextAt, + body.indexOf("this.listContainer.clear()"), + ); + assert.ok( + setTextRegion.includes("loaded ") && + setTextRegion.includes("this.loadedCount"), + "the loaded segment stays (user-restored)", + ); + assert.ok( + !setTextRegion.includes("indexing "), + "the indexing segment stays removed", + ); +}); + +test("T33 (AC-S6-3): Change 2 structural pins still hold beside the third segment", () => { + assert.ok( + indexSource.includes("private static readonly OVERLAY_LINES = 30;"), + "OVERLAY_LINES = 30 intact", + ); + const body = methodBodyOf("rebuildListWithWidth"); + const addChildCount = body.split("addChild(").length - 1; + assert.equal(addChildCount, 4, "no new addChild in rebuildListWithWidth"); + const classAt = indexSource.indexOf("class PromptHistorySelector"); + const ctorAt = indexSource.indexOf("constructor(", classAt); + const ctorEnd = indexSource.indexOf('this.applyFilter("")', ctorAt); + const ctorAddChild = + indexSource.slice(ctorAt, ctorEnd).split("this.addChild(").length - 1; + assert.equal(ctorAddChild, 12, "the constructor child sequence is unchanged"); +}); diff --git a/tests/history-preview-layout.test.ts b/tests/history-preview-layout.test.ts new file mode 100644 index 000000000..782da55ce --- /dev/null +++ b/tests/history-preview-layout.test.ts @@ -0,0 +1,56 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import { fileURLToPath } from "node:url"; + +const sourcePath = fileURLToPath( + new URL("../extensions/history/index.ts", import.meta.url), +); +const source = fs.readFileSync(sourcePath, "utf8"); + +test("preview rows are bottom-padded so the panel shrinks from the bottom", () => { + const rebuildStart = source.indexOf( + "private rebuildPreviewWithWidth(width: number): void {", + ); + assert.notStrictEqual( + rebuildStart, + -1, + "rebuildPreviewWithWidth() should exist", + ); + + const rebuildEnd = source.indexOf( + "\n // -- Selection actions", + rebuildStart, + ); + assert.notStrictEqual( + rebuildEnd, + -1, + "rebuildPreview section boundary should exist", + ); + + const rebuildPreviewSource = source.slice(rebuildStart, rebuildEnd); + + const rowLoopIndex = rebuildPreviewSource.indexOf( + "for (let i = 0; i < PREVIEW_ROWS; i++)", + ); + assert.ok( + rowLoopIndex >= 0, + "fixed-height PREVIEW_ROWS row loop should exist", + ); + + const emptyRowPadIndex = rebuildPreviewSource.indexOf( + "this.previewContainer.addChild(new FixedRowText());", + rowLoopIndex, + ); + assert.ok( + emptyRowPadIndex >= 0, + "rows past the wrapped content should be added as empty bottom padding", + ); + + assert.ok( + !rebuildPreviewSource.includes( + "const topPadding = PREVIEW_ROWS - visible.length;", + ), + "preview should not compute top padding", + ); +}); diff --git a/tests/history-selector-windowing.test.ts b/tests/history-selector-windowing.test.ts new file mode 100644 index 000000000..9fca7e9d7 --- /dev/null +++ b/tests/history-selector-windowing.test.ts @@ -0,0 +1,94 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + buildPromptRecords, + clampPreviewOffset, + clampSelectedIndex, + computeVisibleRange, + getVisiblePromptRecords, + moveSelectedIndex, + pageSelectedIndex, +} from "../extensions/history/selector-helpers.ts"; + +test("buildPromptRecords lowercases search text", () => { + assert.deepEqual(buildPromptRecords(["Hello"]), [ + { text: "Hello", searchText: "hello" }, + ]); +}); + +test("computeVisibleRange centers when possible", () => { + assert.deepEqual(computeVisibleRange(8, 30, 10), { start: 3, end: 13 }); +}); + +test("computeVisibleRange pins near end", () => { + assert.deepEqual(computeVisibleRange(28, 30, 10), { start: 20, end: 30 }); +}); + +test("computeVisibleRange handles small lists", () => { + assert.deepEqual(computeVisibleRange(1, 3, 10), { start: 0, end: 3 }); +}); + +test("clampSelectedIndex stays within filtered record bounds", () => { + assert.equal(clampSelectedIndex(8, 3), 2); + assert.equal(clampSelectedIndex(1, 0), 0); +}); + +test("clampPreviewOffset clamps offsets past the last page", () => { + assert.equal(clampPreviewOffset(50, 47, 10), 37); + assert.equal(clampPreviewOffset(5, 47, 10), 5); +}); + +test("clampPreviewOffset pins to zero when content fits the viewport", () => { + assert.equal(clampPreviewOffset(3, 8, 10), 0); + assert.equal(clampPreviewOffset(7, 0, 10), 0); +}); + +test("moveSelectedIndex wraps around the list", () => { + assert.equal(moveSelectedIndex(0, 3, -1), 2); + assert.equal(moveSelectedIndex(2, 3, 1), 0); + assert.equal(moveSelectedIndex(0, 0, 1), 0); +}); + +test("pageSelectedIndex clamps within the list", () => { + assert.equal(pageSelectedIndex(8, 30, -10), 0); + assert.equal(pageSelectedIndex(2, 3, 10), 2); + assert.equal(pageSelectedIndex(0, 0, 10), 0); +}); + +test("pageSelectedIndex end-clamps a downward page at the last entry (AC-P2-1.1)", () => { + assert.equal(pageSelectedIndex(28, 30, 10), 29); + assert.equal(pageSelectedIndex(25, 30, 10), 29); +}); + +test("pageSelectedIndex clamps |pageSize| greater than total in both directions (AC-P2-1.2)", () => { + assert.equal(pageSelectedIndex(0, 3, 10), 2); + assert.equal(pageSelectedIndex(2, 3, -10), 0); + assert.equal(pageSelectedIndex(0, 30, -50), 0); +}); + +test("pageSelectedIndex never wraps past the ends (AC-P2-1.2)", () => { + assert.equal(pageSelectedIndex(29, 30, 10), 29); + assert.equal(pageSelectedIndex(0, 30, -10), 0); +}); + +test("getVisiblePromptRecords returns visible records with selection state", () => { + assert.deepEqual( + getVisiblePromptRecords( + buildPromptRecords(["one", "two", "three", "four"]), + 2, + 2, + ), + [ + { + index: 1, + record: { text: "two", searchText: "two" }, + isSelected: false, + }, + { + index: 2, + record: { text: "three", searchText: "three" }, + isSelected: true, + }, + ], + ); +}); diff --git a/tests/history-session-writer.test.ts b/tests/history-session-writer.test.ts index 8601cea14..61a2689b3 100644 --- a/tests/history-session-writer.test.ts +++ b/tests/history-session-writer.test.ts @@ -88,22 +88,36 @@ test("two writers own separate files in the same project dir", () => { assert.deepEqual(files, ["inst-a.jsonl", "inst-b.jsonl"]); }); -test("the slice-1 extension entry registers only the capture handler", () => { +test("the extension entry registers exactly the slice-3 wiring surface", () => { // Module load must stay side-effect free (importing index.ts parses the - // whole slice-1 graph without touching the real ~/.pi store root), and - // slice 1 wires exactly one handler: before_agent_start. + // whole graph without touching the real ~/.pi store root). Wiring as of + // slice 3: before_agent_start capture + tool_call overlay dismiss, the + // ctrl+shift+r shortcut, and the history command. session_shutdown is + // slice 6 and must not appear yet. const registered: Array<[string, unknown]> = []; + const shortcuts: Array<[string, unknown]> = []; + const commands: Array<[string, unknown]> = []; const pi = { on: (event: string, handler: unknown) => { registered.push([event, handler]); }, + registerShortcut: (key: string, def: unknown) => { + shortcuts.push([key, def]); + }, + registerCommand: (name: string, def: unknown) => { + commands.push([name, def]); + }, }; promptHistoryExtension(pi as never); assert.deepEqual( registered.map(([event]) => event), - ["before_agent_start"], + ["before_agent_start", "tool_call"], ); - // The handler is callable but is NEVER invoked here: a real invocation + assert.deepEqual(shortcuts.map(([key]) => key), ["ctrl+shift+r"]); + assert.deepEqual(commands.map(([name]) => name), ["history"]); + // Handlers are callable but are NEVER invoked here: a real invocation // would run getWriter() against the user's real ~/.pi/agent/history. - assert.equal(typeof registered[0][1], "function"); + for (const [, handler] of registered) { + assert.equal(typeof handler, "function"); + } }); diff --git a/tests/history-wheel-mouse.test.ts b/tests/history-wheel-mouse.test.ts new file mode 100644 index 000000000..37ddcd6b6 --- /dev/null +++ b/tests/history-wheel-mouse.test.ts @@ -0,0 +1,242 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import { fileURLToPath } from "node:url"; + +// Unit 4 — L6 wheel slice (spec C5, design §D6). +// +// Source-parse structural pins on extensions/history/index.ts (no pi-tui +// runtime graph — the same discipline as the other source-parse suites). +// The overlay renders only through pi-tui, so the unit-level contract is the +// SHAPE of the handleMouse override: +// +// - wheel-only: every non-wheel event type returns undefined (press/click/ +// drag stay host-owned) and the dispatch table gains no extra entry (wheel +// is not a keybinding — dispatch.test.ts remains the authoritative +// untouched pin); +// - ONE consumed wheel return: `handled: true` plus the synthetic target +// enrichment, reached by every wheel path including the no-op regions — +// this closes the pre-existing fullscreen SGR-fallthrough hazard by +// construction; +// - fixed 30-row geometry routing: list region y 5–14, preview region y 17–26, +// all other rows consumed no-ops; +// - list wheel: sign × |wheelDelta| steps through moveDown (the arrow grow +// path applies per step) / moveUp, magnitude clamped to the filtered list, +// zero/absent delta a no-op move — the override itself never re-implements +// growth; +// - preview wheel: 1 line per notch toward the delta direction via the +// existing clampPreviewOffset semantics + rebuildPreview. + +const selectorSource = fs.readFileSync( + fileURLToPath(new URL("../extensions/history/index.ts", import.meta.url)), + "utf8", +); + +// 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)", () => { + const decl = selectorSource.indexOf("override handleMouse("); + assert.ok(decl >= 0, "PromptHistorySelector should override handleMouse"); + const end = selectorSource.indexOf("\n }", decl); + assert.ok(end > decl, "handleMouse's body should close"); + const body = selectorSource.slice(decl, end); + + assert.ok( + body.includes('if (event.type !== "wheel") return undefined;'), + "non-wheel event types must return undefined (press/click/drag stay host-owned)", + ); + assert.ok( + body.includes('ReturnType'), + 'the return type must name the base contract via ReturnType', + ); + + const tableAt = selectorSource.indexOf( + "private readonly dispatch: readonly DispatchEntry[] = [", + ); + assert.ok(tableAt >= 0, "the dispatch table should exist"); + const tableEnd = selectorSource.indexOf("\n ];", tableAt); + assert.ok(tableEnd > tableAt, "the dispatch table should close"); + const table = selectorSource.slice(tableAt, tableEnd); + const entries = table.split("match:").length - 1; + assert.equal( + entries, + 11, + "wheel is not a keybinding: exactly the 11 §B2 dispatch entries, no extra", + ); +}); + +// T13 — AC-L6-2: ONE consumed wheel return with the target enrichment, +// reached by every wheel path including the no-op regions. + +test("every wheel path reaches the single handled:true return with target enrichment (AC-L6-2)", () => { + const decl = selectorSource.indexOf("override handleMouse("); + assert.ok(decl >= 0, "handleMouse should exist"); + const end = selectorSource.indexOf("\n }", decl); + const body = selectorSource.slice(decl, end); + + const returns = body.split("return").length - 1; + assert.equal( + returns, + 2, + "exactly two returns: the guard's undefined and the ONE consumed wheel return", + ); + assert.equal( + body.split("handled: true").length - 1, + 1, + "exactly one handled:true — the single wheel return", + ); + assert.equal( + body.split("return {").length - 1, + 1, + "exactly one object return, so list, preview, and no-op regions all reach it", + ); + + // Synthetic target mirroring dispatchMouseEvent's enrichment math + // (pi-tui tui.js dispatchMouseEvent: originX = screenX - x, originY = + // screenY - y, bounds from the event) — the result carries `target`, so + // dispatch passes it through verbatim. + assert.ok(body.includes("component: this,"), "target.component: this"); + assert.ok( + body.includes("originX: event.screenX - event.x,"), + "target.originX mirrors the dispatch enrichment math", + ); + assert.ok( + body.includes("originY: event.screenY - event.y,"), + "target.originY mirrors the dispatch enrichment math", + ); + assert.ok(body.includes("width: event.width,"), "target bounds width"); + assert.ok(body.includes("height: event.height,"), "target bounds height"); + + // No manual render: pi-tui re-renders handled wheels by default. + assert.ok( + !body.includes("requestRender"), + "handleMouse must not call requestRender (wheel results render by default)", + ); +}); + +// T13 — AC-L6-3: region routing truth table — the fixed 30-row geometry's +// list band 5–14 and preview band 17–26 appear as the y comparisons, all +// other rows fall through to the consumed no-op return. + +test("region constants 5-14 / 17-26 route the y comparisons (AC-L6-3)", () => { + assert.ok( + selectorSource.includes("const LIST_WHEEL_Y_FIRST = 5;"), + "LIST_WHEEL_Y_FIRST = 5 (list container rows)", + ); + assert.ok( + selectorSource.includes("const LIST_WHEEL_Y_LAST = 14;"), + "LIST_WHEEL_Y_LAST = 14", + ); + assert.ok( + selectorSource.includes("const PREVIEW_WHEEL_Y_FIRST = 17;"), + "PREVIEW_WHEEL_Y_FIRST = 17 (preview container rows)", + ); + assert.ok( + selectorSource.includes("const PREVIEW_WHEEL_Y_LAST = 26;"), + "PREVIEW_WHEEL_Y_LAST = 26", + ); + + const decl = selectorSource.indexOf("override handleMouse("); + assert.ok(decl >= 0, "handleMouse should exist"); + const end = selectorSource.indexOf("\n }", decl); + const body = selectorSource.slice(decl, end); + + assert.ok( + body.includes("event.y >= LIST_WHEEL_Y_FIRST") && + body.includes("event.y <= LIST_WHEEL_Y_LAST"), + "the list branch must compare y against the list band", + ); + assert.ok( + body.includes("event.y >= PREVIEW_WHEEL_Y_FIRST") && + body.includes("event.y <= PREVIEW_WHEEL_Y_LAST"), + "the preview branch must compare y against the preview band", + ); +}); + +// T13 — AC-L6-4: list wheel semantics — sign picks the direction, magnitude +// is clamped to the filtered list, zero/absent delta is a no-op, and the +// routing goes THROUGH moveDown's own grow path (never a re-implementation). + +test("list wheel routes sign-clamped steps through moveDown/moveUp (AC-L6-4)", () => { + const decl = selectorSource.indexOf("override handleMouse("); + assert.ok(decl >= 0, "handleMouse should exist"); + const end = selectorSource.indexOf("\n }", decl); + const body = selectorSource.slice(decl, end); + + // Absent wheelDelta normalizes to 0 → zero steps → no-op move. + assert.ok( + body.includes("const delta = event.wheelDelta ?? 0;"), + "delta must default an absent wheelDelta to 0", + ); + + const listStart = body.indexOf("if (event.y >= LIST_WHEEL_Y_FIRST"); + const listEnd = body.indexOf("} else if (", listStart); + assert.ok( + listStart >= 0 && listEnd > listStart, + "the list branch should exist", + ); + const listBranch = body.slice(listStart, listEnd); + + assert.ok( + listBranch.includes( + "const steps = Math.min(Math.abs(delta), this.filteredRecords.length);", + ), + "magnitude must clamp to the filtered list length", + ); + assert.ok( + listBranch.includes("for (let i = 0; i < steps; i++) {"), + "steps must move one row at a time (0 for a zero delta — the no-op)", + ); + assert.ok( + listBranch.includes("if (delta > 0) this.moveDown();") && + listBranch.includes("else this.moveUp();"), + "sign semantics: positive delta moves down through moveDown, else up", + ); + + // Prefetch interplay intact: growth belongs to moveDown itself — the + // override must not re-implement the trigger. + assert.ok( + !body.includes("shouldGrowWindow") && !body.includes("nextLoadedCount"), + "the override must not re-implement growth (wheel-down grows via moveDown)", + ); +}); + +// T13 — AC-L6-5: preview wheel semantics — one line per notch toward the +// delta direction through the existing clamp, zero-delta no-op. + +test("preview wheel scrolls one clamped line per notch (AC-L6-5)", () => { + const decl = selectorSource.indexOf("override handleMouse("); + assert.ok(decl >= 0, "handleMouse should exist"); + const end = selectorSource.indexOf("\n }", decl); + const body = selectorSource.slice(decl, end); + + const previewStart = body.indexOf("} else if ("); + const previewEnd = body.indexOf("return {", previewStart); + assert.ok( + previewStart >= 0 && previewEnd > previewStart, + "the preview branch should exist", + ); + const previewBranch = body.slice(previewStart, previewEnd); + + assert.ok( + previewBranch.includes("if (delta !== 0) {"), + "a zero delta must be a no-op in the preview band too", + ); + assert.ok( + previewBranch.includes("this.previewScrollOffset = clampPreviewOffset("), + "the preview must scroll through the existing clamp semantics", + ); + assert.ok( + previewBranch.includes("this.previewScrollOffset + (delta > 0 ? 1 : -1)"), + "exactly one line per notch toward the delta direction", + ); + assert.ok( + previewBranch.includes("this.wrappedPreviewLines.length") && + previewBranch.includes("PREVIEW_ROWS"), + "the clamp must run against the wrapped length and the viewport rows", + ); + assert.ok( + previewBranch.includes("this.rebuildPreview()"), + "the preview must re-render after the offset change", + ); +}); From d354c8a450b0a4cab042d0f7a24de2f5421b46d6 Mon Sep 17 00:00:00 2001 From: Carolina <26188349+carolitascl@users.noreply.github.com> Date: Fri, 18 Sep 2026 18:41:43 -0300 Subject: [PATCH 02/16] fix(history): intact astral characters, correct row padding, honest comments Review fixes (Copilot + CodeRabbit on PR #819): - sanitizeForDisplay: astral code points (> 0xFFFF) are re-appended via String.fromCodePoint instead of only the high surrogate at text[i]; emoji and other non-BMP characters no longer lose half their code point in list rows and previews. The low-surrogate skip is retained. - FixedRowText.render: the full-width pad now measures the VISIBLE width (SGR escape sequences stripped), matching the centered branch's measurement; colored list rows previously padded short and could leave ghost characters on overlay dismiss. - Lazy-windowing comment corrected: PRELOAD_BUFFER is 3 (fired in the final 3 loaded rows), not 2 as the stale comment claimed. - Regression pins added for both behavior fixes. --- extensions/history/index.ts | 14 ++++++++--- tests/history-preview-layout.test.ts | 37 ++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 4 deletions(-) diff --git a/extensions/history/index.ts b/extensions/history/index.ts index 9fe2d7893..f9559b9d6 100644 --- a/extensions/history/index.ts +++ b/extensions/history/index.ts @@ -57,8 +57,8 @@ import { const SHORTCUT = "ctrl+shift+r"; const MAX_VISIBLE = 10; const PREVIEW_ROWS = 10; -// Lazy windowing (design §D3; user-tuned 2026-09-08). PRELOAD_BUFFER=2 -// fires growth as the cursor enters the final 2 loaded rows; BATCH_SIZE=10 +// Lazy windowing (design §D3; user-tuned 2026-09-08). PRELOAD_BUFFER=3 +// fires growth as the cursor enters the final 3 loaded rows; BATCH_SIZE=10 // loads exactly one viewport per growth; INITIAL_BATCH=10 paints one // viewport at open. PRELOAD_BUFFER <= MAX_VISIBLE keeps a jump within one // viewport covered by the catch-up loop; review all three together. @@ -117,7 +117,10 @@ function sanitizeForDisplay(text: string): string { } else if (cp >= 0x80 && cp < 0xa0) { out += "\\x" + cp.toString(16).padStart(2, "0"); } else { - out += text[i]; + // Astral code points (> 0xFFFF) span a surrogate pair; append the + // full code point, not just the high surrogate at text[i], so emoji + // and other non-BMP characters survive sanitization intact. + out += cp > 0xffff ? String.fromCodePoint(cp) : text[i]; } if (cp > 0xffff) i++; // skip low surrogate of astral pair } @@ -180,7 +183,10 @@ class FixedRowText { : truncateToWidth(this.text, width, "…"); // Pad to full terminal width so the overlay fully overwrites // whatever is beneath it and leaves no ghost characters on dismiss. - return [rendered + " ".repeat(Math.max(0, width - rendered.length))]; + // Measure the VISIBLE width: SGR escape sequences (colored rows from + // rebuildListWithWidth) occupy no terminal cells. + const visible = rendered.replace(/\x1b\[[0-9;]*m/g, ""); + return [rendered + " ".repeat(Math.max(0, width - visible.length))]; } } diff --git a/tests/history-preview-layout.test.ts b/tests/history-preview-layout.test.ts index 782da55ce..02509725c 100644 --- a/tests/history-preview-layout.test.ts +++ b/tests/history-preview-layout.test.ts @@ -54,3 +54,40 @@ test("preview rows are bottom-padded so the panel shrinks from the bottom", () = "preview should not compute top padding", ); }); + +test("row padding measures visible width, stripping SGR escapes", () => { + // Colored list rows carry SGR escape sequences that occupy no terminal + // cells; padding must use the VISIBLE width or the row falls short of + // the overlay width and leaves ghost characters on dismiss. + const renderStart = source.indexOf(" render(width: number): string[] {"); + assert.notStrictEqual(renderStart, -1, "FixedRowText.render should exist"); + + const renderSource = source.slice(renderStart, renderStart + 2200); + const padLine = renderSource + .split("\n") + .find((l) => l.includes('" ".repeat(Math.max(0, width -')); + assert.ok(padLine !== undefined, "final full-width pad should exist"); + assert.ok( + padLine.includes("visible"), + "pad must measure the SGR-stripped visible width, not rendered.length", + ); + assert.ok( + /visible = rendered\.replace\(/.test(renderSource), + "visible width must be derived by stripping escape sequences", + ); +}); + +test("sanitizeForDisplay appends the full astral code point, not a lone surrogate", () => { + const fnStart = source.indexOf("function sanitizeForDisplay("); + assert.notStrictEqual(fnStart, -1, "sanitizeForDisplay should exist"); + + const fnSource = source.slice(fnStart, fnStart + 1200); + assert.ok( + fnSource.includes("String.fromCodePoint(cp)"), + "astral code points must be re-appended whole (emoji survive)", + ); + assert.ok( + fnSource.includes("if (cp > 0xffff) i++"), + "the low surrogate of the pair must still be skipped", + ); +}); From c63caa5ba3897b745d06abc5e13563fc963ad69b Mon Sep 17 00:00:00 2001 From: Carolina <26188349+carolitascl@users.noreply.github.com> Date: Fri, 18 Sep 2026 16:26:32 -0300 Subject: [PATCH 03/16] feat(history): transcript migration and seeding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slice 4/6 of the PR #819 split (maintainer-requested review slices). - load-shared-history (new): v1 shared-history reader, fail-open entry normalization - session-scan (new): transcript JSONL extraction — line-1 admission gate (type/version), text-block extraction, timestamp fallback chain (message-ms -> entry-ISO -> header-ISO -> mtime), prompt length and whitespace rules, one-level encoded-cwd sessions-root scan - store: legacy migration (v1 editor-history.jsonl + pre-v1 editor-history.json -> history-global.jsonl, one-time gate, .imported renames, corrupt-source skip) and project seed bootstrap (transcript scan -> seed.jsonl written ONCE so deletion cannot resurrect, tombstone suppression, cwd matching via encoded sessions dirs) - index.ts: getWriter now runs the upstream init sequence — migrate -> registry -> seed bootstrap -> open instance writer - indexing scope note: upstream's session indexing (session-index.ts + merge-history.ts, ~400 lines) is dead code on the PR branch — zero importers after the store-only drain pivot — and is intentionally absent from this chain (preserved out-of-tree for reference) - tests: 38 new node:test cases (cumulative 148/148): migration one-time gate + .imported renames + corrupt-source skip, seed written-once anti-resurrection + tombstone suppression + regen idempotence, transcript extraction matrix (583-line dev suite preserved), directory scan; tmpdir + fake-cwd fixtures throughout Gates: cumulative scoped history tests 148/148 green. Known pre-existing environmental gate failures unchanged. --- extensions/history/index.ts | 35 +- extensions/history/load-shared-history.ts | 39 ++ extensions/history/session-scan.ts | 233 ++++++++ extensions/history/store.ts | 200 ++++++- tests/history-legacy-migrate-v2.test.ts | 151 +++++ tests/history-load-shared-history.test.ts | 53 ++ tests/history-seed-bootstrap.test.ts | 170 ++++++ tests/history-seed-regen.test.ts | 86 +++ tests/history-session-scan-directory.test.ts | 87 +++ tests/history-session-scan-extract.test.ts | 583 +++++++++++++++++++ 10 files changed, 1625 insertions(+), 12 deletions(-) create mode 100644 extensions/history/load-shared-history.ts create mode 100644 extensions/history/session-scan.ts create mode 100644 tests/history-legacy-migrate-v2.test.ts create mode 100644 tests/history-load-shared-history.test.ts create mode 100644 tests/history-seed-bootstrap.test.ts create mode 100644 tests/history-seed-regen.test.ts create mode 100644 tests/history-session-scan-directory.test.ts create mode 100644 tests/history-session-scan-extract.test.ts diff --git a/extensions/history/index.ts b/extensions/history/index.ts index f9559b9d6..c36fc7f0f 100644 --- a/extensions/history/index.ts +++ b/extensions/history/index.ts @@ -2,9 +2,10 @@ // SPDX-License-Identifier: MIT // Prompt-history extension entry (slice 3): the selector TUI, overlay glue, -// and the shortcut/command wiring over the slice-1 writer and slice-2 -// drains. Legacy migration and seed bootstrap (slice 4), deletion (slice 5), -// and GC/compaction (slice 6) arrive in later slices. +// 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. import { join } from "node:path"; import { homedir } from "node:os"; @@ -16,9 +17,11 @@ import { } from "@earendil-works/pi-coding-agent"; import { appendSessionCapture, + bootstrapProjectSeed, drainGlobal, drainProject, ensureRegistryEntry, + migrateLegacyStores, openSessionWriter, type SessionWriterState, } from "./store.ts"; @@ -92,6 +95,11 @@ const PI_HISTORY_NAV_STATE_DIR = join( "history", ); +// Sessions root for the one-level transcript scan (spec C1, design §D5): +// ~/.pi/agent/sessions/. Read-only by invariant — transcripts are never +// written by this extension. +const SESSIONS_ROOT = join(homedir(), ".pi", "agent", "sessions"); + /** Width of the "→ " / " " prefix on each entry line. */ const ENTRY_PREFIX_WIDTH = 2; @@ -847,17 +855,32 @@ let selectorTui: { requestRender(): void } | null = null; let writerState: SessionWriterState | null = null; /** - * One-time init per extension load: register the project in the advisory - * registry, then open this instance's exclusive capture file. Legacy - * migration and seed bootstrap join this init order in a later slice. + * One-time init per extension load: migrate legacy stores, register the + * project, bootstrap the seed, then open this instance's exclusive file. */ function getWriter(): SessionWriterState { if (!writerState) { + try { + migrateLegacyStores(PI_HISTORY_ROOT, AGENT_DIR); + } catch { + // migration is best-effort; the gate keeps it one-shot + } try { ensureRegistryEntry(PI_HISTORY_ROOT, CURRENT_CWD); } catch { // registry is advisory } + try { + bootstrapProjectSeed( + PI_HISTORY_ROOT, + CURRENT_CWD, + SESSIONS_ROOT, + 500, + PI_HISTORY_NAV_STATE_DIR, + ); + } catch { + // bootstrap is a rebuildable cache + } writerState = openSessionWriter(PI_HISTORY_ROOT, CURRENT_CWD, INSTANCE_ID); } return writerState; diff --git a/extensions/history/load-shared-history.ts b/extensions/history/load-shared-history.ts new file mode 100644 index 000000000..79ef12f7d --- /dev/null +++ b/extensions/history/load-shared-history.ts @@ -0,0 +1,39 @@ +// SPDX-FileCopyrightText: 2026 ExoPro. Inspired by @jasonish/pi-prompt-history +// SPDX-License-Identifier: MIT + +import fs from "node:fs"; + +interface SharedHistoryEntry { + text: string; +} + +function isSharedHistoryEntry(value: unknown): value is SharedHistoryEntry { + if (!value || typeof value !== "object") return false; + return typeof (value as { text?: unknown }).text === "string"; +} + +function toSharedHistoryValue(value: unknown): string | null { + if (typeof value === "string") return value.length > 0 ? value : null; + if (isSharedHistoryEntry(value)) + return value.text.length > 0 ? value.text : null; + return null; +} + +function isNonEmptyString(value: string | null): value is string { + return typeof value === "string" && value.length > 0; +} + +export function loadSharedHistory(historyFile: string): string[] { + if (!fs.existsSync(historyFile)) return []; + + try { + const raw = fs.readFileSync(historyFile, "utf8"); + const parsed: unknown = JSON.parse(raw); + + if (!Array.isArray(parsed)) return []; + + return parsed.map(toSharedHistoryValue).filter(isNonEmptyString); + } catch { + return []; + } +} diff --git a/extensions/history/session-scan.ts b/extensions/history/session-scan.ts new file mode 100644 index 000000000..accd302c7 --- /dev/null +++ b/extensions/history/session-scan.ts @@ -0,0 +1,233 @@ +// SPDX-FileCopyrightText: 2026 ExoPro. Inspired by @jasonish/pi-prompt-history +// SPDX-License-Identifier: MIT + +import fs from "node:fs"; +import path from "node:path"; + +/** + * One user prompt extracted from a pi session transcript file. + * + * `ts` is the resolved ordering timestamp in milliseconds since the epoch. + * It follows the documented fallback chain (message ms-epoch → entry ISO → + * header ISO → file mtime) and exists for ordering only — extraction never + * fails because of it. + */ +export interface ExtractedPrompt { + text: string; + ts: number; +} + +/** + * Result of scanning one session file: the extracted user prompts (in file + * order) plus `skippedLines`, the number of gate-passing lines whose JSON + * could not be parsed. Parse failures are counted, never fatal. + */ +export interface FileScanResult { + prompts: ExtractedPrompt[]; + skippedLines: number; +} + +/** + * Maximum extracted prompt length in UTF-16 code units (`text.length`). A + * prompt strictly greater than this is skipped; at the maximum it still + * extracts. The skip is uniform and silent (design §D1). + */ +export const MAX_PROMPT_CHARS = 16384; + +/** + * Cheap per-line substring prefilter literal. PREFILTER ONLY: a miss means + * the line is never parsed; the parsed extraction rule below is the only + * membership authority. + */ +const USER_ROLE_LITERAL = '"role":"user"'; + +/** Defensive field-access shapes for session JSONL values. */ +interface SessionFields { + type?: unknown; + version?: unknown; + timestamp?: unknown; + message?: unknown; +} + +interface MessageFields { + role?: unknown; + content?: unknown; + timestamp?: unknown; +} + +/** + * Validate the line-1 session header. A file is admitted only when the + * header parses, carries type "session", and a numeric version ≤ 3 + * (format v3; legacy v1/v2 tolerated). Mirrors pi's loadEntriesFromFile + * tolerance: any miss yields an empty scan, never a throw. Returns the + * header timestamp in ms, or NaN when absent or unparseable. + */ +function parseHeader(line: string): number | null { + let header: unknown; + try { + header = JSON.parse(line); + } catch { + return null; + } + if (header == null || typeof header !== "object") return null; + const fields = header as SessionFields; + if (fields.type !== "session") return null; + if (typeof fields.version !== "number" || !(fields.version <= 3)) { + return null; + } + return typeof fields.timestamp === "string" + ? Date.parse(fields.timestamp) + : NaN; +} + +/** + * Extract the prompt text from a user message's content: plain string + * content passes through as-is; block arrays join their text blocks with a + * single space and trim the assembly (upstream extractTextContent rule, + * design §D1) — images and other block types are ignored, and zero text + * blocks yield no prompt. Returns null when no prompt text exists. + */ +function extractText(content: unknown): string | null { + if (typeof content === "string") return content; + if (!Array.isArray(content)) return null; + const parts: string[] = []; + for (const block of content) { + if ( + block != null && + typeof block === "object" && + (block as SessionFields).type === "text" && + typeof (block as { text?: unknown }).text === "string" + ) { + parts.push((block as { text: string }).text); + } + } + if (parts.length === 0) return null; + return parts.join(" ").trim(); +} + +/** + * Resolve a prompt's ordering timestamp: message ms-epoch, then entry ISO, + * then header ISO, then the file mtime. Every hop is NaN-tolerant; ordering + * data is never worth a throw. + */ +function resolveTimestamp( + entry: SessionFields, + message: MessageFields, + headerTs: number, + filePath: string, +): number { + if ( + typeof message.timestamp === "number" && + Number.isFinite(message.timestamp) + ) { + return message.timestamp; + } + if (typeof entry.timestamp === "string") { + const parsed = Date.parse(entry.timestamp); + if (!Number.isNaN(parsed)) return parsed; + } + if (!Number.isNaN(headerTs)) return headerTs; + try { + const mtimeMs = fs.statSync(filePath).mtimeMs; + if (!Number.isNaN(mtimeMs)) return mtimeMs; + } catch { + // The file vanished between read and stat — leave the NaN residue. + } + return NaN; +} + +/** + * Extract every user prompt from one pi session transcript file. + * + * Pipeline (design §C): line-1 header admission gate; per line the cheap + * USER_ROLE_LITERAL substring gate as a PREFILTER ONLY (gate misses are + * never parsed); JSON.parse with a counted skip on throw; then the parsed + * extraction rule (entry type "message" AND user role) as the only + * membership authority. Whitespace-only text is skipped silently, and text + * above MAX_PROMPT_CHARS skips uniformly. UI-free and fs-only: data-path + * errors degrade to empty or partial results and never throw. + */ +export function extractPromptsFromFile(filePath: string): FileScanResult { + let raw: string; + try { + raw = fs.readFileSync(filePath, "utf8"); + } catch { + return { prompts: [], skippedLines: 0 }; + } + + const lines = raw.split("\n"); + const headerTs = parseHeader(lines[0]); + if (headerTs === null) return { prompts: [], skippedLines: 0 }; + + const prompts: ExtractedPrompt[] = []; + let skippedLines = 0; + + for (let index = 1; index < lines.length; index++) { + const line = lines[index]; + // Prefilter: a miss never reaches JSON.parse. + if (!line.includes(USER_ROLE_LITERAL)) continue; + + let entry: unknown; + try { + entry = JSON.parse(line); + } catch { + skippedLines++; + continue; + } + if (entry == null || typeof entry !== "object") continue; + + const record = entry as SessionFields; + if (record.type !== "message") continue; + if (record.message == null || typeof record.message !== "object") continue; + const message = record.message as MessageFields; + if (message.role !== "user") continue; + + const text = extractText(message.content); + if (text === null || text.trim() === "") continue; // silent, not counted + if (text.length > MAX_PROMPT_CHARS) continue; // uniform silent length skip + + prompts.push({ + text, + ts: resolveTimestamp(record, message, headerTs, filePath), + }); + } + + return { prompts, skippedLines }; +} + +/** + * One-level scan rule (C1): list the top-level `*.jsonl` session files of + * every encoded-cwd directory under the pi sessions root. Only DIRECTORIES + * at the root are entered (stray root-level files are skipped) and only + * their top-level jsonl files are candidates — nested subagent payloads + * (`run-N/session.jsonl`) and `subagent-artifacts/` subtrees are directories + * and are never descended. An unreadable root yields an empty list and a + * directory whose readdir fails is skipped — never fatal. Returns sorted + * absolute paths for deterministic scan order. Mirrors pi's non-recursive + * listSessionsFromDir (session-manager.ts:822-826, read-only). + */ +export function listSessionFiles(sessionsRoot: string): string[] { + let rootEntries: fs.Dirent[]; + try { + rootEntries = fs.readdirSync(sessionsRoot, { withFileTypes: true }); + } catch { + return []; + } + const files: string[] = []; + for (const rootEntry of rootEntries) { + if (!rootEntry.isDirectory()) continue; + const dirPath = path.join(sessionsRoot, rootEntry.name); + let children: fs.Dirent[]; + try { + children = fs.readdirSync(dirPath, { withFileTypes: true }); + } catch { + continue; // one unreadable directory skips itself, never fatal + } + for (const child of children) { + if (child.isFile() && child.name.endsWith(".jsonl")) { + files.push(path.join(dirPath, child.name)); + } + } + } + return files.sort(); +} diff --git a/extensions/history/store.ts b/extensions/history/store.ts index fb470515c..7c8b2df57 100644 --- a/extensions/history/store.ts +++ b/extensions/history/store.ts @@ -1,17 +1,24 @@ // SPDX-FileCopyrightText: 2026 ExoPro. Inspired by @jasonish/pi-prompt-history // SPDX-License-Identifier: MIT -// Consolidated multi-concurrency store (v2), slices 1+2: project paths and -// identity, the advisory registry, entry primitives, the per-instance -// session writer, and the scope drain/reader/query section (ordering, -// dedup, tombstone filter, project/global drains). Legacy migration and -// seed bootstrap, scope deletes, and GC/compaction arrive in later slices. -// Formerly store-paths.ts + registry.ts + multi-store.ts (+ v1 primitives). +// Consolidated multi-concurrency store (v2), slices 1+2+4: project paths +// and identity, the advisory registry, entry primitives, the per-instance +// session writer, the scope drain/reader/query section (ordering, dedup, +// tombstone filter, project/global drains), legacy migration, and the +// project seed bootstrap. Scope deletes and GC/compaction arrive in later +// slices. Formerly store-paths.ts + registry.ts + multi-store.ts (+ v1 +// primitives). import { createHash } from "node:crypto"; import fs from "node:fs"; import path from "node:path"; import { loadHiddenPrompts } from "./hide-prompts.ts"; +import { loadSharedHistory } from "./load-shared-history.ts"; +import { + extractPromptsFromFile, + listSessionFiles, + type ExtractedPrompt, +} from "./session-scan.ts"; // =========================================================================== // Paths (formerly store-paths.ts) @@ -403,3 +410,184 @@ export function drainGlobal( stateDir ? loadHiddenPrompts(stateDir) : new Set(), ); } + +// --------------------------------------------------------------------------- +// Legacy migration (design v2: one-time, gated) +// --------------------------------------------------------------------------- + +export interface MigrationResult { + migrated: number; + ran: boolean; +} + +function readValidLines(file: string): StoreEntry[] { + try { + const raw = fs.readFileSync(file, "utf8"); + const entries: StoreEntry[] = []; + for (const lineText of raw.split("\n")) { + const parsed = parseStoreLine(lineText); + if (parsed) entries.push(parsed); + } + return entries; + } catch { + return []; + } +} + +/** + * One-time migration from the v1 stores into the v2 global seed: + * - `~/.pi/agent/editor-history.jsonl` (v1 single-file store) + * - `~/.pi/agent/editor-history.json` (pre-v1 array, newest-first) + * Content lands in `pi-history/history-global.jsonl` chronologically; each + * source is renamed `.imported`, never deleted. Gated: an existing global + * seed means migration already ran. + */ +export function migrateLegacyStores( + root: string, + agentDir: string, +): MigrationResult { + const seed = globalSeedPath(root); + if (fs.existsSync(seed)) return { migrated: 0, ran: false }; + + const collected: StoreEntry[] = []; + + // Pre-v1 array (newest-first) → reverse to chronological. + const legacyArray = path.join(agentDir, "editor-history.json"); + if (fs.existsSync(legacyArray)) { + const texts = loadSharedHistory(legacyArray); + if (texts.length > 0) { + for (let i = texts.length - 1; i >= 0; i--) { + collected.push({ v: 1, text: texts[i] }); + } + } + try { + fs.renameSync(legacyArray, `${legacyArray}.imported`); + } catch { + // The seed write below is the source of truth; rename failure is benign. + } + } + + // v1 single-file store — already chronological. + const v1File = path.join(agentDir, "editor-history.jsonl"); + if (fs.existsSync(v1File)) { + collected.push(...readValidLines(v1File)); + try { + fs.renameSync(v1File, `${v1File}.imported`); + } catch { + // benign + } + } + + if (collected.length === 0) return { migrated: 0, ran: false }; + + fs.mkdirSync(path.dirname(seed), { recursive: true }); + const tmp = `${seed}.tmp-${process.pid}-${Date.now()}`; + fs.writeFileSync( + tmp, + collected.map((e) => JSON.stringify(e)).join("\n") + "\n", + "utf8", + ); + fs.renameSync(tmp, seed); + return { migrated: collected.length, ran: true }; +} + +// --------------------------------------------------------------------------- +// Project bootstrap (design v2: seed.jsonl) +// --------------------------------------------------------------------------- + +export interface SeedResult { + seeded: number; + ran: boolean; +} + +/** + * Seed `projects//seed.jsonl` from the project's pi transcripts when + * the project dir holds fewer than `target` entries. Existing session files + * are counted; their prompts are NOT re-seeded (dedupe by UI-level key). + * The seed is a rebuildable cache — rewritten only when the dir is empty. + */ +export function bootstrapProjectSeed( + root: string, + cwd: string, + sessionsRoot: string, + target: number, + stateDir?: string, +): SeedResult { + const dir = path.join(root, "projects", projectHash(cwd)); + + // Count existing entries and collect their identities. + const existingKeys = new Set(); + let existingCount = 0; + for (const file of listProjectFiles(dir)) { + let raw = ""; + try { + raw = fs.readFileSync(file, "utf8"); + } catch { + continue; + } + for (const lineText of raw.split("\n")) { + const parsed = parseStoreLine(lineText); + if (parsed) { + existingCount += 1; + existingKeys.add(promptKey(parsed.text)); + } + } + } + if (existingCount >= target) return { seeded: 0, ran: false }; + // The seed is written ONCE: an existing seed is never regenerated, so a + // deleted prompt cannot be resurrected from transcripts on a new session. + if (fs.existsSync(seedFilePath(root, cwd))) { + return { seeded: 0, ran: false }; + } + // Tombstones (user deletions) suppress transcript prompts from seeding. + const hidden = stateDir ? loadHiddenPrompts(stateDir) : new Set(); + + // Scan transcripts: session files of THIS project's dir, newest first. + let files: string[] = []; + try { + const dirName = cwd + .replace(/^[/\\]/, "") + .replace(/[/\\:]/g, "-"); + files = listSessionFiles(sessionsRoot).filter((file) => + file.includes(`${path.sep}--${dirName}--${path.sep}`), + ); + } catch { + return { seeded: 0, ran: false }; + } + files.sort((a, b) => fileMtimeMs(b) - fileMtimeMs(a)); + + const collected: StoreEntry[] = []; + outer: for (const file of files) { + let prompts: ExtractedPrompt[] = []; + try { + prompts = extractPromptsFromFile(file).prompts; + } catch { + continue; + } + for (let i = prompts.length - 1; i >= 0; i--) { + const text = prompts[i].text; + if (/^\/[A-Za-z]/.test(text.trim())) continue; + if (hidden.size > 0 && hidden.has(promptDedupKeyOf(text))) continue; + const key = promptKey(text); + if (existingKeys.has(key)) continue; + existingKeys.add(key); + const entry: StoreEntry = { v: 1, text }; + if (Number.isFinite(prompts[i].ts)) entry.ts = prompts[i].ts; + collected.push(entry); + if (collected.length >= target - existingCount) break outer; + } + } + if (collected.length === 0) return { seeded: 0, ran: false }; + + collected.reverse(); // chronological (oldest first) + const seed = seedFilePath(root, cwd); + fs.mkdirSync(path.dirname(seed), { recursive: true }); + const tmp = `${seed}.tmp-${process.pid}-${Date.now()}`; + fs.writeFileSync( + tmp, + collected.map((e) => JSON.stringify(e)).join("\n") + "\n", + "utf8", + ); + fs.renameSync(tmp, seed); + return { seeded: collected.length, ran: true }; +} diff --git a/tests/history-legacy-migrate-v2.test.ts b/tests/history-legacy-migrate-v2.test.ts new file mode 100644 index 000000000..08c41a471 --- /dev/null +++ b/tests/history-legacy-migrate-v2.test.ts @@ -0,0 +1,151 @@ +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 { globalSeedPath, migrateLegacyStores } from "../extensions/history/store.ts"; + +function makeDirs(): { root: string; agentDir: string } { + const base = fs.mkdtempSync(path.join(os.tmpdir(), "pi-history-mig-")); + const root = path.join(base, "pi-history"); + const agentDir = path.join(base, "agent"); + fs.mkdirSync(agentDir, { recursive: true }); + return { root, agentDir }; +} + +function fileTexts(file: string): string[] { + if (!fs.existsSync(file)) return []; + return fs + .readFileSync(file, "utf8") + .trim() + .split("\n") + .filter((l) => l.length > 0) + .map((l) => (JSON.parse(l) as { text: string }).text); +} + +test("no legacy files: migration is a no-op, nothing created", () => { + const { root, agentDir } = makeDirs(); + const result = migrateLegacyStores(root, agentDir); + assert.deepEqual(result, { migrated: 0, ran: false }); + assert.equal(fs.existsSync(globalSeedPath(root)), false); +}); + +test("v1 jsonl migrates into the global seed chronologically", () => { + const { root, agentDir } = makeDirs(); + const v1 = path.join(agentDir, "editor-history.jsonl"); + fs.writeFileSync( + v1, + `${[ + JSON.stringify({ v: 1, text: "old" }), + JSON.stringify({ v: 1, text: "new" }), + ].join("\n")}\n`, + "utf8", + ); + const result = migrateLegacyStores(root, agentDir); + assert.deepEqual(result, { migrated: 2, ran: true }); + assert.deepEqual(fileTexts(globalSeedPath(root)), ["old", "new"]); + assert.equal(fs.existsSync(v1), false); + assert.equal(fs.existsSync(`${v1}.imported`), true); +}); + +test("legacy array file also migrates (newest-first reversed)", () => { + const { root, agentDir } = makeDirs(); + const legacy = path.join(agentDir, "editor-history.json"); + fs.writeFileSync(legacy, JSON.stringify(["newest", "oldest"]), "utf8"); + const result = migrateLegacyStores(root, agentDir); + assert.deepEqual(result, { migrated: 2, ran: true }); + assert.deepEqual(fileTexts(globalSeedPath(root)), ["oldest", "newest"]); + assert.equal(fs.existsSync(`${legacy}.imported`), true); +}); + +test("both legacy files: v1 jsonl content appends after array content", () => { + const { root, agentDir } = makeDirs(); + const legacy = path.join(agentDir, "editor-history.json"); + const v1 = path.join(agentDir, "editor-history.jsonl"); + fs.writeFileSync(legacy, JSON.stringify(["from-array"]), "utf8"); + fs.writeFileSync( + v1, + `${JSON.stringify({ v: 1, text: "from-jsonl" })}\n`, + "utf8", + ); + migrateLegacyStores(root, agentDir); + assert.deepEqual(fileTexts(globalSeedPath(root)), [ + "from-array", + "from-jsonl", + ]); + assert.equal(fs.existsSync(`${legacy}.imported`), true); + assert.equal(fs.existsSync(`${v1}.imported`), true); +}); + +test("existing global seed gates the migration (idempotent)", () => { + const { root, agentDir } = makeDirs(); + fs.mkdirSync(path.dirname(globalSeedPath(root)), { recursive: true }); + fs.writeFileSync( + globalSeedPath(root), + `${JSON.stringify({ v: 1, text: "already-here" })}\n`, + "utf8", + ); + const v1 = path.join(agentDir, "editor-history.jsonl"); + fs.writeFileSync( + v1, + `${JSON.stringify({ v: 1, text: "would-migrate" })}\n`, + "utf8", + ); + const result = migrateLegacyStores(root, agentDir); + assert.deepEqual(result, { migrated: 0, ran: false }); + assert.deepEqual(fileTexts(globalSeedPath(root)), ["already-here"]); + assert.equal(fs.existsSync(v1), true); +}); + +test("malformed v1 jsonl lines are skipped, not fatal", () => { + const { root, agentDir } = makeDirs(); + const v1 = path.join(agentDir, "editor-history.jsonl"); + fs.writeFileSync( + v1, + `${["{torn", JSON.stringify({ v: 1, text: "good" })].join("\n")}\n`, + "utf8", + ); + const result = migrateLegacyStores(root, agentDir); + assert.deepEqual(result, { migrated: 1, ran: true }); + assert.deepEqual(fileTexts(globalSeedPath(root)), ["good"]); +}); + +// node:test has no test.skipIf (Bun-ism): root skips via the options +// object — chmod 000 is invisible to the superuser. +const sealedLegacyTest = (name: string, fn: () => void) => + test( + name, + { skip: process.getuid?.() === 0 ? "requires non-root" : false }, + fn, + ); +sealedLegacyTest( + "an unreadable legacy file is skipped; the readable file still migrates", + () => { + const { root, agentDir } = makeDirs(); + const readable = path.join(agentDir, "editor-history.json"); + fs.writeFileSync(readable, JSON.stringify(["from-array"]), "utf8"); + const sealed = path.join(agentDir, "editor-history.jsonl"); + fs.writeFileSync( + sealed, + `${JSON.stringify({ v: 1, text: "sealed-content" })}\n`, + "utf8", + ); + fs.chmodSync(sealed, 0o000); + try { + // The sealed file's bytes are unreadable: its prompts contribute + // nothing to the seed; the readable array still migrates. No throw. + const result = migrateLegacyStores(root, agentDir); + assert.deepEqual(result, { migrated: 1, ran: true }); + assert.deepEqual(fileTexts(globalSeedPath(root)), ["from-array"]); + } finally { + // The migration archives the unreadable file as `.imported` (rename + // needs no read permission — content skipped, file still moved aside); + // restore only when the original path survived an early failure. + try { + fs.chmodSync(sealed, 0o644); + } catch { + // already renamed to `.imported` by the migration + } + } + }, +); diff --git a/tests/history-load-shared-history.test.ts b/tests/history-load-shared-history.test.ts new file mode 100644 index 000000000..235a7b1bf --- /dev/null +++ b/tests/history-load-shared-history.test.ts @@ -0,0 +1,53 @@ +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 { loadSharedHistory } from "../extensions/history/load-shared-history.ts"; + +function makeTempFile(content: string): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "history-load-")); + const file = path.join(dir, "editor-history.json"); + fs.writeFileSync(file, content, "utf8"); + return file; +} + +test("returns empty array when file is missing", () => { + assert.deepEqual(loadSharedHistory("/definitely/missing.json"), []); +}); + +test("returns empty array for malformed json", () => { + const file = makeTempFile("{not-json"); + assert.deepEqual(loadSharedHistory(file), []); +}); + +test("supports string entries", () => { + const file = makeTempFile(JSON.stringify(["one", "two"])); + assert.deepEqual(loadSharedHistory(file), ["one", "two"]); +}); + +test("supports object entries with text field", () => { + const file = makeTempFile(JSON.stringify([{ text: "one" }, { text: "two" }])); + assert.deepEqual(loadSharedHistory(file), ["one", "two"]); +}); + +test("ignores malformed object entries", () => { + const file = makeTempFile( + JSON.stringify([{ text: "one" }, { text: 2 }, { nope: "three" }]), + ); + assert.deepEqual(loadSharedHistory(file), ["one"]); +}); + +test("ignores empty string entries", () => { + const file = makeTempFile( + JSON.stringify(["", "one", { text: "" }, { text: "two" }]), + ); + assert.deepEqual(loadSharedHistory(file), ["one", "two"]); +}); + +test("keeps only valid strings from mixed arrays", () => { + const file = makeTempFile( + JSON.stringify(["one", null, false, 42, { text: "two" }, { text: 1 }]), + ); + assert.deepEqual(loadSharedHistory(file), ["one", "two"]); +}); diff --git a/tests/history-seed-bootstrap.test.ts b/tests/history-seed-bootstrap.test.ts new file mode 100644 index 000000000..d8126f254 --- /dev/null +++ b/tests/history-seed-bootstrap.test.ts @@ -0,0 +1,170 @@ +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 { + bootstrapProjectSeed, + projectHash, + seedFilePath, +} from "../extensions/history/store.ts"; + +// Fake project cwd (never created on disk): projectHash falls back to +// raw-string hashing for nonexistent paths, and the transcript dirName +// encoding derives from the same string. +const CWD = "/pi-history-test/seed-project"; +const DIR = "--pi-history-test-seed-project--"; + +function makeDirs(): { root: string; sessionsRoot: string } { + const base = fs.mkdtempSync(path.join(os.tmpdir(), "pi-history-seed-")); + return { + root: path.join(base, "pi-history"), + sessionsRoot: path.join(base, "sessions"), + }; +} + +function writeSession( + sessionsRoot: string, + dirName: string, + fileName: string, + userTexts: string[], + mtimeMs?: number, +): string { + const dir = path.join(sessionsRoot, dirName); + fs.mkdirSync(dir, { recursive: true }); + const file = path.join(dir, fileName); + const lines: string[] = [ + JSON.stringify({ + type: "session", + version: 1, + timestamp: "2026-01-01T00:00:00.000Z", + }), + ]; + let ms = 1700000000000; + for (const text of userTexts) { + lines.push( + JSON.stringify({ + type: "message", + timestamp: "2026-01-01T00:00:00.000Z", + message: { role: "user", content: text, timestamp: ms }, + }), + ); + ms += 1; + } + fs.writeFileSync(file, `${lines.join("\n")}\n`, "utf8"); + if (mtimeMs !== undefined) { + fs.utimesSync(file, new Date(mtimeMs), new Date(mtimeMs)); + } + return file; +} + +function seedTexts(root: string): string[] { + const file = seedFilePath(root, CWD); + if (!fs.existsSync(file)) return []; + return fs + .readFileSync(file, "utf8") + .trim() + .split("\n") + .filter((l) => l.length > 0) + .map((l) => (JSON.parse(l) as { text: string }).text); +} + +test("no sessions and no project dir: bootstrap seeds nothing", () => { + const { root, sessionsRoot } = makeDirs(); + const result = bootstrapProjectSeed(root, CWD, sessionsRoot, 500); + assert.deepEqual(result, { seeded: 0, ran: false }); + assert.equal(fs.existsSync(seedFilePath(root, CWD)), false); +}); + +test("empty project dir bootstraps from the project's transcripts", () => { + const { root, sessionsRoot } = makeDirs(); + writeSession(sessionsRoot, DIR, "s1.jsonl", [ + "real prompt", + "/compact", + " ", + "second prompt", + ]); + const result = bootstrapProjectSeed(root, CWD, sessionsRoot, 500); + assert.deepEqual(result, { seeded: 2, ran: true }); + // Chronological order (oldest first) in the seed file. + assert.deepEqual(seedTexts(root), ["real prompt", "second prompt"]); +}); + +test("only the project's own session dir is scanned", () => { + const { root, sessionsRoot } = makeDirs(); + writeSession(sessionsRoot, DIR, "s1.jsonl", ["mine"]); + writeSession(sessionsRoot, "--Other--", "s2.jsonl", ["not mine"]); + bootstrapProjectSeed(root, CWD, sessionsRoot, 500); + assert.deepEqual(seedTexts(root), ["mine"]); +}); + +test("caps at the target keeping the newest", () => { + const { root, sessionsRoot } = makeDirs(); + const texts: string[] = []; + for (let i = 1; i <= 600; i++) texts.push(`p${i}`); + writeSession(sessionsRoot, DIR, "big.jsonl", texts); + const result = bootstrapProjectSeed(root, CWD, sessionsRoot, 500); + assert.deepEqual(result, { seeded: 500, ran: true }); + const all = seedTexts(root); + assert.equal(all.length, 500); + assert.equal(all[0], "p101"); // oldest kept + assert.equal(all[499], "p600"); // newest +}); + +test("project dir already populated above target: no scan, seed untouched", () => { + const { root, sessionsRoot } = makeDirs(); + const dir = path.join(root, "projects", projectHash(CWD)); + fs.mkdirSync(dir, { recursive: true }); + const existing = path.join(dir, "existing.jsonl"); + fs.writeFileSync( + existing, + `${Array.from({ length: 500 }, (_, i) => + JSON.stringify({ v: 1, text: `e${i}` }), + ).join("\n")}\n`, + "utf8", + ); + const marker = writeSession(sessionsRoot, DIR, "s.jsonl", ["marker"]); + fs.utimesSync( + marker, + new Date(Date.now() + 5000), + new Date(Date.now() + 5000), + ); + const result = bootstrapProjectSeed(root, CWD, sessionsRoot, 500); + assert.deepEqual(result, { seeded: 0, ran: false }); + assert.deepEqual(seedTexts(root), []); + assert.equal(fs.readFileSync(existing, "utf8").includes("marker"), false); +}); + +// node:test has no test.skipIf (Bun-ism): root skips via the options +// object — chmod 000 is invisible to the superuser. +const sealedStoreTest = (name: string, fn: () => void) => + test( + name, + { skip: process.getuid?.() === 0 ? "requires non-root" : false }, + fn, + ); +sealedStoreTest( + "an unreadable existing store file is skipped during counting; seeding still runs from transcripts", + () => { + const { root, sessionsRoot } = makeDirs(); + const dir = path.join(root, "projects", projectHash(CWD)); + fs.mkdirSync(dir, { recursive: true }); + const sealed = path.join(dir, "sealed.jsonl"); + fs.writeFileSync( + sealed, + `${JSON.stringify({ v: 1, text: "sealed-entry" })}\n`, + "utf8", + ); + fs.chmodSync(sealed, 0o000); + writeSession(sessionsRoot, DIR, "s1.jsonl", ["from transcript"]); + try { + // The unreadable file contributes zero to existingCount, so the count + // stays under target and the transcript scan still runs. No throw. + const result = bootstrapProjectSeed(root, CWD, sessionsRoot, 500); + assert.deepEqual(result, { seeded: 1, ran: true }); + assert.deepEqual(seedTexts(root), ["from transcript"]); + } finally { + fs.chmodSync(sealed, 0o644); // restore before cleanup + } + }, +); diff --git a/tests/history-seed-regen.test.ts b/tests/history-seed-regen.test.ts new file mode 100644 index 000000000..e4e16539e --- /dev/null +++ b/tests/history-seed-regen.test.ts @@ -0,0 +1,86 @@ +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 { bootstrapProjectSeed, seedFilePath } from "../extensions/history/store.ts"; + +// Fake project cwd (never created on disk): projectHash falls back to +// raw-string hashing for nonexistent paths, and the transcript dirName +// encoding derives from the same string. +const CWD = "/pi-history-test/seed-regen-project"; +const DIR = "--pi-history-test-seed-regen-project--"; + +function setup() { + const base = fs.mkdtempSync(path.join(os.tmpdir(), "seed2-")); + return { + root: path.join(base, "h"), + sessionsRoot: path.join(base, "sessions"), + stateDir: path.join(base, "state"), + }; +} + +function writeSession(sessionsRoot: string, texts: string[]): void { + const dir = path.join(sessionsRoot, DIR); + fs.mkdirSync(dir, { recursive: true }); + const lines = [ + JSON.stringify({ + type: "session", + version: 1, + timestamp: "2026-01-01T00:00:00.000Z", + }), + ]; + let ms = 1700000000000; + for (const text of texts) { + lines.push( + JSON.stringify({ + type: "message", + timestamp: "2026-01-01T00:00:00.000Z", + message: { role: "user", content: text, timestamp: ms++ }, + }), + ); + } + fs.writeFileSync(path.join(dir, "s1.jsonl"), `${lines.join("\n")}\n`, "utf8"); +} + +test("an existing seed is never regenerated (deleted prompts stay gone)", () => { + const { root, sessionsRoot } = setup(); + writeSession(sessionsRoot, ["keep", "delete-me"]); + bootstrapProjectSeed(root, CWD, sessionsRoot, 500); + // User deletes "delete-me" from the seed file (scope delete). + const seed = seedFilePath(root, CWD); + const kept = fs + .readFileSync(seed, "utf8") + .split("\n") + .filter((l) => !l.includes("delete-me")) + .join("\n"); + fs.writeFileSync(seed, kept, "utf8"); + // A NEW session bootstraps again -> must not resurrect from transcripts. + const again = bootstrapProjectSeed(root, CWD, sessionsRoot, 500); + assert.deepEqual(again, { seeded: 0, ran: false }); + const texts = fs + .readFileSync(seed, "utf8") + .trim() + .split("\n") + .map((l) => (JSON.parse(l) as { text: string }).text); + assert.deepEqual(texts, ["keep"]); +}); + +test("tombstoned prompts are not seeded from transcripts", () => { + const { root, sessionsRoot, stateDir } = setup(); + writeSession(sessionsRoot, ["visible", "hidden-prompt"]); + // Tombstone "hidden-prompt" (same key shape hide-prompts writes). + fs.mkdirSync(stateDir, { recursive: true }); + fs.writeFileSync( + path.join(stateDir, "hidden.json"), + JSON.stringify(["hidden-prompt"]), + "utf8", + ); + bootstrapProjectSeed(root, CWD, sessionsRoot, 500, stateDir); + const texts = fs + .readFileSync(seedFilePath(root, CWD), "utf8") + .trim() + .split("\n") + .map((l) => (JSON.parse(l) as { text: string }).text); + assert.deepEqual(texts, ["visible"]); +}); diff --git a/tests/history-session-scan-directory.test.ts b/tests/history-session-scan-directory.test.ts new file mode 100644 index 000000000..d868b5073 --- /dev/null +++ b/tests/history-session-scan-directory.test.ts @@ -0,0 +1,87 @@ +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 { listSessionFiles } from "../extensions/history/session-scan.ts"; + +/** + * WU1b-carried T7 (AC-S1-7): the one-level directory exclusion matrix. The + * fixture tree mirrors the pi sessions root — encoded-cwd directories with + * top-level jsonl session files, nested run-N/session.jsonl subagent + * payloads, a subagent-artifacts subtree, and a stray root-level file. + * Fixture files carry garbage content: the scanner must LIST paths only, so + * exact path equality proves nested payloads are never ingested (a + * recursive scanner would emit them). + */ +function makeSessionsRoot(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), "session-scan-dirs-")); +} + +function writeFileAt(filePath: string): void { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, "garbage-not-json\n", "utf8"); +} + +test("one-level scan rule: only top-level jsonl of cwd dirs; nested payloads, subagent-artifacts, and stray root files never ingested (AC-S1-7)", () => { + const root = makeSessionsRoot(); + + // two cwd directories, each holding top-level jsonl session files + const cwdA = path.join(root, "--home-user-project-a--"); + const cwdB = path.join(root, "--home-user-project-b--"); + writeFileAt(path.join(cwdA, "2026-01-01t10-00-00aaa.jsonl")); + writeFileAt(path.join(cwdA, "2026-01-02t11-00-00bbb.jsonl")); + writeFileAt(path.join(cwdB, "2026-01-03t12-00-00ccc.jsonl")); + + // nested run-N/session.jsonl subagent payloads — never descended + writeFileAt(path.join(cwdA, "run-1", "session.jsonl")); + writeFileAt(path.join(cwdB, "run-2", "session.jsonl")); + + // a subagent-artifacts subtree holding a jsonl file — never descended + writeFileAt(path.join(cwdA, "subagent-artifacts", "artifact.jsonl")); + + // one stray root-level FILE (not a directory) — skipped + writeFileAt(path.join(root, "stray.jsonl")); + + const files = listSessionFiles(root); + + // exactly the three top-level jsonl paths of the cwd directories, + // sorted, absolute + assert.deepEqual(files, [ + path.join(cwdA, "2026-01-01t10-00-00aaa.jsonl"), + path.join(cwdA, "2026-01-02t11-00-00bbb.jsonl"), + path.join(cwdB, "2026-01-03t12-00-00ccc.jsonl"), + ]); + for (const file of files) { + assert.equal(path.isAbsolute(file), true); + } +}); + +// node:test has no test.skipIf (Bun-ism): root skips via the options +// object — chmod 000 is invisible to the superuser. +const sealedDirTest = (name: string, fn: () => void) => + test( + name, + { skip: process.getuid?.() === 0 ? "requires non-root" : false }, + fn, + ); +sealedDirTest( + "an unreadable child dir (chmod 000) is skipped; sibling dirs still list", + () => { + const root = makeSessionsRoot(); + const sealed = path.join(root, "--sealed--"); + const open = path.join(root, "--open--"); + writeFileAt(path.join(sealed, "hidden-session.jsonl")); + writeFileAt(path.join(open, "visible-session.jsonl")); + fs.chmodSync(sealed, 0o000); + try { + // One directory whose readdir fails skips itself — never fatal — and + // the sibling directories still contribute their files. + assert.deepEqual(listSessionFiles(root), [ + path.join(open, "visible-session.jsonl"), + ]); + } finally { + fs.chmodSync(sealed, 0o755); // restore before cleanup + } + }, +); diff --git a/tests/history-session-scan-extract.test.ts b/tests/history-session-scan-extract.test.ts new file mode 100644 index 000000000..7814ef8cc --- /dev/null +++ b/tests/history-session-scan-extract.test.ts @@ -0,0 +1,583 @@ +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 { + extractPromptsFromFile, + listSessionFiles, + MAX_PROMPT_CHARS, +} from "../extensions/history/session-scan.ts"; + +/** + * WU1a fixtures (AC-S1-1..6): synthetic v3 session JSONL written to OS temp + * dirs — the module under test is fs-only and takes the file path as a + * parameter. Object lines serialize compactly (pi's JSONL shape); raw + * strings land verbatim for corrupt-line fixtures. + */ +function writeSessionFile(lines: Array): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "session-scan-")); + const file = path.join(dir, "session.jsonl"); + const serialized = lines + .map((line) => (typeof line === "string" ? line : JSON.stringify(line))) + .join("\n"); + fs.writeFileSync(file, `${serialized}\n`, "utf8"); + return file; +} + +/** + * WU1b fixtures (AC-S1-8..9): a synthetic pi sessions root whose shape + * mirrors ~/.pi/agent/sessions — encoded-cwd directories holding top-level + * jsonl session files. + */ +function makeSessionsRoot(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), "session-scan-root-")); +} + +function writeFileAt(filePath: string, lines: Array): void { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + const serialized = lines + .map((line) => (typeof line === "string" ? line : JSON.stringify(line))) + .join("\n"); + fs.writeFileSync(filePath, `${serialized}\n`, "utf8"); +} + +function sessionHeader(overrides: Record = {}): object { + return { + type: "session", + version: 3, + timestamp: "2026-01-15T10:00:00.000Z", + id: "session-1", + cwd: "/tmp/project", + ...overrides, + }; +} + +function userTextEntry( + text: string, + options: { messageTimestamp?: number; entryTimestamp?: string } = {}, +): object { + const message: Record = { role: "user", content: text }; + if (options.messageTimestamp !== undefined) { + message.timestamp = options.messageTimestamp; + } + const entry: Record = { + type: "message", + id: "entry-1", + parentId: null, + message, + }; + if (options.entryTimestamp !== undefined) { + entry.timestamp = options.entryTimestamp; + } + return entry; +} + +test("extracts exactly the user text block with the message ms-epoch ts (AC-S1-1)", () => { + const user = { + type: "message", + id: "m1", + parentId: null, + timestamp: "2026-01-15T10:00:01.000Z", + message: { + role: "user", + content: [{ type: "text", text: "hello from the user" }], + timestamp: 1768468801123, + }, + }; + const assistant = { + type: "message", + id: "m2", + parentId: "m1", + timestamp: "2026-01-15T10:00:02.000Z", + message: { + role: "assistant", + content: [{ type: "text", text: "assistant reply" }], + }, + }; + const toolResult = { + type: "message", + id: "m3", + parentId: "m2", + timestamp: "2026-01-15T10:00:03.000Z", + message: { + role: "toolResult", + content: [{ type: "text", text: "tool output" }], + }, + }; + const file = writeSessionFile([sessionHeader(), user, assistant, toolResult]); + const result = extractPromptsFromFile(file); + assert.equal(result.prompts.length, 1); + assert.deepEqual(result.prompts[0], { + text: "hello from the user", + ts: 1768468801123, + }); + assert.equal(result.skippedLines, 0); +}); + +test("excludes every non-prompt entry type and role; the valid user entry still extracts (AC-S1-2)", () => { + // compaction / custom_message / custom carry a nested "role":"user" so the + // substring gate HITS and the parsed type rule must reject them — the gate + // never decides membership. The role exclusions below gate-miss instead. + const exclusions = [ + { + type: "compaction", + message: { role: "user", content: "compacted summary" }, + }, + { type: "branch_summary", summary: "branched from main" }, + { type: "custom", customType: "state_snapshot", data: { role: "user" } }, + { + type: "custom_message", + message: { role: "user", content: "custom message text" }, + }, + { type: "label", name: "checkpoint" }, + { type: "session_info", version: 3 }, + { type: "model_change", message: { role: "assistant", content: "switch" } }, + { type: "thinking_level_change", level: "high" }, + { + type: "message", + message: { + role: "assistant", + content: [{ type: "text", text: "reply" }], + }, + }, + { + type: "message", + message: { + role: "toolResult", + content: [{ type: "text", text: "output" }], + }, + }, + { + type: "message", + message: { + role: "bashExecution", + content: [{ type: "text", text: "ls -la" }], + }, + }, + ]; + const file = writeSessionFile([ + sessionHeader(), + userTextEntry("real prompt"), + ...exclusions, + ]); + const result = extractPromptsFromFile(file); + assert.equal(result.prompts.length, 1); + assert.equal(result.prompts[0].text, "real prompt"); + assert.equal(result.skippedLines, 0); +}); + +test("skips empty, whitespace-only, and images-only user content; neighbors still extract (AC-S1-3)", () => { + const entries = [ + userTextEntry("real text before"), + { + type: "message", + message: { + role: "user", + content: [{ type: "image", source: { type: "base64", data: "img" } }], + }, + }, + { type: "message", message: { role: "user", content: "" } }, + { type: "message", message: { role: "user", content: " \n\t " } }, + { + type: "message", + message: { role: "user", content: [{ type: "text", text: " \t " }] }, + }, + userTextEntry("real text after"), + ]; + const file = writeSessionFile([sessionHeader(), ...entries]); + const result = extractPromptsFromFile(file); + assert.deepEqual( + result.prompts.map((prompt) => prompt.text), + ["real text before", "real text after"], + ); + assert.equal(result.skippedLines, 0); +}); + +test("ts precedence: message ms beats entry ISO; ISO alone; header ts; file mtime; NaN hops tolerated (AC-S1-4)", () => { + // (a) message ms-epoch beats entry ISO + const a = writeSessionFile([ + sessionHeader(), + userTextEntry("a", { + messageTimestamp: 1700000000123, + entryTimestamp: "2023-11-14T22:13:19.000Z", + }), + ]); + assert.equal(extractPromptsFromFile(a).prompts[0].ts, 1700000000123); + + // (b) entry ISO only + const b = writeSessionFile([ + sessionHeader(), + userTextEntry("b", { entryTimestamp: "2024-03-01T09:30:00.000Z" }), + ]); + assert.equal( + extractPromptsFromFile(b).prompts[0].ts, + Date.parse("2024-03-01T09:30:00.000Z"), + ); + + // (c) neither present → header timestamp + const c = writeSessionFile([sessionHeader(), userTextEntry("c")]); + assert.equal( + extractPromptsFromFile(c).prompts[0].ts, + Date.parse("2026-01-15T10:00:00.000Z"), + ); + + // NaN tolerance at the entry-ISO hop: garbage entry timestamp falls through + const garbage = writeSessionFile([ + sessionHeader(), + userTextEntry("g", { entryTimestamp: "not-a-timestamp" }), + ]); + assert.equal( + extractPromptsFromFile(garbage).prompts[0].ts, + Date.parse("2026-01-15T10:00:00.000Z"), + ); + + // final fallback: unparseable header timestamp → the file mtime + const before = Date.now() - 5; + const mtimeFile = writeSessionFile([ + sessionHeader({ timestamp: "garbage" }), + userTextEntry("m"), + ]); + const after = Date.now() + 5000; + const ts = extractPromptsFromFile(mtimeFile).prompts[0].ts; + assert.ok(Number.isFinite(ts)); + assert.ok(ts >= before && ts <= after); +}); + +test("corrupt lines are skipped, counted, and never fatal (AC-S1-5)", () => { + const file = writeSessionFile([ + sessionHeader(), + userTextEntry("one"), + '{"type":"message","message":{"role":"user"', + userTextEntry("two"), + '{broken json with "role":"user" inside}', + userTextEntry("three"), + 'not json "role":"user" at all', + ",{oops", + ]); + const result = extractPromptsFromFile(file); + assert.deepEqual( + result.prompts.map((prompt) => prompt.text), + ["one", "two", "three"], + ); + // The three corrupt gate-hit lines count; the gate-missed corrupt line is + // skipped by the prefilter without ever being parsed or counted. + assert.equal(result.skippedLines, 3); +}); + +test("bad-header aborts yield zero entries without throwing (AC-S1-6)", () => { + const emptyResult = { prompts: [], skippedLines: 0 }; + + // first line missing: an empty file + const emptyDir = fs.mkdtempSync(path.join(os.tmpdir(), "session-scan-")); + const emptyFile = path.join(emptyDir, "session.jsonl"); + fs.writeFileSync(emptyFile, "", "utf8"); + assert.deepEqual(extractPromptsFromFile(emptyFile), emptyResult); + + // unparseable first line + const unparseable = writeSessionFile([ + "{not json at all", + userTextEntry("ignored"), + ]); + assert.deepEqual(extractPromptsFromFile(unparseable), emptyResult); + + // first line type is not session + const wrongType = writeSessionFile([ + { type: "compaction", version: 3 }, + userTextEntry("ignored"), + ]); + assert.deepEqual(extractPromptsFromFile(wrongType), emptyResult); + + // version >= 4 + const futureVersion = writeSessionFile([ + sessionHeader({ version: 4 }), + userTextEntry("ignored"), + ]); + assert.deepEqual(extractPromptsFromFile(futureVersion), emptyResult); + + // non-numeric version is rejected without coercion + const stringVersion = writeSessionFile([ + sessionHeader({ version: "3" }), + userTextEntry("ignored"), + ]); + assert.deepEqual(extractPromptsFromFile(stringVersion), emptyResult); +}); + +test("boundaries: header-only file, blank padding lines, gate hits that fail the parsed rule (triangulation)", () => { + const emptyResult = { prompts: [], skippedLines: 0 }; + + // header-only file: admitted, zero prompts, zero skips + const headerOnly = writeSessionFile([sessionHeader()]); + assert.deepEqual(extractPromptsFromFile(headerOnly), emptyResult); + + // trailing and interior blank lines never parse (gate economy) + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "session-scan-")); + const padded = path.join(dir, "session.jsonl"); + fs.writeFileSync( + padded, + JSON.stringify(sessionHeader()) + + "\n\n" + + JSON.stringify(userTextEntry("padded")) + + "\n\n", + "utf8", + ); + const paddedResult = extractPromptsFromFile(padded); + assert.equal(paddedResult.prompts.length, 1); + assert.equal(paddedResult.prompts[0].text, "padded"); + assert.equal(paddedResult.skippedLines, 0); + + // a gate hit whose parsed shape fails the extraction rule is silently + // dropped — the gate alone never decides membership + const gateHit = writeSessionFile([ + sessionHeader(), + { + type: "custom", + payload: { role: "user", content: "nested user literal" }, + }, + ]); + assert.deepEqual(extractPromptsFromFile(gateHit), emptyResult); +}); + +test("gate safety: escaped quotes extract exactly, misses never parse, the gate never decides membership (AC-S1-8)", () => { + const root = makeSessionsRoot(); + const cwdDir = path.join(root, "--tmp-project--"); + + // Escaped quotes beside the role field and inside text values: the raw + // "role":"user" literal survives serialization, the gate hits, and + // JSON.parse decodes the escapes to the exact text. + writeFileAt(path.join(cwdDir, "escaped.jsonl"), [ + sessionHeader(), + { + type: "message", + message: { + content: '"leading quote right before the role field', + role: "user", + }, + }, + { + type: "message", + message: { + role: "user", + content: [{ type: "text", text: 'block with "quoted" words' }], + }, + }, + ]); + + // Sentinel lines WITHOUT the user-role literal: if the gate ever parsed + // them, JSON.parse would throw and skippedLines would count them — a zero + // skip count proves the miss path never parses. + writeFileAt(path.join(cwdDir, "sentinel.jsonl"), [ + sessionHeader(), + "{definitely not json and no role literal", + userTextEntry("real prompt after sentinels"), + "{another broken line, still no literal", + ]); + + // A gate hit that fails the parsed extraction rule is silently dropped: + // the parsed rule, not the substring, decides membership. + writeFileAt(path.join(cwdDir, "gate-only.jsonl"), [ + sessionHeader(), + { + type: "custom", + message: { role: "user", content: "gate hits, rule rejects" }, + }, + ]); + + const prompts: string[] = []; + let skippedLines = 0; + const files = listSessionFiles(root); + assert.equal(files.length, 3); + for (const file of files) { + const result = extractPromptsFromFile(file); + for (const prompt of result.prompts) prompts.push(prompt.text); + skippedLines += result.skippedLines; + } + assert.ok(prompts.includes('"leading quote right before the role field')); + assert.ok(prompts.includes('block with "quoted" words')); + assert.ok(prompts.includes("real prompt after sentinels")); + assert.ok(!prompts.includes("gate hits, rule rejects")); + assert.equal(skippedLines, 0); +}); + +test("v1/v2 legacy tolerance: no id/parentId, weak timestamps resolve through the fallback chain (AC-S1-9)", () => { + const root = makeSessionsRoot(); + const cwdDir = path.join(root, "--legacy-project--"); + + // version-1 header; entries carry no id and no parentId + writeFileAt(path.join(cwdDir, "legacy-v1.jsonl"), [ + { type: "session", version: 1, timestamp: "2025-06-01T08:00:00.000Z" }, + { + type: "message", + timestamp: "2025-06-01T09:00:00.000Z", + message: { role: "user", content: "legacy with entry iso" }, + }, + { + type: "message", + message: { role: "user", content: "legacy bare" }, + }, + ]); + + // neither entry nor header timestamp usable → the file mtime is the tail + const before = Date.now() - 5_000; + writeFileAt(path.join(cwdDir, "legacy-mtime.jsonl"), [ + { type: "session", version: 2, timestamp: "garbage" }, + { type: "message", message: { role: "user", content: "legacy mtime" } }, + ]); + const after = Date.now() + 5_000; + + const byText = new Map(); + for (const file of listSessionFiles(root)) { + for (const prompt of extractPromptsFromFile(file).prompts) { + byText.set(prompt.text, prompt.ts); + } + } + assert.equal(byText.size, 3); + assert.equal( + byText.get("legacy with entry iso"), + Date.parse("2025-06-01T09:00:00.000Z"), + ); + assert.equal( + byText.get("legacy bare"), + Date.parse("2025-06-01T08:00:00.000Z"), + ); + const mtimeTs = byText.get("legacy mtime"); + if (mtimeTs === undefined) { + throw new Error("legacy mtime entry did not extract"); + } + assert.ok(Number.isFinite(mtimeTs)); + assert.ok(mtimeTs >= before && mtimeTs <= after); +}); + +test("multi-block content joins text blocks with a single space, trimmed; string content passes as-is (AC-S1-10)", () => { + const multi = writeSessionFile([ + sessionHeader(), + { + type: "message", + message: { + role: "user", + content: [ + { type: "text", text: "first part" }, + { type: "image", source: { type: "base64", data: "img" } }, + { type: "text", text: "second part" }, + ], + }, + }, + ]); + const multiResult = extractPromptsFromFile(multi); + assert.equal(multiResult.prompts.length, 1); + assert.equal(multiResult.prompts[0].text, "first part second part"); + + // the assembly is trimmed at its ends; the raw join keeps inner spacing + const padded = writeSessionFile([ + sessionHeader(), + { + type: "message", + message: { + role: "user", + content: [ + { type: "text", text: " padded " }, + { type: "text", text: "tail " }, + ], + }, + }, + ]); + const paddedResult = extractPromptsFromFile(padded); + assert.equal(paddedResult.prompts[0].text, "padded tail"); + + // plain-string content extracts as-is (WU1a behavior preserved) + const plain = writeSessionFile([ + sessionHeader(), + userTextEntry("plain string content"), + ]); + assert.equal( + extractPromptsFromFile(plain).prompts[0].text, + "plain string content", + ); +}); + +test("length guard: at MAX_PROMPT_CHARS extracts, strictly above skips uniformly and silently (AC-S1-11)", () => { + const atMax = "a".repeat(MAX_PROMPT_CHARS); + const over = "b".repeat(MAX_PROMPT_CHARS + 1); + const file = writeSessionFile([ + sessionHeader(), + userTextEntry("short entry"), + { type: "message", message: { role: "user", content: atMax } }, + { type: "message", message: { role: "user", content: over } }, + ]); + const result = extractPromptsFromFile(file); + assert.deepEqual( + result.prompts.map((prompt) => prompt.text), + ["short entry", atMax], + ); + // the oversized skip is uniform and silent — not a corruption count + assert.equal(result.skippedLines, 0); +}); + +test("WU1b triangulation: exact length boundary, sorted determinism across runs, empty-root fail-open", () => { + // the boundary is exact: MAX_PROMPT_CHARS extracts, one unit more skips + const exact = "x".repeat(MAX_PROMPT_CHARS); + const boundary = writeSessionFile([ + sessionHeader(), + { type: "message", message: { role: "user", content: exact } }, + { + type: "message", + message: { role: "user", content: "y".repeat(MAX_PROMPT_CHARS + 1) }, + }, + ]); + const boundaryResult = extractPromptsFromFile(boundary); + assert.deepEqual( + boundaryResult.prompts.map((prompt) => prompt.text), + [exact], + ); + assert.equal(boundaryResult.skippedLines, 0); + + // two runs return identical sorted absolute paths (deterministic order) + const root = makeSessionsRoot(); + writeFileAt(path.join(root, "--bbb--", "b.jsonl"), [ + sessionHeader(), + userTextEntry("b"), + ]); + writeFileAt(path.join(root, "--aaa--", "a.jsonl"), [ + sessionHeader(), + userTextEntry("a"), + ]); + const first = listSessionFiles(root); + const second = listSessionFiles(root); + assert.deepEqual(first, second); + assert.deepEqual(first, [ + path.join(root, "--aaa--", "a.jsonl"), + path.join(root, "--bbb--", "b.jsonl"), + ]); + + // a sessions root with no cwd directories yields an empty list, no throw + assert.deepEqual(listSessionFiles(makeSessionsRoot()), []); +}); + +test("a nonexistent path yields the empty result without throwing (triangulation)", () => { + const missing = path.join( + fs.mkdtempSync(path.join(os.tmpdir(), "session-scan-")), + "does-not-exist.jsonl", + ); + assert.deepEqual(extractPromptsFromFile(missing), { + prompts: [], + skippedLines: 0, + }); +}); + +test("a header with NO timestamp field (parseHeader NaN branch) plus timestamp-less messages falls back to the file mtime", () => { + // Distinct from the garbage-header-timestamp case already covered: here + // the header carries no timestamp key at all, so parseHeader returns NaN + // and resolveTimestamp falls all the way through to the file mtime. + const before = Date.now() - 5; + const file = writeSessionFile([ + { type: "session", version: 3 }, + userTextEntry("no ts anywhere"), + ]); + const after = Date.now() + 5000; + const result = extractPromptsFromFile(file); + assert.equal(result.prompts.length, 1); + assert.equal(result.prompts[0].text, "no ts anywhere"); + const ts = result.prompts[0].ts; + assert.ok(Number.isFinite(ts)); + assert.ok(ts >= before && ts <= after); +}); From 6b61c980f6d370175e7ad650b2be9e128dbf5b84 Mon Sep 17 00:00:00 2001 From: Carolina <26188349+carolitascl@users.noreply.github.com> Date: Fri, 18 Sep 2026 18:46:12 -0300 Subject: [PATCH 04/16] fix(history): migration renames only after the seed write; init off the first-prompt path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review fixes (CodeRabbit on PR #819): - migrateLegacyStores: legacy sources are renamed .imported only AFTER the global seed write succeeds. Previously each source was renamed immediately after reading, so a seed-write failure stranded the collected entries in .imported files with the one-shot seed gate blocking retry — silent data loss. Failure-path test added: a read-only store root makes the seed write throw, sources stay in place, and the retried migration completes and archives them. - promptHistoryExtension: writer init (migrate + registry + seed bootstrap) is scheduled once via setImmediate so the transcript scan never runs on the first-prompt path; prompts arriving before the scheduled init fall back to getWriter()'s synchronous lazy init, whose writerState guard keeps the work single-shot. - Source pin added for the setImmediate scheduling and the retained synchronous fallback. --- extensions/history/index.ts | 12 +++++++ extensions/history/store.ts | 33 +++++++++--------- tests/history-command-registration.test.ts | 21 ++++++++++++ tests/history-legacy-migrate-v2.test.ts | 40 ++++++++++++++++++++++ 4 files changed, 89 insertions(+), 17 deletions(-) diff --git a/extensions/history/index.ts b/extensions/history/index.ts index c36fc7f0f..b62963d39 100644 --- a/extensions/history/index.ts +++ b/extensions/history/index.ts @@ -933,6 +933,18 @@ function recordsFromEntries( 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. + setImmediate(() => { + try { + getWriter(); + } catch { + // init is best-effort; the lazy path retries on the next prompt + } + }); // Persist every delivered user prompt (write-through, append-only JSONL). // The local ExtensionAPI stub types handler args as unknown; narrow here. diff --git a/extensions/history/store.ts b/extensions/history/store.ts index 7c8b2df57..32fb48295 100644 --- a/extensions/history/store.ts +++ b/extensions/history/store.ts @@ -438,9 +438,10 @@ function readValidLines(file: string): StoreEntry[] { * One-time migration from the v1 stores into the v2 global seed: * - `~/.pi/agent/editor-history.jsonl` (v1 single-file store) * - `~/.pi/agent/editor-history.json` (pre-v1 array, newest-first) - * Content lands in `pi-history/history-global.jsonl` chronologically; each - * source is renamed `.imported`, never deleted. Gated: an existing global - * seed means migration already ran. + * Content lands in `pi-history/history-global.jsonl` chronologically; only + * after the seed write succeeds is each source renamed `.imported`, never + * deleted — a failed write leaves sources untouched for a later retry. + * Gated: an existing global seed means migration already ran. */ export function migrateLegacyStores( root: string, @@ -455,15 +456,8 @@ export function migrateLegacyStores( const legacyArray = path.join(agentDir, "editor-history.json"); if (fs.existsSync(legacyArray)) { const texts = loadSharedHistory(legacyArray); - if (texts.length > 0) { - for (let i = texts.length - 1; i >= 0; i--) { - collected.push({ v: 1, text: texts[i] }); - } - } - try { - fs.renameSync(legacyArray, `${legacyArray}.imported`); - } catch { - // The seed write below is the source of truth; rename failure is benign. + for (let i = texts.length - 1; i >= 0; i--) { + collected.push({ v: 1, text: texts[i] }); } } @@ -471,11 +465,6 @@ export function migrateLegacyStores( const v1File = path.join(agentDir, "editor-history.jsonl"); if (fs.existsSync(v1File)) { collected.push(...readValidLines(v1File)); - try { - fs.renameSync(v1File, `${v1File}.imported`); - } catch { - // benign - } } if (collected.length === 0) return { migrated: 0, ran: false }; @@ -488,6 +477,16 @@ export function migrateLegacyStores( "utf8", ); fs.renameSync(tmp, seed); + + // The seed write is the source of truth: rename sources only once it + // succeeded, so a failure can never strand entries in .imported files. + for (const src of [legacyArray, v1File]) { + try { + if (fs.existsSync(src)) fs.renameSync(src, `${src}.imported`); + } catch { + // benign: the seed gate prevents duplicate import on the next run + } + } return { migrated: collected.length, ran: true }; } diff --git a/tests/history-command-registration.test.ts b/tests/history-command-registration.test.ts index e4aa9bf6b..4e44c3859 100644 --- a/tests/history-command-registration.test.ts +++ b/tests/history-command-registration.test.ts @@ -82,3 +82,24 @@ test("in-UI hint describes multi-word AND substring matching, not fuzzy", () => "hint should describe multi-word AND substring filtering (AC-P1-6.1)", ); }); + +test("writer init is scheduled off the first-prompt path via setImmediate", () => { + const entry = source.indexOf("export default function promptHistoryExtension"); + assert.notStrictEqual(entry, -1, "extension entry point should exist"); + + const body = source.slice(entry); + assert.ok( + body.includes("setImmediate(() => {"), + "init must be scheduled with setImmediate so bootstrap never runs on\nthe first-prompt path", + ); + assert.ok( + /setImmediate\(\(\) => \{[\s\S]*?getWriter\(\);/.test(body), + "the scheduled callback should warm getWriter()", + ); + // The synchronous fallback stays: a prompt arriving before the + // scheduled call still initializes lazily inside the capture handler. + assert.ok( + /before_agent_start[\s\S]*?appendSessionCapture\(getWriter\(\)/.test(body), + "capture handler keeps the synchronous getWriter() fallback", + ); +}); diff --git a/tests/history-legacy-migrate-v2.test.ts b/tests/history-legacy-migrate-v2.test.ts index 08c41a471..77d43586a 100644 --- a/tests/history-legacy-migrate-v2.test.ts +++ b/tests/history-legacy-migrate-v2.test.ts @@ -118,6 +118,46 @@ const sealedLegacyTest = (name: string, fn: () => void) => { skip: process.getuid?.() === 0 ? "requires non-root" : false }, fn, ); + +// chmod-based failure injection is also invisible to the superuser. +const seedFailureTest = (name: string, fn: () => void) => + test( + name, + { skip: process.getuid?.() === 0 ? "requires non-root" : false }, + fn, + ); + +seedFailureTest( + "a failed seed write leaves legacy sources untouched for retry", + () => { + const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "migrate-fail-")); + const v1 = path.join(agentDir, "editor-history.jsonl"); + fs.writeFileSync( + v1, + `${JSON.stringify({ v: 1, text: "survives-retry" })}\n`, + "utf8", + ); + const root = fs.mkdtempSync(path.join(os.tmpdir(), "migrate-fail-root-")); + // A read-only store root makes the seed write fail AFTER the sources + // have been read but BEFORE any rename. + fs.chmodSync(root, 0o555); + try { + assert.throws(() => migrateLegacyStores(root, agentDir)); + // The source was NOT renamed: the retry path is intact. + assert.equal(fs.existsSync(v1), true); + assert.equal(fs.existsSync(`${v1}.imported`), false); + assert.equal(fs.existsSync(globalSeedPath(root)), false); + } finally { + fs.chmodSync(root, 0o755); + } + // Retry after the failure clears: full migration, then rename. + const result = migrateLegacyStores(root, agentDir); + assert.deepEqual(result, { migrated: 1, ran: true }); + assert.equal(fs.existsSync(`${v1}.imported`), true); + assert.deepEqual(fileTexts(globalSeedPath(root)), ["survives-retry"]); + }, +); + sealedLegacyTest( "an unreadable legacy file is skipped; the readable file still migrates", () => { From a22588fcf81747c4cf63b23739a979dbf04f8e4f Mon Sep 17 00:00:00 2001 From: Carolina <26188349+carolitascl@users.noreply.github.com> Date: Wed, 23 Sep 2026 21:15:26 -0300 Subject: [PATCH 05/16] 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 f9559b9d6..d82b413d9 100644 --- a/extensions/history/index.ts +++ b/extensions/history/index.ts @@ -11,7 +11,7 @@ import { homedir } from "node:os"; import { DynamicBorder, type ExtensionAPI, - type ShortcutContext, + type ExtensionCommandContext, type Theme, } from "@earendil-works/pi-coding-agent"; import { @@ -815,7 +815,7 @@ function createPromptHistorySelectorFactory( } async function runPromptHistorySelection( - ctx: ShortcutContext, + ctx: Pick, records: PromptRecord[], ): Promise { const historyGlobals: PiHistoryGlobals = globalThis as Record< @@ -876,7 +876,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 353a99f80e4ac2171f4bfc146484e55a3e05ce0c Mon Sep 17 00:00:00 2001 From: Carolina <26188349+carolitascl@users.noreply.github.com> Date: Wed, 23 Sep 2026 21:18:17 -0300 Subject: [PATCH 06/16] 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 b62963d39..4f5d08195 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 { @@ -823,7 +823,7 @@ function createPromptHistorySelectorFactory( } async function runPromptHistorySelection( - ctx: ShortcutContext, + ctx: Pick, records: PromptRecord[], ): Promise { const historyGlobals: PiHistoryGlobals = globalThis as Record< @@ -899,7 +899,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 25a1dd12b1813f3d129dc77e6a6a528873310364 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 07/16] 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 4f5d08195..53b470532 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) and GC/compaction (slice 6) arrive // in later slices. +// +// 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"; @@ -931,14 +937,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 { @@ -946,12 +1012,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 831bbe9689f0d2cec8b4e39ff671b28531834af5 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 08/16] 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 | 64 ++++++++++++++++++++++++--- tests/history-session-writer.test.ts | 66 +++++++++++++++++++++++++++- 4 files changed, 185 insertions(+), 7 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 d82b413d9..31e5c5755 100644 --- a/extensions/history/index.ts +++ b/extensions/history/index.ts @@ -5,6 +5,12 @@ // and the shortcut/command wiring over the slice-1 writer and slice-2 // drains. Legacy migration and seed bootstrap (slice 4), deletion (slice 5), // and GC/compaction (slice 6) arrive in later slices. +// +// 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"; @@ -75,7 +81,6 @@ const PREVIEW_WHEEL_Y_LAST = 26; // v2 multi-concurrency store root (design: tmp/multi-concurrency-design.md). const PI_HISTORY_ROOT = join(homedir(), ".pi", "agent", "history"); -const AGENT_DIR = join(homedir(), ".pi", "agent"); const CURRENT_CWD = process.cwd(); // Instance identity: one exclusive capture file per pi process. const INSTANCE_ID = randomUUID(); @@ -908,15 +913,62 @@ function recordsFromEntries( return buildPromptRecords(dedupePromptEntries(entries)); } -export default function promptHistoryExtension(pi: ExtensionAPI) { - // One writer per extension load; see getWriter() for the init order. - // Persist every delivered user prompt (write-through, append-only JSONL). - // The local ExtensionAPI stub types handler args as unknown; narrow here. +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: register the project in the advisory + * registry, then open this instance's exclusive capture file. Legacy + * migration and seed bootstrap join this init order in a later slice. + */ + const getWriter = (): SessionWriterState => { + if (!writerState) { + try { + ensureRegistryEntry(root, cwd); + } catch { + // registry is advisory + } + writerState = openSessionWriter(root, cwd, instanceId); + } + return writerState; + }; + + // 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..671ca6c50 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 e787ce464758bb9883924cf9c468ddd62dc65ff6 Mon Sep 17 00:00:00 2001 From: Carolina <26188349+carolitascl@users.noreply.github.com> Date: Thu, 24 Sep 2026 17:18:01 -0300 Subject: [PATCH 09/16] 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 | 1 - 1 file changed, 1 deletion(-) diff --git a/README.md b/README.md index db19d3529..a8a5ee727 100644 --- a/README.md +++ b/README.md @@ -280,7 +280,6 @@ Start with the product-facing destination, then move into the operational refere | [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. | -| [Prompt history](docs/prompt-history.md) | Opt-in capture switch, storage layout, readers, and disable/removal semantics. |

Back to top ↑

From e81e194fb3bc5d26bd0ea7a5f784c5bfb9e1fad5 Mon Sep 17 00:00:00 2001 From: Carolina <26188349+carolitascl@users.noreply.github.com> Date: Thu, 24 Sep 2026 17:20:48 -0300 Subject: [PATCH 10/16] 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 6786efdb8fd99e66dd88e3f196178f9d06ec1e73 Mon Sep 17 00:00:00 2001 From: Carolina <26188349+carolitascl@users.noreply.github.com> Date: Thu, 24 Sep 2026 17:28:12 -0300 Subject: [PATCH 11/16] chore(ci): re-trigger checks review-repository-windows failed with CandidateViewError "candidate view owner preparation failed (ETIMEDOUT)" during worktree preparation, while test/verify/session-transport all passed. No code change; re-running the checks via an empty commit because workflow rerun requires upstream admin rights. From f4b7a33b6456d5e8112a12a3894034d8c79dddfc Mon Sep 17 00:00:00 2001 From: Carolina <26188349+carolitascl@users.noreply.github.com> Date: Thu, 24 Sep 2026 20:07:03 -0300 Subject: [PATCH 12/16] fix(history): gate legacy seeding/migration behind capture opt-in Review follow-up on the slice-04 PR: the selector read path (openHistorySelector -> drainForScope -> getWriter) ran legacy migration and seed bootstrap without checking the capture preference, so opening /history with capture disabled silently imported past prompts into searchable store files. - openHistorySelector warns and returns before any drain unless GENTLE_PI_HISTORY_CAPTURE=1|true|on; drainForScope adds a defense-in-depth early return. The warm-up and capture handler were already gated; the read path now matches. - Define the previously-undefined AGENT_DIR constant: migrateLegacyStores had been dead code (swallowed ReferenceError) since the deps refactor. - docs/prompt-history.md: "Legacy migration and seeding are opt-in" - imports create new searchable copies under ~/.pi/agent/history, source transcripts stay untouched, disabling does not remove imported copies. - tests/history-off-path.test.ts: with capture off, extension load writes nothing and the history command imports nothing and warns. --- docs/prompt-history.md | 13 +++++ extensions/history/index.ts | 18 ++++++- tests/history-off-path.test.ts | 94 ++++++++++++++++++++++++++++++++++ 3 files changed, 124 insertions(+), 1 deletion(-) create mode 100644 tests/history-off-path.test.ts diff --git a/docs/prompt-history.md b/docs/prompt-history.md index 1fa8eba4f..fce302705 100644 --- a/docs/prompt-history.md +++ b/docs/prompt-history.md @@ -21,6 +21,19 @@ GENTLE_PI_HISTORY_CAPTURE=1 pi - With capture off the extension is inert: no registry entry, no files, and prompts are never written. +## Legacy migration and seeding are opt-in + +Importing past prompts is part of capture: opening the history selector while +capture is enabled also migrates legacy editor-history stores and runs the +one-time seed bootstrap from past session transcripts. With capture off, the +selector warns and returns before any of that — no migration, no seed, no +store files. + +An import creates **new searchable copies** under `~/.pi/agent/history`. The +source transcripts stay untouched and read-only. Turning capture off again +does not remove copies that were already imported: delete them manually as +described in "What disabling capture does" below. + ## Where the files live Everything sits under `~/.pi/agent/history/`: diff --git a/extensions/history/index.ts b/extensions/history/index.ts index 610bcf76e..0cb0785a2 100644 --- a/extensions/history/index.ts +++ b/extensions/history/index.ts @@ -82,8 +82,11 @@ const LIST_WHEEL_Y_LAST = 14; const PREVIEW_WHEEL_Y_FIRST = 17; const PREVIEW_WHEEL_Y_LAST = 26; +// 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"); // v2 multi-concurrency store root (design: tmp/multi-concurrency-design.md). -const PI_HISTORY_ROOT = join(homedir(), ".pi", "agent", "history"); +const PI_HISTORY_ROOT = join(AGENT_DIR, "history"); const CURRENT_CWD = process.cwd(); // Instance identity: one exclusive capture file per pi process. const INSTANCE_ID = randomUUID(); @@ -897,6 +900,9 @@ function getWriter(): SessionWriterState { * dirs + the legacy global seed). Both filter tombstoned prompts. */ function drainForScope(scope: HistoryScope): string[] { + // 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) @@ -906,6 +912,16 @@ function drainForScope(scope: HistoryScope): string[] { async function openHistorySelector( ctx: Pick, ): Promise { + // Gate: with capture off the selector must not run legacy migration, seed + // 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.", + "warning", + ); + return; + } + // Store-only drain (user-directed): both scopes read the store files // symmetrically — no live transcript merge (the one-time seed bootstrap // covers pre-store history). diff --git a/tests/history-off-path.test.ts b/tests/history-off-path.test.ts new file mode 100644 index 000000000..ef0eb0701 --- /dev/null +++ b/tests/history-off-path.test.ts @@ -0,0 +1,94 @@ +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 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; + +function makeRoot(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), "pi-history-off-")); +} + +const CWD = "/pi-history-test/project-off"; + +interface Harness { + commandHandler: (args: unknown, ctx: unknown) => Promise; +} + +/** + * Load the extension against a temp root and capture the registered + * shortcut + history command handlers from the fake pi. + */ +function loadWithCommand(env: NodeJS.ProcessEnv, root: string): Harness { + const shortcuts: Array<[string, { handler: unknown }]> = []; + const commands: Array<[string, { handler: unknown }]> = []; + const pi = { + on: () => {}, + registerShortcut: (key: string, def: { handler: unknown }) => { + shortcuts.push([key, def]); + }, + registerCommand: (name: string, def: { handler: unknown }) => { + commands.push([name, def]); + }, + }; + promptHistoryExtension(pi as never, { + env, + root, + cwd: CWD, + instanceId: "inst-off", + now: () => 1700000000000, + }); + const command = commands.find(([name]) => name === "history"); + assert.ok(command, "the history command must be registered"); + assert.equal(shortcuts.length, 1, "the shortcut must still be registered"); + return { + commandHandler: command[1].handler as Harness["commandHandler"], + }; +} + +function fakeCtx(notifyCalls: Array<[string, string]>) { + return { + ui: { + notify: (message: string, level: string) => { + notifyCalls.push([message, level]); + }, + }, + }; +} + +// NOTE: there is deliberately no enabled-path smoke test here. The selector +// drain is a module-level path hard-wired to PI_HISTORY_ROOT +// (~/.pi/agent/history) with no injection point, and bun's os.homedir() +// ignores runtime HOME overrides — invoking the command with capture on +// would migrate/seed/write the real user store. The enabled direction stays +// covered by the deps-injected tests in history-session-writer.test.ts. + +test("with capture disabled, extension load writes nothing", async () => { + const root = makeRoot(); + loadWithCommand({}, root); + // Flush the setImmediate warm-up. + await new Promise((resolve) => setImmediate(resolve)); + // Nothing at all: no registry, no seed, no store file. + assert.deepEqual(fs.readdirSync(root), []); +}); + +test("with capture disabled, the history command imports nothing and warns", async () => { + const root = makeRoot(); + const { commandHandler } = loadWithCommand({}, root); + await new Promise((resolve) => setImmediate(resolve)); + const notifyCalls: Array<[string, string]> = []; + await commandHandler([], fakeCtx(notifyCalls)); + assert.equal(notifyCalls.length, 1); + assert.equal(notifyCalls[0][1], "warning"); + assert.ok( + notifyCalls[0][0].includes("GENTLE_PI_HISTORY_CAPTURE"), + `the warning must name the switch, got: ${notifyCalls[0][0]}`, + ); + // The gate must fire before the drain: no migration, no seed, no store. + assert.deepEqual(fs.readdirSync(root), []); +}); From 417b9fdba8572aa0dede1df05d519ede2e7809ac Mon Sep 17 00:00:00 2001 From: Carolina <26188349+carolitascl@users.noreply.github.com> Date: Thu, 24 Sep 2026 21:57:35 -0300 Subject: [PATCH 13/16] fix(history): port fail-closed tombstones to the seed slice Port slice-5's restoration (89ac348) of the fail-closed tombstone contract onto slice-4 so PR #1391 does not reintroduce the fail-open hidden.json behavior after #1392 merges: - hide-prompts.ts byte-identical to the restored version: readHiddenPrompts returns trusted (missing/valid array) or untrusted (unreadable/corrupt/malformed) with a recovery message naming hidden.json; hidePrompt refuses to rewrite an untrusted file. - store.ts: drains return DrainResult (blocked status carries no prompts field) and bootstrapProjectSeed skips seeding on untrusted tombstones. - index.ts: drainForScope unwraps DrainResult; the selector surfaces the blocked recovery message instead of silently showing entries. - Tests: hide-prompts, drain-hidden, and drain-order suites ported byte-identically from the restored versions. Delete-flow code remains slice-5 scope; nothing delete-related entered this port. --- extensions/history/hide-prompts.ts | 83 ++++++++++--- extensions/history/index.ts | 35 +++++- extensions/history/store.ts | 64 ++++++++-- tests/history-drain-hidden.test.ts | 62 +++++++++- tests/history-drain-order.test.ts | 22 +++- tests/history-hide-prompts.test.ts | 187 +++++++++++++++++++++++------ 6 files changed, 370 insertions(+), 83 deletions(-) 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 0cb0785a2..648138eda 100644 --- a/extensions/history/index.ts +++ b/extensions/history/index.ts @@ -29,6 +29,7 @@ import { ensureRegistryEntry, migrateLegacyStores, openSessionWriter, + type DrainResult, type SessionWriterState, } from "./store.ts"; import { randomUUID } from "node:crypto"; @@ -548,8 +549,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()); @@ -894,19 +903,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( @@ -926,6 +945,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/store.ts b/extensions/history/store.ts index 32fb48295..feb8d05c3 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); } // --------------------------------------------------------------------------- @@ -539,7 +570,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-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 f6a832f10944f21ee008daf92c3e451657ed69b8 Mon Sep 17 00:00:00 2001 From: Alan Buscaglia Date: Sat, 26 Sep 2026 18:06:40 +0200 Subject: [PATCH 14/16] fix(history): preserve migration retry and tombstones --- docs/prompt-history.md | 25 ++++++----- extensions/history/index.ts | 9 +++- extensions/history/store.ts | 32 ++++++++------ odd/tasks/history-contributor-chain.md | 24 +++++++++++ tests/history-legacy-migrate-v2.test.ts | 57 +++++++++++++++++++------ tests/history-seed-regen.test.ts | 43 +++++++++++++++++++ tests/history-session-writer.test.ts | 27 ++++++++++++ 7 files changed, 176 insertions(+), 41 deletions(-) create mode 100644 odd/tasks/history-contributor-chain.md diff --git a/docs/prompt-history.md b/docs/prompt-history.md index fce302705..4a4262c40 100644 --- a/docs/prompt-history.md +++ b/docs/prompt-history.md @@ -1,9 +1,8 @@ # 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. +Prompt history stores captured prompts per pi instance and can import older +history and project session transcripts. Deletion and compaction arrive in +later slices of the chain. ## Capture is opt-in @@ -23,11 +22,12 @@ GENTLE_PI_HISTORY_CAPTURE=1 pi ## Legacy migration and seeding are opt-in -Importing past prompts is part of capture: opening the history selector while -capture is enabled also migrates legacy editor-history stores and runs the -one-time seed bootstrap from past session transcripts. With capture off, the -selector warns and returns before any of that — no migration, no seed, no -store files. +Importing past prompts is part of capture: the first delivered prompt in an +opted-in session attempts legacy migration and one-time bootstrap from project +session transcripts. The selector reads the store but does not initiate import. +With capture off, both capture and the selector leave the store untouched. +Failed migration reads can be retried on a later session; untrusted deletion +records defer transcript bootstrap until they can be read safely. An import creates **new searchable copies** under `~/.pi/agent/history`. The source transcripts stay untouched and read-only. Turning capture off again @@ -42,6 +42,8 @@ Everything sits under `~/.pi/agent/history/`: labels. - `projects//.jsonl` — one append-only capture file per pi process. +- `projects//seed.jsonl` — one-time transcript import for this project. +- `history-global.jsonl` — imported legacy editor-history prompts. `` 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: @@ -50,8 +52,9 @@ cwd; `` is a per-process UUID. Each line is one delivered prompt: {"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. +UI command-like prompts (`/name ...`) and empty lines are never captured. +Imported copies remain on disk when capture is turned off; the seed is not +regenerated if it already exists, to avoid resurrecting deleted prompts. ## Who can read them diff --git a/extensions/history/index.ts b/extensions/history/index.ts index 10dd3bbea..f26971e4b 100644 --- a/extensions/history/index.ts +++ b/extensions/history/index.ts @@ -99,6 +99,8 @@ export interface HistoryDeps { cwd?: string; instanceId?: string; now?: () => number; + agentDir?: string; + sessionsRoot?: string; } /** @@ -973,6 +975,8 @@ export default function promptHistoryExtension( const cwd = deps.cwd ?? process.cwd(); const instanceId = deps.instanceId ?? randomUUID(); const now = deps.now ?? Date.now; + const agentDir = deps.agentDir ?? AGENT_DIR; + const sessionsRoot = deps.sessionsRoot ?? SESSIONS_ROOT; let writerState: SessionWriterState | null = null; /** @@ -982,7 +986,7 @@ export default function promptHistoryExtension( const getWriter = (): SessionWriterState => { if (!writerState) { try { - migrateLegacyStores(PI_HISTORY_ROOT, AGENT_DIR); + migrateLegacyStores(root, agentDir); } catch { // migration is best-effort; the gate keeps it one-shot } @@ -995,8 +999,9 @@ export default function promptHistoryExtension( bootstrapProjectSeed( root, cwd, - SESSIONS_ROOT, + sessionsRoot, 500, + root, ); } catch { // bootstrap is a rebuildable cache diff --git a/extensions/history/store.ts b/extensions/history/store.ts index feb8d05c3..df4bdd2ae 100644 --- a/extensions/history/store.ts +++ b/extensions/history/store.ts @@ -13,7 +13,6 @@ import { createHash } from "node:crypto"; import fs from "node:fs"; import path from "node:path"; import { readHiddenPrompts } from "./hide-prompts.ts"; -import { loadSharedHistory } from "./load-shared-history.ts"; import { extractPromptsFromFile, listSessionFiles, @@ -452,17 +451,15 @@ export interface MigrationResult { } function readValidLines(file: string): StoreEntry[] { - try { - const raw = fs.readFileSync(file, "utf8"); - const entries: StoreEntry[] = []; - for (const lineText of raw.split("\n")) { - const parsed = parseStoreLine(lineText); - if (parsed) entries.push(parsed); - } - return entries; - } catch { - return []; + // A read failure must abort the entire migration: archiving a source + // whose prompts were not imported would make the loss permanent. + const raw = fs.readFileSync(file, "utf8"); + const entries: StoreEntry[] = []; + for (const lineText of raw.split("\n")) { + const parsed = parseStoreLine(lineText); + if (parsed) entries.push(parsed); } + return entries; } /** @@ -486,9 +483,16 @@ export function migrateLegacyStores( // Pre-v1 array (newest-first) → reverse to chronological. const legacyArray = path.join(agentDir, "editor-history.json"); if (fs.existsSync(legacyArray)) { - const texts = loadSharedHistory(legacyArray); - for (let i = texts.length - 1; i >= 0; i--) { - collected.push({ v: 1, text: texts[i] }); + // Unlike the tolerant UI reader, migration must not archive a source + // whose bytes could not be read or parsed. Read exactly once. + const values: unknown = JSON.parse(fs.readFileSync(legacyArray, "utf8")); + if (!Array.isArray(values)) throw new Error("Invalid legacy history array"); + for (let i = values.length - 1; i >= 0; i--) { + const item: unknown = values[i]; + const text = typeof item === "string" ? item : + item && typeof item === "object" && "text" in item && + typeof item.text === "string" ? item.text : null; + if (text && text.length > 0) collected.push({ v: 1, text }); } } diff --git a/odd/tasks/history-contributor-chain.md b/odd/tasks/history-contributor-chain.md new file mode 100644 index 000000000..7d46c0a8a --- /dev/null +++ b/odd/tasks/history-contributor-chain.md @@ -0,0 +1,24 @@ +# Complete contributor history chain + +## Objective / authorization +Adapt and merge Gentle Shell History PRs #1391 (migration/seeding), #1393 (deletion/tombstones), and #1394 (compaction), in order. Exclude unrelated PR #1452. User authorized adaptation and merge. Issue #818 is approved. Earlier #1390/#1392/#1453–#1455 are already merged. + +## Problem and constraints +The open PRs are cumulative branches from older main. #1393/#1394 switch the current `GENTLE_PI_HISTORY_CAPTURE` opt-in to `GENTLE_PI_HISTORY_ENABLE`; #1394's compaction can remove a file with concurrent appends and the seed gate, and runs at shutdown while capture is off. Current main is moving. Preserve existing privacy semantics, contributor attribution, and all unrelated work; do not merge an unsafe head and assume revert restores lost data. + +## Route and delivery +Delegated direct writer for multi-file implementation and preparatory reading. One writer at a time; isolate each PR adaptation in its own worktree. Existing cumulative chain is the delivery strategy, in order #1391 → #1393 → #1394. Each slice is a work unit with tests/docs and Conventional Commit. Forecast: incremental #1391 ~510 lines; #1393 ~700 lines; #1394 ~600 lines, beyond the advisory 400-line PR budget; preserve coherent behavior and report size exception rather than code-golf. Check PR policy, exact head, mergeability, and required CI before each merge. Do not wait for optional CodeRabbit; evaluate critical findings on final heads. Review mode is user-owned. + +## Tasks +- [ ] H1: Reconcile #1391 against current main, fix migration/seed privacy and retry issues with deterministic tests; verify and merge only when eligible. Route: delegated, multiple nontrivial source/test files. Commit and merge identities pending. +- [ ] H2: Reconcile #1393 against post-H1 main, preserve `GENTLE_PI_HISTORY_CAPTURE`, ensure exact tombstones, race-safe deletion, and failure reporting; verify and merge only when eligible. Route: delegated, multiple nontrivial source/test files. Commit and merge identities pending. +- [ ] H3: Reconcile #1394 against post-H2 main, preserve capture compatibility and seed gate, protect active writers during GC, and update truthful docs; verify and merge only when eligible. Route: delegated, multiple nontrivial source/test files. Commit and merge identities pending. + +## Acceptance and checks +Run focused `tests/history-*.test.ts`, project verification/typecheck, `git diff --check`, and required exact-head CI as applicable. Each merged slice preserves current main's opt-in behavior and shows no lost prompts in concurrency/failure tests. No automatic rollback is a substitute for data safety. Record failures/skips/pending honestly. + +## Progress +2026-09-26: Remote heads inspected: #1391 `649711c`, #1393 `e302337`, #1394 `5981153`; origin/main `06c9915` at the H1 snapshot. #1391 cumulative diff since #1455 is 1796 additions/23 deletions across 13 files; later cumulative PRs are larger. Separate #1391 worktree created at `fix/history-pr1391-adaptation`. H1 writer observed RED 2 migration failures, GREEN 14/14 focused tests. With node_modules linked from the main checkout, independent verification passed 168 history tests, typecheck had 188 baseline diagnostics and no regression, and `git diff --check` passed. Integration with main, commit, PR checks and merge remain pending. Engram mirror `odd/history-contributor-chain/tasks` pending: this session is bound to the separate gentle-ai project and Engram rejects a gentle-pi write. + +## Next step +Commit and integrate H1 with current main, then obtain exact-head CI and repository-policy metadata before merge. Keep H2/H3 blocked until H1 is safe. diff --git a/tests/history-legacy-migrate-v2.test.ts b/tests/history-legacy-migrate-v2.test.ts index 77d43586a..98bae7f57 100644 --- a/tests/history-legacy-migrate-v2.test.ts +++ b/tests/history-legacy-migrate-v2.test.ts @@ -97,6 +97,43 @@ test("existing global seed gates the migration (idempotent)", () => { assert.equal(fs.existsSync(v1), true); }); +test("unreadable legacy source leaves both sources for a complete retry", () => { + const { root, agentDir } = makeDirs(); + const array = path.join(agentDir, "editor-history.json"); + const v1 = path.join(agentDir, "editor-history.jsonl"); + fs.writeFileSync(array, JSON.stringify(["array prompt"])); + fs.writeFileSync(v1, `${JSON.stringify({ text: "v1 prompt" })}\n`); + const original = fs.readFileSync; + fs.readFileSync = ((file: fs.PathOrFileDescriptor, ...args: unknown[]) => { + if (file === v1) throw new Error("injected read failure"); + return (original as (...args: unknown[]) => unknown)(file, ...args); + }) as typeof fs.readFileSync; + try { + assert.throws(() => migrateLegacyStores(root, agentDir), /injected read failure/); + assert.equal(fs.existsSync(globalSeedPath(root)), false); + assert.equal(fs.existsSync(array), true); + assert.equal(fs.existsSync(v1), true); + } finally { + fs.readFileSync = original; + } + assert.deepEqual(migrateLegacyStores(root, agentDir), { migrated: 2, ran: true }); + assert.deepEqual(fileTexts(globalSeedPath(root)), ["array prompt", "v1 prompt"]); +}); + +test("malformed legacy array cannot archive a readable v1 source", () => { + const { root, agentDir } = makeDirs(); + const array = path.join(agentDir, "editor-history.json"); + const v1 = path.join(agentDir, "editor-history.jsonl"); + fs.writeFileSync(array, "{torn"); + fs.writeFileSync(v1, `${JSON.stringify({ text: "survives" })}\n`); + assert.throws(() => migrateLegacyStores(root, agentDir), SyntaxError); + assert.equal(fs.existsSync(globalSeedPath(root)), false); + assert.equal(fs.existsSync(v1), true); + fs.writeFileSync(array, JSON.stringify(["repaired"])); + assert.deepEqual(migrateLegacyStores(root, agentDir), { migrated: 2, ran: true }); + assert.deepEqual(fileTexts(globalSeedPath(root)), ["repaired", "survives"]); +}); + test("malformed v1 jsonl lines are skipped, not fatal", () => { const { root, agentDir } = makeDirs(); const v1 = path.join(agentDir, "editor-history.jsonl"); @@ -159,7 +196,7 @@ seedFailureTest( ); sealedLegacyTest( - "an unreadable legacy file is skipped; the readable file still migrates", + "an unreadable legacy file defers migration without archiving either source", () => { const { root, agentDir } = makeDirs(); const readable = path.join(agentDir, "editor-history.json"); @@ -172,20 +209,12 @@ sealedLegacyTest( ); fs.chmodSync(sealed, 0o000); try { - // The sealed file's bytes are unreadable: its prompts contribute - // nothing to the seed; the readable array still migrates. No throw. - const result = migrateLegacyStores(root, agentDir); - assert.deepEqual(result, { migrated: 1, ran: true }); - assert.deepEqual(fileTexts(globalSeedPath(root)), ["from-array"]); + assert.throws(() => migrateLegacyStores(root, agentDir)); + assert.equal(fs.existsSync(globalSeedPath(root)), false); + assert.equal(fs.existsSync(readable), true); + assert.equal(fs.existsSync(sealed), true); } finally { - // The migration archives the unreadable file as `.imported` (rename - // needs no read permission — content skipped, file still moved aside); - // restore only when the original path survived an early failure. - try { - fs.chmodSync(sealed, 0o644); - } catch { - // already renamed to `.imported` by the migration - } + fs.chmodSync(sealed, 0o644); } }, ); diff --git a/tests/history-seed-regen.test.ts b/tests/history-seed-regen.test.ts index e4e16539e..fe8bc559c 100644 --- a/tests/history-seed-regen.test.ts +++ b/tests/history-seed-regen.test.ts @@ -66,6 +66,49 @@ test("an existing seed is never regenerated (deleted prompts stay gone)", () => assert.deepEqual(texts, ["keep"]); }); +test("untrusted tombstones defer seed until repaired, then suppress deleted prompts", () => { + const { root, sessionsRoot, stateDir } = setup(); + writeSession(sessionsRoot, ["visible", "hidden-prompt"]); + fs.mkdirSync(stateDir, { recursive: true }); + const hiddenFile = path.join(stateDir, "hidden.json"); + fs.writeFileSync(hiddenFile, "{broken"); + assert.deepEqual(bootstrapProjectSeed(root, CWD, sessionsRoot, 500, stateDir), { + seeded: 0, ran: false, + }); + assert.equal(fs.existsSync(seedFilePath(root, CWD)), false); + fs.writeFileSync(hiddenFile, JSON.stringify(["hidden-prompt"])); + assert.deepEqual(bootstrapProjectSeed(root, CWD, sessionsRoot, 500, stateDir), { + seeded: 1, ran: true, + }); + assert.deepEqual( + fs.readFileSync(seedFilePath(root, CWD), "utf8").trim().split("\n") + .map((line) => (JSON.parse(line) as { text: string }).text), + ["visible"], + ); +}); + +test("a failed seed rename leaves no gate and retries with all prompts", () => { + const { root, sessionsRoot, stateDir } = setup(); + writeSession(sessionsRoot, ["visible", "hidden-prompt"]); + fs.mkdirSync(stateDir, { recursive: true }); + fs.writeFileSync(path.join(stateDir, "hidden.json"), JSON.stringify(["hidden-prompt"])); + const seed = seedFilePath(root, CWD); + const original = fs.renameSync; + fs.renameSync = ((from: fs.PathLike, to: fs.PathLike) => { + if (to === seed) throw new Error("injected seed rename failure"); + return original(from, to); + }) as typeof fs.renameSync; + try { + assert.throws(() => bootstrapProjectSeed(root, CWD, sessionsRoot, 500, stateDir), /injected/); + assert.equal(fs.existsSync(seed), false); + } finally { + fs.renameSync = original; + } + assert.deepEqual(bootstrapProjectSeed(root, CWD, sessionsRoot, 500, stateDir), { + seeded: 1, ran: true, + }); +}); + test("tombstoned prompts are not seeded from transcripts", () => { const { root, sessionsRoot, stateDir } = setup(); writeSession(sessionsRoot, ["visible", "hidden-prompt"]); diff --git a/tests/history-session-writer.test.ts b/tests/history-session-writer.test.ts index bb135582a..a249e8b18 100644 --- a/tests/history-session-writer.test.ts +++ b/tests/history-session-writer.test.ts @@ -7,6 +7,7 @@ import { appendSessionCapture, openSessionWriter, projectHash, + seedFilePath, sessionFilePath, } from "../extensions/history/store.ts"; import promptHistoryExtension, { captureEnabled } from "../extensions/history/index.ts"; @@ -48,6 +49,8 @@ function captureHandlerWith(env: NodeJS.ProcessEnv, root: string) { cwd: CWD, instanceId: "inst-entry", now: () => 1700000000000, + agentDir: path.join(root, "agent"), + sessionsRoot: path.join(root, "sessions"), }); return registered[0][1] as (event: unknown) => void; } @@ -174,6 +177,30 @@ test("an opted-in session captures delivered prompts", () => { ]); }); +test("opted-in capture imports into its own root and defers seed on untrusted tombstones", () => { + const root = makeRoot(); + const sessions = path.join(root, "sessions", "--pi-history-test-project-a--"); + fs.mkdirSync(sessions, { recursive: true }); + fs.writeFileSync(path.join(sessions, "s.jsonl"), [ + JSON.stringify({ type: "session", version: 3 }), + JSON.stringify({ type: "message", message: { role: "user", content: "transcript prompt" } }), + ].join("\n") + "\n"); + const agentDir = path.join(root, "agent"); + fs.mkdirSync(agentDir); + fs.writeFileSync(path.join(agentDir, "editor-history.jsonl"), + JSON.stringify({ v: 1, text: "legacy prompt" }) + "\n"); + fs.writeFileSync(path.join(root, "hidden.json"), "{invalid"); + const handler = captureHandlerWith({ GENTLE_PI_HISTORY_CAPTURE: "1" }, root); + handler({ prompt: "current prompt" }); + assert.deepEqual(fileTexts(path.join(root, "history-global.jsonl")), ["legacy prompt"]); + assert.equal(fs.existsSync(seedFilePath(root, CWD)), false); + assert.deepEqual(fileTexts(sessionFilePath(root, CWD, "inst-entry")), ["current prompt"]); + fs.writeFileSync(path.join(root, "hidden.json"), JSON.stringify(["transcript prompt"])); + // A new instance retries bootstrap after tombstones become trusted. + captureHandlerWith({ GENTLE_PI_HISTORY_CAPTURE: "1" }, root)({ prompt: "next prompt" }); + assert.equal(fs.existsSync(seedFilePath(root, CWD)), false); +}); + test("disabling capture stops new lines and leaves existing files alone", () => { const root = makeRoot(); const env: NodeJS.ProcessEnv = { GENTLE_PI_HISTORY_CAPTURE: "true" }; From cc6ecca6842a6b2775b45db2d5b06f9c087882a2 Mon Sep 17 00:00:00 2001 From: Alan Buscaglia Date: Sat, 26 Sep 2026 18:24:05 +0200 Subject: [PATCH 15/16] fix(history): preserve concurrent legacy migration data --- extensions/history/store.ts | 61 ++++++++++++----- tests/history-legacy-migrate-v2.test.ts | 91 +++++++++++++++++++++++-- 2 files changed, 128 insertions(+), 24 deletions(-) diff --git a/extensions/history/store.ts b/extensions/history/store.ts index df4bdd2ae..fc608af58 100644 --- a/extensions/history/store.ts +++ b/extensions/history/store.ts @@ -467,18 +467,45 @@ function readValidLines(file: string): StoreEntry[] { * - `~/.pi/agent/editor-history.jsonl` (v1 single-file store) * - `~/.pi/agent/editor-history.json` (pre-v1 array, newest-first) * Content lands in `pi-history/history-global.jsonl` chronologically; only - * after the seed write succeeds is each source renamed `.imported`, never - * deleted — a failed write leaves sources untouched for a later retry. - * Gated: an existing global seed means migration already ran. + * after the seed write succeeds is the array source renamed `.imported`. + * Keep the v1 JSONL path live: older processes may still append to it, and + * later opens import new prompts without replacing the complete seed. */ export function migrateLegacyStores( root: string, agentDir: string, ): MigrationResult { const seed = globalSeedPath(root); - if (fs.existsSync(seed)) return { migrated: 0, ran: false }; + fs.mkdirSync(root, { recursive: true }); + const lock = `${seed}.migration-lock`; + try { + fs.mkdirSync(lock); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "EEXIST") { + return { migrated: 0, ran: false }; + } + throw error; + } + try { + return migrateLegacyStoresLocked(seed, agentDir); + } finally { + fs.rmdirSync(lock); + } +} +function migrateLegacyStoresLocked(seed: string, agentDir: string): MigrationResult { + // Re-read under the exclusive lock: another process may have published a + // complete seed while this one waited. Never replace a published seed. + const existing = fs.existsSync(seed) ? readValidLines(seed) : []; + const known = new Set(existing.map((entry) => promptKey(entry.text))); const collected: StoreEntry[] = []; + const collect = (entry: StoreEntry) => { + const key = promptKey(entry.text); + if (!known.has(key)) { + known.add(key); + collected.push(entry); + } + }; // Pre-v1 array (newest-first) → reverse to chronological. const legacyArray = path.join(agentDir, "editor-history.json"); @@ -492,35 +519,35 @@ export function migrateLegacyStores( const text = typeof item === "string" ? item : item && typeof item === "object" && "text" in item && typeof item.text === "string" ? item.text : null; - if (text && text.length > 0) collected.push({ v: 1, text }); + if (text && text.length > 0) collect({ v: 1, text }); } } // v1 single-file store — already chronological. const v1File = path.join(agentDir, "editor-history.jsonl"); - if (fs.existsSync(v1File)) { - collected.push(...readValidLines(v1File)); + for (const source of [`${v1File}.imported`, v1File]) { + // Older migrations may have renamed a file still open for appends. + if (fs.existsSync(source)) { + for (const entry of readValidLines(source)) collect(entry); + } } if (collected.length === 0) return { migrated: 0, ran: false }; - fs.mkdirSync(path.dirname(seed), { recursive: true }); const tmp = `${seed}.tmp-${process.pid}-${Date.now()}`; fs.writeFileSync( tmp, - collected.map((e) => JSON.stringify(e)).join("\n") + "\n", + [...existing, ...collected].map((e) => JSON.stringify(e)).join("\n") + "\n", "utf8", ); fs.renameSync(tmp, seed); - // The seed write is the source of truth: rename sources only once it - // succeeded, so a failure can never strand entries in .imported files. - for (const src of [legacyArray, v1File]) { - try { - if (fs.existsSync(src)) fs.renameSync(src, `${src}.imported`); - } catch { - // benign: the seed gate prevents duplicate import on the next run - } + // Array sources are immutable; the v1 JSONL path remains live for writers + // opened by older processes, and is checked again on later migrations. + try { + if (fs.existsSync(legacyArray)) fs.renameSync(legacyArray, `${legacyArray}.imported`); + } catch { + // A failed archive is harmless: already seeded entries are deduplicated. } return { migrated: collected.length, ran: true }; } diff --git a/tests/history-legacy-migrate-v2.test.ts b/tests/history-legacy-migrate-v2.test.ts index 98bae7f57..6fedf9fe3 100644 --- a/tests/history-legacy-migrate-v2.test.ts +++ b/tests/history-legacy-migrate-v2.test.ts @@ -44,8 +44,84 @@ test("v1 jsonl migrates into the global seed chronologically", () => { const result = migrateLegacyStores(root, agentDir); assert.deepEqual(result, { migrated: 2, ran: true }); assert.deepEqual(fileTexts(globalSeedPath(root)), ["old", "new"]); - assert.equal(fs.existsSync(v1), false); - assert.equal(fs.existsSync(`${v1}.imported`), true); + assert.equal(fs.existsSync(v1), true); + assert.equal(fs.existsSync(`${v1}.imported`), false); +}); + +test("competing migration cannot publish a partial seed over a complete one", () => { + const { root, agentDir } = makeDirs(); + const array = path.join(agentDir, "editor-history.json"); + const v1 = path.join(agentDir, "editor-history.jsonl"); + fs.writeFileSync(array, JSON.stringify(["array prompt"])); + fs.writeFileSync(v1, `${JSON.stringify({ text: "v1 prompt" })}\n`); + const original = fs.readFileSync; + let competing: ReturnType | undefined; + fs.readFileSync = ((file: fs.PathOrFileDescriptor, ...args: unknown[]) => { + if (file === v1 && !competing) { + // The first migration has already read the array; the competing one + // must not publish a seed while that snapshot is incomplete. + competing = { migrated: -1, ran: true }; + competing = migrateLegacyStores(root, agentDir); + } + return (original as (...args: unknown[]) => unknown)(file, ...args); + }) as typeof fs.readFileSync; + try { + migrateLegacyStores(root, agentDir); + } finally { + fs.readFileSync = original; + } + assert.deepEqual(competing, { migrated: 0, ran: false }); + assert.deepEqual(fileTexts(globalSeedPath(root)), ["array prompt", "v1 prompt"]); +}); + +test("an ownerless crash lock fails closed without changing sources or seed", () => { + const { root, agentDir } = makeDirs(); + const array = path.join(agentDir, "editor-history.json"); + const v1 = path.join(agentDir, "editor-history.jsonl"); + const seed = globalSeedPath(root); + const arrayBytes = JSON.stringify(["array prompt"]); + const v1Bytes = `${JSON.stringify({ text: "after crash" })}\n`; + const seedBytes = `${JSON.stringify({ text: "already seeded" })}\n`; + fs.writeFileSync(array, arrayBytes); + fs.writeFileSync(v1, v1Bytes); + fs.mkdirSync(root, { recursive: true }); + fs.writeFileSync(seed, seedBytes); + // A process can die immediately after mkdir, before recording ownership. + // An ownerless lock needs manual recovery; do not claim automatic import. + const lock = `${seed}.migration-lock`; + fs.mkdirSync(lock); + assert.deepEqual(migrateLegacyStores(root, agentDir), { migrated: 0, ran: false }); + assert.equal(fs.readFileSync(array, "utf8"), arrayBytes); + assert.equal(fs.readFileSync(v1, "utf8"), v1Bytes); + assert.equal(fs.existsSync(`${array}.imported`), false); + assert.equal(fs.existsSync(`${v1}.imported`), false); + assert.equal(fs.readFileSync(seed, "utf8"), seedBytes); + assert.equal(fs.existsSync(lock), true); +}); + +test("a live legacy writer remains reachable for prompts appended after migration", () => { + const { root, agentDir } = makeDirs(); + const v1 = path.join(agentDir, "editor-history.jsonl"); + fs.writeFileSync(v1, `${JSON.stringify({ text: "before" })}\n`); + const fd = fs.openSync(v1, "a"); + try { + migrateLegacyStores(root, agentDir); + fs.writeSync(fd, `${JSON.stringify({ text: "after" })}\n`); + assert.deepEqual(migrateLegacyStores(root, agentDir), { migrated: 1, ran: true }); + assert.deepEqual(fileTexts(globalSeedPath(root)), ["before", "after"]); + } finally { + fs.closeSync(fd); + } +}); + +test("prompts appended to an already archived live v1 file are recovered", () => { + const { root, agentDir } = makeDirs(); + const archived = path.join(agentDir, "editor-history.jsonl.imported"); + fs.writeFileSync(archived, `${JSON.stringify({ text: "archived" })}\n`); + migrateLegacyStores(root, agentDir); + fs.appendFileSync(archived, `${JSON.stringify({ text: "late" })}\n`); + assert.deepEqual(migrateLegacyStores(root, agentDir), { migrated: 1, ran: true }); + assert.deepEqual(fileTexts(globalSeedPath(root)), ["archived", "late"]); }); test("legacy array file also migrates (newest-first reversed)", () => { @@ -74,10 +150,10 @@ test("both legacy files: v1 jsonl content appends after array content", () => { "from-jsonl", ]); assert.equal(fs.existsSync(`${legacy}.imported`), true); - assert.equal(fs.existsSync(`${v1}.imported`), true); + assert.equal(fs.existsSync(v1), true); }); -test("existing global seed gates the migration (idempotent)", () => { +test("existing global seed is preserved while new v1 prompts are imported", () => { const { root, agentDir } = makeDirs(); fs.mkdirSync(path.dirname(globalSeedPath(root)), { recursive: true }); fs.writeFileSync( @@ -92,8 +168,9 @@ test("existing global seed gates the migration (idempotent)", () => { "utf8", ); const result = migrateLegacyStores(root, agentDir); - assert.deepEqual(result, { migrated: 0, ran: false }); - assert.deepEqual(fileTexts(globalSeedPath(root)), ["already-here"]); + assert.deepEqual(result, { migrated: 1, ran: true }); + assert.deepEqual(fileTexts(globalSeedPath(root)), ["already-here", "would-migrate"]); + assert.deepEqual(migrateLegacyStores(root, agentDir), { migrated: 0, ran: false }); assert.equal(fs.existsSync(v1), true); }); @@ -190,7 +267,7 @@ seedFailureTest( // Retry after the failure clears: full migration, then rename. const result = migrateLegacyStores(root, agentDir); assert.deepEqual(result, { migrated: 1, ran: true }); - assert.equal(fs.existsSync(`${v1}.imported`), true); + assert.equal(fs.existsSync(v1), true); assert.deepEqual(fileTexts(globalSeedPath(root)), ["survives-retry"]); }, ); From 6b384443740ffa111edd99414913f3d20b8a1b94 Mon Sep 17 00:00:00 2001 From: Alan Buscaglia Date: Sat, 26 Sep 2026 19:34:04 +0200 Subject: [PATCH 16/16] docs(odd): record history migration verification evidence --- odd/tasks/history-contributor-chain.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/odd/tasks/history-contributor-chain.md b/odd/tasks/history-contributor-chain.md index 7d46c0a8a..4718a6d37 100644 --- a/odd/tasks/history-contributor-chain.md +++ b/odd/tasks/history-contributor-chain.md @@ -21,4 +21,4 @@ Run focused `tests/history-*.test.ts`, project verification/typecheck, `git diff 2026-09-26: Remote heads inspected: #1391 `649711c`, #1393 `e302337`, #1394 `5981153`; origin/main `06c9915` at the H1 snapshot. #1391 cumulative diff since #1455 is 1796 additions/23 deletions across 13 files; later cumulative PRs are larger. Separate #1391 worktree created at `fix/history-pr1391-adaptation`. H1 writer observed RED 2 migration failures, GREEN 14/14 focused tests. With node_modules linked from the main checkout, independent verification passed 168 history tests, typecheck had 188 baseline diagnostics and no regression, and `git diff --check` passed. Integration with main, commit, PR checks and merge remain pending. Engram mirror `odd/history-contributor-chain/tasks` pending: this session is bound to the separate gentle-ai project and Engram rejects a gentle-pi write. ## Next step -Commit and integrate H1 with current main, then obtain exact-head CI and repository-policy metadata before merge. Keep H2/H3 blocked until H1 is safe. +H1 is blocked: native review lineage `review-88445da92b015b5c` escalated with `targeted_validator_rejected` for R3-001/R3-002 after the single bounded correction. Do not publish or merge this candidate as approved. Diagnose the native refusal through supported maintainer inspection or make a separately authorized fresh candidate; keep H2/H3 pending. Last integrated commit `15249c57b`, correction commit `cc6ecca68`; independent recheck: 172 focused history tests pass, typecheck retains 188 baseline diagnostics without regressions, diff check passed. `pnpm test` aborted before tests because pnpm attempted a noninteractive `node_modules` purge; the equivalent direct unit stage ran 3,754 tests (3,707 pass, 4 fail, 43 skip), while direct provider-contract and runtime-harness stages passed. Three failures are presence-poll timeouts in `agents-view-thread-identity.test.ts`; one `gentle-shell.test.ts` border mismatch includes unexpected `INSERT`. Baseline attribution unverified. Native review is still escalated. No PR push or merge occurred.