diff --git a/.claude/scheduled_tasks.lock b/.claude/scheduled_tasks.lock new file mode 100644 index 0000000..7100aba --- /dev/null +++ b/.claude/scheduled_tasks.lock @@ -0,0 +1 @@ +{"sessionId":"90cea4a8-a4dd-41fb-926d-6225eb929fdd","pid":99767,"procStart":"Mon Apr 27 07:35:47 2026","acquiredAt":1777283179332} \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c49f17..a009278 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,162 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.5.0] - 2026-04-26 + +### Changed + +- README headline reframed around the Opus 4.7 1M-token context window + (Phase 10 SC3): the same ~108k token ghost inventory is now expressed as + ~11% of 1M (was 54% of 200k), with the absolute number kept inline so + readers on smaller-context models retain a figure that matches their + reality. New "Native alternatives" section (Phase 10 SC4) compares + `/skills t-sort`, `/usage`, and `claude plugin disable` against + ccaudit's cross-component scope, regime-aware token math, and + archive-with-rollback differentiator. Docs only — no code change. + +### Added + +- `ccaudit purge-archive` command (Phase 9 SC6) — drains + `~/.claude/ccaudit/archived/` via a classifier over the manifest union. + Default is `--dry-run`; real purge requires explicit `--yes` (no prompt + fallback). Classification: reclaim-if-free → archive moved back to source, + drop-if-occupied → archive unlinked (source never overwritten), + drop-if-stale → already-gone archive's manifest entry retired, skip-if- + both-missing → preserved for diagnosis. Scope is archive ops only; flag + (memory) and disable (MCP) ops are untouched. Each executed mutation + appends a new `archive_purge` op to a fresh `purge--.jsonl` + manifest — originals are never rewritten. JSON envelope: + `purge.summary.{purgedCount, reclaimedCount, skippedOccupiedCount, +staleFilteredCount}` + `purge.failures[]` + additive `purge.manifestPath` + and `purge.manifestErrors[]`. See + [docs/JSON-SCHEMA.md § Purge](./docs/JSON-SCHEMA.md). +- Empty-inventory short-circuit (Phase 9 SC1): `ghost` and + `ghost --interactive` exit 0 with a clean single-line message on an empty + inventory; the TUI is never opened. +- `CCAUDIT_NO_INTERACTIVE=1` env escape hatch (Phase 9 SC2) — truthy + (`1` / `true`, case-insensitive) gates every interactive entry point. + Silent suppression of auto-open; hard refusal on explicit `--interactive` + with exit code 2 and `refusing: CCAUDIT_NO_INTERACTIVE is set`. +- Tabbed-picker pagination (Phase 9 SC3): viewports bounded by terminal + rows; 500+ item tabs scroll cleanly without layout breakage; scroll + position preserved across `/` filter, `s` sort cycle, and framework + group toggle. +- Color-blind-friendly glyph set (Phase 9 SC4): every selectable picker + state carries a distinct ASCII-safe glyph in column 1 independent of + color — selected `◉`, unselected `◯`, protected `🔒`, multi-config MCP + `⚠`, stale memory `⌛` — with ASCII fallback under `NO_COLOR` / `TERM=dumb` + / `--no-color`. Legend in the `?` help overlay. +- SIGWINCH-robust picker rendering (Phase 9 SC5): the custom + `@clack/core.MultiSelectPrompt` subclass now registers a `SIGWINCH` + handler that recomputes viewport dimensions and issues a full re-render. + Debounced via `setImmediate` and torn down on picker exit. +- `restore --interactive` / `-i`: open a mirror of the archive picker + listing every archived item across all manifests (deduplicated, + newer-wins); select a subset to restore. +- `restore --name `: fuzzy single-match restore (case-insensitive + substring). Ambiguous patterns error with a candidate list — never + auto-resolve. +- `restore --all-matching `: bulk restore of every item matching + the fuzzy pattern. +- JSON envelope: restore success/partial-success results now include + additive `selectionFilter` (`null | { mode: "subset", ids: string[] }`) + and `skipped[]` (source_exists skips with `canonicalId`) fields. See + `docs/JSON-SCHEMA.md`. +- `restore.filteredStaleCount` JSON field — additive non-negative + integer on success / partial-success / list envelopes counting + archive ops suppressed from the restore listing because + `archive_path` is missing AND `source_path` exists + (already-restored / test-residue hygiene, Phase 8.2). Applies to + `restore --list`, `restore --interactive`, and full `restore`. + +### Fixed + +- `restore --interactive` now executes selected MEMORY items in the subset + restore path instead of dropping them after picker confirmation. Selected + memory files have their `ccaudit-stale` / `ccaudit-flagged` frontmatter + cleaned as expected, and `selectionFilter.ids` now reflects only the + ids that actually resolved/executed. +- `restore --help` and `ghost --help` no longer leak gunshi's raw + negatable-placeholder lines (`Negatable of --color`, + `Negatable of --group-frameworks`). The public `--no-color` and + `--no-group-frameworks` flags now render as clean user-facing help rows. + +## [1.5.0-beta.0] - 2026-04-19 + +v1.5 "Interactive Archive" — response to Reddit feedback asking for a surgical +alternative to the full-inventory bust. Threads an optional subset filter +through the existing `runBust` pipeline, adds a `@clack/core`-based TUI +picker, and locks down six new safety invariants (INV-S1…S6) before polish. +Restore gains `--interactive` / `--name` / `--all-matching` in a companion +phase (not shipped in this entry — see Phase 8 tracking in `.planning/`). + +### Added + +- `ghost --interactive` / `-i`: tabbed TUI picker for selective archival. + Five category tabs (agents / skills / MCP / memory / commands) with + bounded viewport, cross-tab selection persistence, and an inline + confirmation screen that replaces the 3-prompt readline ceremony. Requires + a TTY; non-TTY sessions fall back to `--dry-run`. `--interactive` combined + with `--json` is a hard error. Hook archival is deferred to a future + phase. +- Keyboard model: `/` filter (case-insensitive substring), `s` sort cycle + (staleness → tokens → name), `?` help overlay, `Space` toggle, `a` + toggle-all-within-tab, `Tab` / `Shift-Tab` / `←` / `→` tab navigation, + `1`–`6` direct-jump to visible tabs, `Enter` confirm global selection, + `Esc` / `Ctrl+C` / `q` cancel with "No changes made." and exit 0. +- Live token counter in the picker footer: `X of Y · ≈ Zk tokens saved` + recomputes on every toggle and re-renders on `SIGWINCH`. +- Framework protection UX: partially-used-framework members render dimmed + with a `[🔒]` glyph and inline reason + `"Part of (N used, M ghost). --force-partial to override."` + Space is a no-op on protected rows. `--force-partial` surfaces a banner + warning at the top of the TUI and unlocks the rows for the current run. +- MCP multi-project warning: MCP server rows whose key appears in more than + one config file render with a `⚠` glyph and a focused-row "Also in:" hint + listing the referenced config paths. +- JSON envelope fields (additive — see `docs/JSON-SCHEMA.md`): + `bust.summary.totalPlannedTokens` (full-plan figure preserved across + subset busts) and `manifest.header.selection_filter` (`{ mode: 'full' }` + or `{ mode: 'subset', ids: string[] }`). +- Auto-open prompt: after a regular `ccaudit ghost` scan on a TTY, users + see `Open interactive picker? [y/N]`. Suppressed by `--json`, `--csv`, + `--quiet`, `--ci`, and non-TTY. +- `CCAUDIT_SELECT_IDS` environment variable: non-interactive subset hook + (primarily for integration tests and scripted automation). Threads the + same filter the TUI uses through `runBust`. +- Six new safety invariants (INV-S1…S6) documented in `CLAUDE.md` and + locked by fixture-based integration tests. + +### Changed + +- `bust.summary.freedTokens` is now **subset-accurate** when + `manifest.header.selection_filter.mode === 'subset'`. For full-inventory + busts (the default non-interactive path) the value is unchanged — + `freedTokens === totalPlannedTokens` and the v1.4 contract is preserved. + **Migration note**: consumers that compared `freedTokens` across runs + must now consult `manifest.header.selection_filter.mode` to distinguish subset vs full + busts. Dashboards that want "what was the full opportunity?" should read + `totalPlannedTokens`. + +### Fixed + +- TUI picker long-list viewport overflow (Phase 3.1): `@clack/prompts.groupMultiselect` + renders all options inline with no windowing, so long inventories auto- + anchored to the bottom of the terminal and the highlighted cursor + disappeared above the viewport. Replaced with a custom + `@clack/core.MultiSelectPrompt` subclass backed by a bounded viewport + (`max(8, rows − 10)`) with `↑ N more` / `↓ N more` scroll indicators. +- `@clack/core` Esc → cancel / Enter → submit alias defect (Phase 5 gap + closure): `@clack/core`'s base `Prompt.onKeypress` unconditionally + aliased `escape` → cancel and `return` → submit after subclass handlers + ran, breaking `Esc`-clears-filter, `Enter`-keeps-query, and `Esc`-closes- + help-overlay contracts. Fixed by wrapping `onKeypress` on the subclass + and conditionally suppressing the base aliases when `filterMode` or + `helpOpen` is true. Normal picker-state Ctrl+C / Esc / Enter behavior + is unchanged. + +--- + ## [1.4.0] - 2026-04-13 Token estimation methodology rewrite. All six Claude Code inventory categories now diff --git a/CLAUDE.md b/CLAUDE.md index e1513d9..021770c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -41,6 +41,12 @@ CI is tag-triggered only. Pre-push hook runs format + lint (not tests). Run `pnp ## Safety invariants +**Approach A** — ship the archive flow as a reversible move + manifest + +checkpoint-hash gate, not a destructive edit. Every invariant below enforces +this principle: nothing is deleted, every action is logged to a manifest, +and every mutation is gated by a prior dry-run checkpoint whose SHA-256 +inventory hash matches the current scan. + These are user-visible behaviors that must not regress. Each was hard-won; the fix history is in the [v1.4.0 changelog entry](./CHANGELOG.md). - **Nothing is deleted.** Agents and skills are _moved_ to `~/.claude/ccaudit/archived/`. MCP servers are _key-renamed_ in-place (`name` → `ccaudit-disabled:name`). Memory files get a _frontmatter flag_, never destructive rewrites. @@ -51,6 +57,19 @@ These are user-visible behaviors that must not regress. Each was hard-won; the f - **`reclaim` never overwrites an existing source path.** If the inferred source already exists, skip with a warning. Same for any future recovery command. - **History writes never crash the main command.** `recordHistory` is wrapped in try/catch at the outermost layer; on failure emit one stderr warning and continue. `CCAUDIT_NO_HISTORY=1` short-circuits before any filesystem work. +### Invariant register (INV-S1..INV-S6) + +Identifier-stable register for the v1.5 interactive archive surface. Each +ID maps to a fixture-backed integration test — regressions here are ship +blockers. + +- **INV-S1** — Unselected MCP server keys are byte-preserved in `~/.claude.json` across a subset bust (no whitespace churn, no key reordering). +- **INV-S2** — SIGINT / Ctrl+C during any TUI flow produces zero manifest writes: aborted picker sessions leave `~/.claude/ccaudit/manifests/` untouched. +- **INV-S3** — `restore` round-trips both subset and full manifests: items archived by a subset bust and items archived by a later full bust are both reachable from a single `restore` invocation. +- **INV-S4** — `manifest.header.planned_ops` counts reflect the filtered plan, not the full plan: a subset bust of N items writes exactly `archive + disable + flag === N` operation records. +- **INV-S5** — `bust.summary.freedTokens` is subset-accurate; the additive `bust.summary.totalPlannedTokens` preserves the full-plan figure for consumers. Full-inventory busts satisfy `freedTokens === totalPlannedTokens`. +- **INV-S6** — Framework-as-unit protection blocks partial busts by default: members of a partially-used framework are not selectable in the TUI and are skipped by the non-interactive bust path unless `--force-partial` is explicitly set on both `--dry-run` and `--dangerously-bust-ghosts`. + ## Where things live - `apps/ccaudit/src/cli/commands/{ghost,restore,reclaim,mcp,trend,install-skill}.ts`: subcommand wrappers diff --git a/README.md b/README.md index c872f70..ca1d9bb 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,28 @@ # ccaudit -**54% of your Claude Code context window is consumed before you type a word.** +**~11% of your Opus 4.7 1M-token context is gone before you type a word.** (~108k tokens of ghost inventory — roughly half a Sonnet 4.6 200k context. See snapshot below.) Unused agents, skills, MCP servers, commands, hooks, and memory files (call them ghosts) load into context every session.
ccaudit finds them, shows you the cost, and moves them to `~/.claude/ccaudit/archived/` in one command.
Undo any time with `ccaudit restore`. +## Native alternatives (and where ccaudit fits) + +Claude Code ships three first-party tools that overlap with parts of what +ccaudit does. None of them cover the full picture: + +| Tool | Covers | Doesn't cover | +| ----------------------- | --------------------------------------------- | ------------------------------------------------------------------------------------------------ | +| `/skills t-sort` | Skills only — list, reorder. | Agents, MCP servers, hooks, memory, commands. No token math. No archive / rollback. | +| `/usage` | Aggregate token + cost reporting per session. | Per-component ghost identification. Doesn't tell you what to remove or move anything. | +| `claude plugin disable` | Plugin-level on/off. | Granular agent / skill / MCP / hook visibility. No token cost. No manifest of what was disabled. | + +ccaudit's differentiator: **cross-component scope** (agents + skills + MCP + +memory + commands + hooks in one pass), **regime-aware token math** (eager +vs deferred-tools once `cc ≥ 2.1.7` flips the ToolSearch threshold), and +**archive-with-rollback** — every action is manifest-logged and reversible +via `ccaudit restore`. + ```bash npx ccaudit-cli@latest # see what's loading vs. what's used npx ccaudit-cli --dry-run # see the archive plan, no files touched @@ -14,7 +31,7 @@ npx ccaudit-cli --dangerously-bust-ghosts # archive ghosts (nothing deleted, un > ccusage tells you what you spent. ccaudit tells you what's wasting it. -Current release: **v1.4.0**. +Current release: **v1.5.0** — interactive archive picker, interactive restore picker, fuzzy-match restore by name, archive purge. See [CHANGELOG.md](./CHANGELOG.md). ![Image](https://github.com/user-attachments/assets/2afbe339-539b-4a8d-9f73-8d43746c9ace) @@ -129,17 +146,101 @@ Restore anytime: ccaudit restore --- +## Interactive mode + +`ccaudit ghost --interactive` (short: `-i`) opens a TUI picker for selective +archival. Instead of archiving every ghost in one shot, you scroll six +category tabs, pick exactly what to archive, and confirm on a single screen. +Requires a TTY; non-TTY sessions fall back to `--dry-run` and `--interactive` +combined with `--json` is a hard error. + +```bash +npx ccaudit-cli ghost --interactive +``` + +### The picker + +```text +┌─ Archive ghosts ─────────────────────────────────────────────────────┐ +│ [ agents (3/12) ] [ skills (0/8) ] [ mcp (1/2) ] [ memory (0/4) ] │ +│ [ commands (0/5) ] │ +├──────────────────────────────────────────────────────────────────────┤ +│ ◉ code-reviewer agent ~1.2k 28d stale │ +│ ◯ pencil-dev agent ~0.9k 44d stale │ +│ ◉ doc-writer agent ~1.5k 91d stale │ +│ [🔒] gsd-planner agent ~2.1k Part of GSD (3 used, │ +│ 9 ghost). --force-partial │ +│ ◉ playwright ⚠ mcp ~3.8k Also in: ./project-b/.mcp │ +│ ↓ 2 more │ +├──────────────────────────────────────────────────────────────────────┤ +│ 5 of 32 · ≈ 8.4k tokens saved Space toggle / filter ? help │ +└──────────────────────────────────────────────────────────────────────┘ +``` + +> Hook archival deferred — selectable archive coming in a future phase. + +### The confirmation screen + +```text +┌─ Confirm archive ────────────────────────────────────────────────────┐ +│ │ +│ Archive the following 5 ghosts? │ +│ │ +│ agents: 3 (code-reviewer, doc-writer, +1 more) │ +│ mcp: 1 (playwright) │ +│ skills: 1 (my-skill) │ +│ │ +│ Estimated savings: ≈ 8.4k tokens per session │ +│ Manifest: ~/.claude/ccaudit/manifests/bust-*.jsonl │ +│ │ +│ [ Archive ] [ Cancel ] │ +│ │ +└──────────────────────────────────────────────────────────────────────┘ +``` + +### Keybinds + +| Key | Action | +| ---------------------- | ----------------------------------------------- | +| `Space` | Toggle selection of focused row | +| `a` | Toggle-all within the active tab | +| `/` | Filter by case-insensitive substring | +| `s` | Cycle sort (staleness → tokens → name) | +| `?` | Help overlay (canonical full keybind list) | +| `Tab` / `Shift-Tab` | Cycle tabs forward / back with wrap | +| `←` / `→` | Same as `Tab` / `Shift-Tab` | +| `1`–`6` | Jump directly to a visible tab | +| `Enter` | Confirm selection and proceed to confirm screen | +| `Esc` / `Ctrl+C` / `q` | Cancel with "No changes made." (exit 0) | + +Framework-protected rows render dimmed with a `[🔒]` glyph and are not +selectable by default — pass `--force-partial` to unlock them for the +current run (a `--force-partial active — partial-framework busts allowed` +banner appears at the top of the picker). See `CLAUDE.md`'s Safety +invariants section for the full rationale behind framework-as-unit +protection. + +Rollback: `ccaudit restore` moves everything back. **v1.5 adds +`restore --interactive`**, a mirror of the archive picker listing every +archived item across all manifests (deduplicated, newer-wins) so you can +restore a subset. `restore --name ` and +`restore --all-matching ` add fuzzy single-match and bulk-match +restore by name. See the `restore` section below. + +--- + ## Commands -| Command | What it does | Notable options | -| --------------- | -------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `ghost` | Default ghost inventory report, plus dry-run and remediation entry point | `--since`, `--dry-run`, `--dangerously-bust-ghosts`, `--yes-proceed-busting`, `--privacy`, `--verbose`, `--no-group-frameworks`, `--force-partial`, `--include-hooks`, `--regime` | -| `inventory` | Full inventory with usage statistics | `--since`, `--verbose`, `--no-group-frameworks` | -| `mcp` | MCP server token costs and frequency | `--since`, `--live`, `--timeout` | -| `trend` | Invocation frequency over time | `--since` | -| `restore` | Revert a previous bust | `[name]`, `--list` | -| `reclaim` | Recover orphaned files in `~/.claude/ccaudit/archived/` not referenced by any manifest | `--dry-run` | -| `install-skill` | Install the `/ccaudit-bust` Claude Code skill | `--dry-run`, `--force`, `--project` | +| Command | What it does | Notable options | +| --------------- | -------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `ghost` | Default ghost inventory report, plus dry-run and remediation entry point | `--interactive` / `-i`, `--since`, `--dry-run`, `--dangerously-bust-ghosts`, `--yes-proceed-busting`, `--privacy`, `--verbose`, `--no-group-frameworks`, `--force-partial`, `--include-hooks`, `--regime` | +| `inventory` | Full inventory with usage statistics | `--since`, `--verbose`, `--no-group-frameworks` | +| `mcp` | MCP server token costs and frequency | `--since`, `--live`, `--timeout` | +| `trend` | Invocation frequency over time | `--since` | +| `restore` | Revert a previous bust | `[name]`, `--list`, `--interactive` / `-i`, `--name `, `--all-matching ` | +| `reclaim` | Recover orphaned files in `~/.claude/ccaudit/archived/` not referenced by any manifest | `--dry-run` | +| `purge-archive` | Drain `~/.claude/ccaudit/archived/` of reclaimed and stale entries | `--dry-run`, `--yes`, `--json` | +| `install-skill` | Install the `/ccaudit-bust` Claude Code skill | `--dry-run`, `--force`, `--project` | --- @@ -168,21 +269,22 @@ Notes: ### `ghost` -| Flag | Short | Description | -| --------------------------- | ----- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `--since ` | `-s` | Time window for ghost detection (e.g. `7d`, `30d`, `2w`). Default: `7d`. | -| `--json` | `-j` | Output as JSON with a `meta` envelope. | -| `--csv` | | RFC 4180 CSV export. | -| `--quiet` | `-q` | Machine-readable TSV only (suppress decorative text). | -| `--verbose` | `-v` | Show scan details on stderr; expand framework rows into member trees. | -| `--dry-run` | | Preview the change plan without mutating files. Writes a checkpoint to `~/.claude/ccaudit/.last-dry-run`. | -| `--dangerously-bust-ghosts` | | Execute the bust plan: archive ghost agents/skills, disable ghost MCP servers, flag stale memory. Requires a prior `--dry-run` with a matching inventory hash. | -| `--yes-proceed-busting` | | Skip the 3-step confirmation ceremony. Required for non-TTY shells and CI. | -| `--privacy` | | Redact real project paths from output (replaces with `project-01`, `project-02`, etc.). | -| `--no-group-frameworks` | | Disable framework grouping. Output reverts to the v1.2.1 layout. | -| `--force-partial` | | Bypass framework-as-unit bust protection. Must match between `--dry-run` and `--dangerously-bust-ghosts` runs. | -| `--include-hooks` | | Add hook upper-bound token costs to the grand total. By default hooks are surfaced as advisory only. | -| `--regime ` | | Override MCP regime detection. `eager` = per-server measured costs; `deferred` = single ToolSearch overhead (~8.7k tokens). Default: `auto`. | +| Flag | Short | Description | +| --------------------------- | ----- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `--since ` | `-s` | Time window for ghost detection (e.g. `7d`, `30d`, `2w`). Default: `7d`. | +| `--json` | `-j` | Output as JSON with a `meta` envelope. | +| `--csv` | | RFC 4180 CSV export. | +| `--quiet` | `-q` | Machine-readable TSV only (suppress decorative text). | +| `--verbose` | `-v` | Show scan details on stderr; expand framework rows into member trees. | +| `--interactive` | `-i` | Open an interactive TUI picker to archive a subset of ghosts. Implies bust with an inline confirmation screen. Requires a TTY; non-TTY sessions fall back to `--dry-run`. Mutually exclusive with `--json`. | +| `--dry-run` | | Preview the change plan without mutating files. Writes a checkpoint to `~/.claude/ccaudit/.last-dry-run`. | +| `--dangerously-bust-ghosts` | | Execute the bust plan: archive ghost agents/skills, disable ghost MCP servers, flag stale memory. Requires a prior `--dry-run` with a matching inventory hash. | +| `--yes-proceed-busting` | | Skip the 3-step confirmation ceremony. Required for non-TTY shells and CI. | +| `--privacy` | | Redact real project paths from output (replaces with `project-01`, `project-02`, etc.). | +| `--no-group-frameworks` | | Disable framework grouping. Output reverts to the v1.2.1 layout. | +| `--force-partial` | | Bypass framework-as-unit bust protection. Must match between `--dry-run` and `--dangerously-bust-ghosts` runs. | +| `--include-hooks` | | Add hook upper-bound token costs to the grand total. By default hooks are surfaced as advisory only. | +| `--regime ` | | Override MCP regime detection. `eager` = per-server measured costs; `deferred` = single ToolSearch overhead (~8.7k tokens). Default: `auto`. | ### `inventory` @@ -219,15 +321,25 @@ Notes: ### `restore` -| Flag / Arg | Short | Description | -| ----------- | ----- | ---------------------------------------------------------------------- | -| _(no args)_ | | Restore all items from the most recent bust manifest. | -| `` | | Restore a single archived item by name (e.g. `restore code-reviewer`). | -| `--list` | | List all archived items across all bust manifests (read-only). | -| `--json` | `-j` | Output as JSON with a `meta` envelope. | -| `--csv` | | RFC 4180 CSV export. | -| `--quiet` | `-q` | Machine-readable TSV only. | -| `--verbose` | `-v` | Show detailed output including warnings. | +| Flag / Arg | Short | Description | +| -------------------------- | ----- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| _(no args)_ | | Restore all items from **every** bust manifest (deduplicated, newer-wins). | +| `` | | Restore a single archived item by name: basename without extension for agents/skills/commands (e.g. `restore code-reviewer`), or server name for MCP entries. | +| `--interactive` | `-i` | Open a TUI picker listing every archived item across all manifests. Select a subset to restore. Requires a TTY; may be combined with `--json` for the final result envelope. | +| `--name ` | | Fuzzy single-match restore (case-insensitive substring). Ambiguous patterns error with a candidate list — never auto-resolve. | +| `--all-matching ` | | Bulk restore of every item matching the fuzzy pattern. Exits `1` with `no archived item matches ""` on stderr if nothing matches. | +| `--list` | | List all archived items across all bust manifests (read-only). | +| `--json` | `-j` | Output as JSON with a `meta` envelope. Includes additive `selectionFilter`, `skipped[]`, and `filteredStaleCount` fields — see [docs/JSON-SCHEMA.md](./docs/JSON-SCHEMA.md). | +| `--csv` | | RFC 4180 CSV export. | +| `--quiet` | `-q` | Machine-readable TSV only. | +| `--verbose` | `-v` | Show detailed output including warnings. | + +**Listing hygiene (v1.5):** `restore --interactive`, `restore --list`, and full +`restore` automatically suppress archive ops whose `archive_path` is missing +AND `source_path` exists (already-restored items or stale test residue). The +count of suppressed entries is reported as `restore.filteredStaleCount` in +the JSON envelope. Entries where both paths are missing stay listed so restore +can fail loudly on genuinely broken state. ### `reclaim` @@ -295,15 +407,18 @@ npx ccaudit-cli --dangerously-bust-ghosts --yes-proceed-busting Everything is reversible: ```bash -npx ccaudit-cli restore # restore everything from the latest manifest -npx ccaudit-cli restore # restore one archived item by name -npx ccaudit-cli restore --list # list all archived items across busts +npx ccaudit-cli restore # restore every archived item across all manifests +npx ccaudit-cli restore --interactive # TUI picker — select a subset to restore (v1.5) +npx ccaudit-cli restore --name code-reviewer # fuzzy single-match restore (v1.5) +npx ccaudit-cli restore --all-matching gsd- # bulk-restore everything matching the pattern (v1.5) +npx ccaudit-cli restore # restore one archived item by exact canonical id +npx ccaudit-cli restore --list # list all archived items across busts ``` -Full-mode `restore` now walks **every** manifest in `~/.claude/ccaudit/manifests/` (not just the newest), with deduplication. Items archived by older busts are recovered too. Output now reports `moved` and `already-at-source` separately so idempotent re-runs don't inflate the success count. +Full-mode `restore` walks **every** manifest in `~/.claude/ccaudit/manifests/` (not just the newest), with deduplication. Items archived by older busts are recovered too. Output reports `moved` and `already-at-source` separately so idempotent re-runs don't inflate the success count. In v1.5, listing paths additionally suppress entries whose archive file is gone but whose source is already restored (stale / already-done hygiene) and surface the count as `filteredStaleCount` in JSON. ```text -159 agents/skills restored to their original locations (324 were already at source) +159 items restored to their original locations (324 were already at source) ``` What gets reversed: @@ -335,6 +450,28 @@ Safety invariant: `reclaim` **never** overwrites an existing file at the inferre Note: if the manifest still exists, `ccaudit restore` is the right tool: it walks all manifests including older ones. `reclaim` is the rescue path for files whose manifest is gone. +### Purge archive (v1.5) + +Over time `~/.claude/ccaudit/archived/` accumulates: items you reclaimed back to source, items whose source path has since been re-created from scratch, plus stale manifest entries where the archive file has already been moved. `ccaudit purge-archive` drains that directory on demand. + +```bash +ccaudit purge-archive # same as --dry-run (default) +ccaudit purge-archive --dry-run # classify; zero filesystem changes +ccaudit purge-archive --yes # execute the classified plan (destructive) +ccaudit purge-archive --json # structured envelope, combinable with --yes +``` + +Classification per manifest-union archive op: + +- **Reclaim** — archive exists, source path is free. The archive is moved back to source (same shared mover as `reclaim`). +- **Drop / source_occupied** — archive exists, source path is already occupied. The archive is unlinked. The source file is **never** overwritten. +- **Drop / stale_archive_missing** — archive is already gone, source exists (already-restored or test residue). No disk mutation; a follow-up manifest op is still appended so restore listings stop surfacing it. +- **Skip / both_missing** — both paths are absent. Preserved for diagnosis; never auto-resolved. + +Scope is **archive ops only**. Flag ops (memory frontmatter) and MCP disable ops (the `name → ccaudit-disabled:name` key-rename written by `bust`) are untouched by this command. Each executed mutation is recorded as an append-only `archive_purge` op in a fresh `purge--.jsonl` manifest — the original archive op stays intact for audit. Real purge requires an **explicit `--yes`**; there is no prompt fallback. Failures on individual items are reported in `purge.failures[]` but do not abort the batch. + +See [docs/JSON-SCHEMA.md § Purge](./docs/JSON-SCHEMA.md) for the `purge.summary` / `purge.failures[]` envelope contract. + --- ## Audit trail @@ -546,11 +683,56 @@ wins. Place more-specific entries before more-general ones to avoid shadowing. ## Version -- Current package version: **1.4.0** +- Current package version: **1.5.0** - Build source of truth: `apps/ccaudit/package.json` and `apps/ccaudit/src/_version.ts` --- +## Roadmap + +Items planned for upcoming releases. No firm dates — order may shift. + +### Interactive process killer + +An interactive TUI for forcefully terminating running Claude Code instances, +mirroring the ghost selection picker (`--interactive`). Lists every live +`claude` process the user has access to with token usage, runtime, and project +context, lets you multi-select with the same keybindings as the archive +picker, and signals selected processes (TERM by default, KILL on confirm). +For when one rogue session is pinning context across half your machine. + +### v1.6 game CLI API contract + +A separate, stable JSON CLI surface scoped to embedding tools — initial +consumer is the standalone **ccaudit Ghost Town** game. The contract: + +```bash +ccaudit game scan --json +ccaudit game bust --ids --json +ccaudit game vault --json +ccaudit game restore --ids --json +ccaudit game agent-context --id --json +ccaudit game dry-run --ids --json # optional +``` + +Stability guarantees external consumers will rely on: + +- TUI behavior is **not** part of the contract. +- Human-rendered tables are **not** part of the contract. +- Hidden environment variables are **not** part of the contract. +- Internal TypeScript functions are **not** part of the contract. +- Path-shaped canonical IDs are not public unless explicitly declared. + +The envelope follows the existing `--json` shape (see +[docs/JSON-SCHEMA.md](./docs/JSON-SCHEMA.md)) with a `command: "game.*"` and +`apiVersion` field for forward compatibility. + +### A new frontend — surprise + +Something visual is in the works. More when it lands. + +--- + ## Author [fabio-dee](https://github.com/fabio-dee) diff --git a/apps/ccaudit/package.json b/apps/ccaudit/package.json index 4555640..e992f8c 100644 --- a/apps/ccaudit/package.json +++ b/apps/ccaudit/package.json @@ -1,6 +1,6 @@ { "name": "ccaudit-cli", - "version": "1.4.0", + "version": "1.5.0", "description": "Audit Claude Code ghost inventory — agents, skills, MCP servers, and memory files", "keywords": [ "audit", @@ -43,6 +43,8 @@ "devDependencies": { "@ccaudit/internal": "workspace:*", "@ccaudit/terminal": "workspace:*", + "@clack/prompts": "catalog:", + "@praha/byethrow": "catalog:", "@types/node": "catalog:", "@vitest/coverage-v8": "catalog:", "gunshi": "catalog:", diff --git a/apps/ccaudit/scripts/bundle-baseline-03.2.txt b/apps/ccaudit/scripts/bundle-baseline-03.2.txt new file mode 100644 index 0000000..84e0115 --- /dev/null +++ b/apps/ccaudit/scripts/bundle-baseline-03.2.txt @@ -0,0 +1 @@ +170619 diff --git a/apps/ccaudit/scripts/bundle-baseline-phase-05.txt b/apps/ccaudit/scripts/bundle-baseline-phase-05.txt new file mode 100644 index 0000000..d0535af --- /dev/null +++ b/apps/ccaudit/scripts/bundle-baseline-phase-05.txt @@ -0,0 +1 @@ +177038 diff --git a/apps/ccaudit/scripts/bundle-baseline-phase-06.txt b/apps/ccaudit/scripts/bundle-baseline-phase-06.txt new file mode 100644 index 0000000..61fea92 --- /dev/null +++ b/apps/ccaudit/scripts/bundle-baseline-phase-06.txt @@ -0,0 +1 @@ +180494 diff --git a/apps/ccaudit/scripts/bundle-baseline-phase-09.txt b/apps/ccaudit/scripts/bundle-baseline-phase-09.txt new file mode 100644 index 0000000..f8e01d3 --- /dev/null +++ b/apps/ccaudit/scripts/bundle-baseline-phase-09.txt @@ -0,0 +1 @@ +195187 diff --git a/apps/ccaudit/scripts/bundle-baseline.txt b/apps/ccaudit/scripts/bundle-baseline.txt new file mode 100644 index 0000000..b0dd56a --- /dev/null +++ b/apps/ccaudit/scripts/bundle-baseline.txt @@ -0,0 +1 @@ +196217 diff --git a/apps/ccaudit/scripts/bundle-size-check.mjs b/apps/ccaudit/scripts/bundle-size-check.mjs new file mode 100644 index 0000000..285707f --- /dev/null +++ b/apps/ccaudit/scripts/bundle-size-check.mjs @@ -0,0 +1,90 @@ +#!/usr/bin/env node +// bundle-size-check.mjs — D-04 verification: dist/index.js gzipped size must not exceed +// bundle-baseline.txt by more than the configured budget. Uses node:fs + node:zlib only. +// Run after `pnpm -w build`. Part of `pnpm verify` chain. +import { readFileSync, existsSync } from 'node:fs'; +import { gzipSync } from 'node:zlib'; +import { resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __filename = fileURLToPath(import.meta.url); +const scriptsDir = resolve(__filename, '..'); +const distPath = resolve(scriptsDir, '../dist/index.js'); +const baselinePath = resolve(scriptsDir, 'bundle-baseline.txt'); +const BUDGET_BYTES = 15 * 1024; // 15360 bytes per D-04 + +if (!existsSync(distPath)) { + console.error(`[bundle-size] FAIL: dist/index.js not found at ${distPath}`); + console.error('[bundle-size] Run `pnpm -w build` first.'); + process.exit(1); +} + +if (!existsSync(baselinePath)) { + console.error(`[bundle-size] FAIL: baseline file not found at ${baselinePath}`); + process.exit(1); +} + +const buf = readFileSync(distPath); +const gzipped = gzipSync(buf, { level: 9 }); +const actual = gzipped.length; + +const baselineRaw = readFileSync(baselinePath, 'utf8').trim(); +const baseline = Number.parseInt(baselineRaw, 10); + +if (!Number.isFinite(baseline) || baseline <= 0) { + console.error( + `[bundle-size] FAIL: could not parse baseline from ${baselinePath}: ${JSON.stringify(baselineRaw)}`, + ); + process.exit(1); +} + +const delta = actual - baseline; +console.log( + `[bundle-size] actual=${actual}B baseline=${baseline}B delta=${delta}B budget=${BUDGET_BYTES}B`, +); + +if (delta > BUDGET_BYTES) { + console.error(`[bundle-size] FAIL: delta exceeds 15 KB budget (${delta} > ${BUDGET_BYTES})`); + process.exit(1); +} + +// Phase-local bundle gate (opt-in via CCAUDIT_PHASE_BASELINE=/path/to/baseline.txt env var). +// Phase 3.1 uses a <10 KB growth budget (D3.1-16); callers can override via CCAUDIT_PHASE_BUDGET_BYTES. +// Future phases can set their own baseline file + budget without touching this script. +const phaseBaselinePath = process.env.CCAUDIT_PHASE_BASELINE; +if (phaseBaselinePath) { + if (!existsSync(phaseBaselinePath)) { + console.error( + `[bundle-size] FAIL: CCAUDIT_PHASE_BASELINE set but file not found at ${phaseBaselinePath}`, + ); + process.exit(1); + } + const phaseBaselineRaw = readFileSync(phaseBaselinePath, 'utf8').trim(); + const phaseBaseline = Number.parseInt(phaseBaselineRaw, 10); + if (!Number.isFinite(phaseBaseline) || phaseBaseline <= 0) { + console.error( + `[bundle-size] FAIL: could not parse phase baseline from ${phaseBaselinePath}: ${JSON.stringify(phaseBaselineRaw)}`, + ); + process.exit(1); + } + const phaseBudgetRaw = process.env.CCAUDIT_PHASE_BUDGET_BYTES ?? '10240'; + const phaseBudget = Number.parseInt(phaseBudgetRaw, 10); + if (!Number.isFinite(phaseBudget) || phaseBudget <= 0) { + console.error( + `[bundle-size] FAIL: invalid CCAUDIT_PHASE_BUDGET_BYTES: ${JSON.stringify(phaseBudgetRaw)}`, + ); + process.exit(1); + } + const phaseDelta = actual - phaseBaseline; + console.log( + `[bundle-size] phase-local baseline=${phaseBaseline}B delta=${phaseDelta}B budget=${phaseBudget}B`, + ); + if (phaseDelta > phaseBudget) { + console.error( + `[bundle-size] FAIL: phase-local delta exceeds ${phaseBudget}B (${phaseDelta} > ${phaseBudget})`, + ); + process.exit(1); + } +} + +process.exit(0); diff --git a/apps/ccaudit/scripts/bundle-smoke-test.mjs b/apps/ccaudit/scripts/bundle-smoke-test.mjs new file mode 100644 index 0000000..af19714 --- /dev/null +++ b/apps/ccaudit/scripts/bundle-smoke-test.mjs @@ -0,0 +1,41 @@ +#!/usr/bin/env node +// bundle-smoke-test.mjs — D-03 verification: dist/index.js must load without unresolved imports. +// Run after `pnpm -w build`. Part of `pnpm verify` chain. +import { fileURLToPath } from 'node:url'; +import { resolve } from 'node:path'; +import { existsSync } from 'node:fs'; +import { spawnSync } from 'node:child_process'; + +const __filename = fileURLToPath(import.meta.url); +const distPath = resolve(__filename, '../../dist/index.js'); + +if (!existsSync(distPath)) { + console.error(`[bundle-smoke] FAIL: dist/index.js not found at ${distPath}`); + console.error('[bundle-smoke] Run `pnpm -w build` first.'); + process.exit(1); +} + +const result = spawnSync(process.execPath, [distPath, '--help'], { + encoding: 'utf8', + timeout: 10_000, +}); + +if (result.error) { + console.error('[bundle-smoke] FAIL: dist/index.js failed to spawn:'); + console.error(result.error); + process.exit(1); +} + +if (result.status !== 0) { + const reason = + result.signal !== null + ? `terminated by signal ${result.signal}` + : `exited with code ${result.status}`; + console.error(`[bundle-smoke] FAIL: dist/index.js --help ${reason}`); + if (result.stdout) console.error(result.stdout); + if (result.stderr) console.error(result.stderr); + process.exit(1); +} + +console.log('[bundle-smoke] dist/index.js --help exited 0 — bundle OK'); +process.exit(0); diff --git a/apps/ccaudit/src/__tests__/__fixtures__/regime-post-2-1-117/.claude.json b/apps/ccaudit/src/__tests__/__fixtures__/regime-post-2-1-117/.claude.json new file mode 100644 index 0000000..2f07298 --- /dev/null +++ b/apps/ccaudit/src/__tests__/__fixtures__/regime-post-2-1-117/.claude.json @@ -0,0 +1,17 @@ +{ + "mcpServers": { + "server-01": { "type": "stdio", "command": "noop", "args": [] }, + "server-02": { "type": "stdio", "command": "noop", "args": [] }, + "server-03": { "type": "stdio", "command": "noop", "args": [] }, + "server-04": { "type": "stdio", "command": "noop", "args": [] }, + "server-05": { "type": "stdio", "command": "noop", "args": [] }, + "server-06": { "type": "stdio", "command": "noop", "args": [] }, + "server-07": { "type": "stdio", "command": "noop", "args": [] }, + "server-08": { "type": "stdio", "command": "noop", "args": [] }, + "server-09": { "type": "stdio", "command": "noop", "args": [] }, + "server-10": { "type": "stdio", "command": "noop", "args": [] }, + "server-11": { "type": "stdio", "command": "noop", "args": [] }, + "server-12": { "type": "stdio", "command": "noop", "args": [] } + }, + "projects": {} +} diff --git a/apps/ccaudit/src/__tests__/__fixtures__/regime-post-2-1-117/.claude/settings.json b/apps/ccaudit/src/__tests__/__fixtures__/regime-post-2-1-117/.claude/settings.json new file mode 100644 index 0000000..9dfc303 --- /dev/null +++ b/apps/ccaudit/src/__tests__/__fixtures__/regime-post-2-1-117/.claude/settings.json @@ -0,0 +1,24 @@ +{ + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [{ "type": "command", "command": "echo post-2-1-117-fixture" }] + } + ] + }, + "mcpServers": { + "server-01": { "type": "stdio", "command": "noop", "args": [] }, + "server-02": { "type": "stdio", "command": "noop", "args": [] }, + "server-03": { "type": "stdio", "command": "noop", "args": [] }, + "server-04": { "type": "stdio", "command": "noop", "args": [] }, + "server-05": { "type": "stdio", "command": "noop", "args": [] }, + "server-06": { "type": "stdio", "command": "noop", "args": [] }, + "server-07": { "type": "stdio", "command": "noop", "args": [] }, + "server-08": { "type": "stdio", "command": "noop", "args": [] }, + "server-09": { "type": "stdio", "command": "noop", "args": [] }, + "server-10": { "type": "stdio", "command": "noop", "args": [] }, + "server-11": { "type": "stdio", "command": "noop", "args": [] }, + "server-12": { "type": "stdio", "command": "noop", "args": [] } + } +} diff --git a/apps/ccaudit/src/__tests__/__fixtures__/regime-pre-2-1-116/.claude.json b/apps/ccaudit/src/__tests__/__fixtures__/regime-pre-2-1-116/.claude.json new file mode 100644 index 0000000..2f07298 --- /dev/null +++ b/apps/ccaudit/src/__tests__/__fixtures__/regime-pre-2-1-116/.claude.json @@ -0,0 +1,17 @@ +{ + "mcpServers": { + "server-01": { "type": "stdio", "command": "noop", "args": [] }, + "server-02": { "type": "stdio", "command": "noop", "args": [] }, + "server-03": { "type": "stdio", "command": "noop", "args": [] }, + "server-04": { "type": "stdio", "command": "noop", "args": [] }, + "server-05": { "type": "stdio", "command": "noop", "args": [] }, + "server-06": { "type": "stdio", "command": "noop", "args": [] }, + "server-07": { "type": "stdio", "command": "noop", "args": [] }, + "server-08": { "type": "stdio", "command": "noop", "args": [] }, + "server-09": { "type": "stdio", "command": "noop", "args": [] }, + "server-10": { "type": "stdio", "command": "noop", "args": [] }, + "server-11": { "type": "stdio", "command": "noop", "args": [] }, + "server-12": { "type": "stdio", "command": "noop", "args": [] } + }, + "projects": {} +} diff --git a/apps/ccaudit/src/__tests__/__fixtures__/regime-pre-2-1-116/.claude/settings.json b/apps/ccaudit/src/__tests__/__fixtures__/regime-pre-2-1-116/.claude/settings.json new file mode 100644 index 0000000..deffac9 --- /dev/null +++ b/apps/ccaudit/src/__tests__/__fixtures__/regime-pre-2-1-116/.claude/settings.json @@ -0,0 +1,3 @@ +{ + "hooks": {} +} diff --git a/apps/ccaudit/src/__tests__/__fixtures__/restore-interactive/bust-2026-04-17T10-00-00-000Z-aaaa.jsonl b/apps/ccaudit/src/__tests__/__fixtures__/restore-interactive/bust-2026-04-17T10-00-00-000Z-aaaa.jsonl new file mode 100644 index 0000000..25302ed --- /dev/null +++ b/apps/ccaudit/src/__tests__/__fixtures__/restore-interactive/bust-2026-04-17T10-00-00-000Z-aaaa.jsonl @@ -0,0 +1,4 @@ +{"record_type":"header","manifest_version":1,"ccaudit_version":"1.5.0-test","checkpoint_ghost_hash":"deadbeef","checkpoint_timestamp":"2026-04-17T09:59:59.000Z","since_window":"30d","os":"darwin","node_version":"v20.0.0","planned_ops":{"archive":2,"disable":0,"flag":0},"selection_filter":{"mode":"subset","ids":["agent:{{TMPHOME}}/.claude/ccaudit/archived/agents/pencil-dev.md","agent:{{TMPHOME}}/.claude/ccaudit/archived/agents/pencil-review.md"]}} +{"op_id":"op-pencil-dev-subset","op_type":"archive","timestamp":"2026-04-17T10:00:00.000Z","status":"completed","category":"agent","scope":"global","source_path":"{{TMPHOME}}/.claude/agents/pencil-dev.md","archive_path":"{{TMPHOME}}/.claude/ccaudit/archived/agents/pencil-dev.md","content_sha256":"0000000000000000000000000000000000000000000000000000000000000001"} +{"op_id":"op-pencil-review-subset","op_type":"archive","timestamp":"2026-04-17T10:00:00.001Z","status":"completed","category":"agent","scope":"global","source_path":"{{TMPHOME}}/.claude/agents/pencil-review.md","archive_path":"{{TMPHOME}}/.claude/ccaudit/archived/agents/pencil-review.md","content_sha256":"0000000000000000000000000000000000000000000000000000000000000002"} +{"record_type":"footer","status":"completed","actual_ops":{"archive":{"completed":2,"failed":0},"disable":{"completed":0,"failed":0},"flag":{"completed":0,"failed":0,"refreshed":0,"skipped":0}},"duration_ms":42,"exit_code":0} diff --git a/apps/ccaudit/src/__tests__/__fixtures__/restore-interactive/bust-2026-04-18T10-00-00-000Z-bbbb.jsonl b/apps/ccaudit/src/__tests__/__fixtures__/restore-interactive/bust-2026-04-18T10-00-00-000Z-bbbb.jsonl new file mode 100644 index 0000000..a71bb95 --- /dev/null +++ b/apps/ccaudit/src/__tests__/__fixtures__/restore-interactive/bust-2026-04-18T10-00-00-000Z-bbbb.jsonl @@ -0,0 +1,5 @@ +{"record_type":"header","manifest_version":1,"ccaudit_version":"1.5.0-test","checkpoint_ghost_hash":"cafebabe","checkpoint_timestamp":"2026-04-18T09:59:59.000Z","since_window":"30d","os":"darwin","node_version":"v20.0.0","planned_ops":{"archive":2,"disable":0,"flag":1},"selection_filter":{"mode":"full"}} +{"op_id":"op-code-reviewer-full","op_type":"archive","timestamp":"2026-04-18T10:00:00.000Z","status":"completed","category":"agent","scope":"global","source_path":"{{TMPHOME}}/.claude/agents/code-reviewer.md","archive_path":"{{TMPHOME}}/.claude/ccaudit/archived/agents/code-reviewer.md","content_sha256":"0000000000000000000000000000000000000000000000000000000000000003"} +{"op_id":"op-pencil-review-dup","op_type":"archive","timestamp":"2026-04-18T10:00:00.001Z","status":"completed","category":"agent","scope":"global","source_path":"{{TMPHOME}}/.claude/agents/pencil-review.md","archive_path":"{{TMPHOME}}/.claude/ccaudit/archived/agents/pencil-review.md","content_sha256":"0000000000000000000000000000000000000000000000000000000000000002"} +{"op_id":"op-stale-memo-flag","op_type":"flag","timestamp":"2026-04-18T10:00:00.002Z","status":"completed","file_path":"{{TMPHOME}}/.claude/CLAUDE.md","scope":"global","had_frontmatter":false,"had_ccaudit_stale":false,"patched_keys":["ccaudit-stale","ccaudit-flagged"],"original_content_sha256":"0000000000000000000000000000000000000000000000000000000000000004"} +{"record_type":"footer","status":"completed","actual_ops":{"archive":{"completed":2,"failed":0},"disable":{"completed":0,"failed":0},"flag":{"completed":1,"failed":0,"refreshed":0,"skipped":0}},"duration_ms":38,"exit_code":0} diff --git a/apps/ccaudit/src/__tests__/_test-helpers.ts b/apps/ccaudit/src/__tests__/_test-helpers.ts index fdffe8c..19c82fc 100644 --- a/apps/ccaudit/src/__tests__/_test-helpers.ts +++ b/apps/ccaudit/src/__tests__/_test-helpers.ts @@ -1,9 +1,67 @@ /** - * Shared test helpers for ccaudit integration tests (Phase 0). - * tmpHome scaffolding + subprocess runner + JSONL reader. + * Shared test helpers for ccaudit integration tests (Phase 0 + Phase 3 + Phase 3.1). + * tmpHome scaffolding + subprocess runner + JSONL reader + Phase 3 fixtures + + * Phase 3.1 tabbed-picker key-injection helpers. */ -import { spawn } from 'node:child_process'; -import { mkdtemp, rm, readFile } from 'node:fs/promises'; +import { spawn, type ChildProcess } from 'node:child_process'; + +// ── TUI timing helpers ──────────────────────────────────────────────────── + +/** + * Wait for the spawned picker subprocess to reach its blocking read loop. + * Polls until the child exits (error/crash path), or until maxWaitMs elapses, + * with exponential backoff — whichever comes first. Then adds a final grace + * delay so the TUI is ready for key input. + * + * Typical path: child never exits during the poll window, loop runs until + * maxWaitMs, then a 300ms grace is added for the render cycle to settle. + */ +export async function waitForPicker(child: ChildProcess, graceMs = 300): Promise { + // Short poll to detect early crashes (child exiting prematurely). + const earlyExitMs = 1_000; + const startMs = Date.now(); + let delayMs = 50; + while (child.exitCode === null && Date.now() - startMs < earlyExitMs) { + await new Promise((r) => setTimeout(r, delayMs)); + delayMs = Math.min(delayMs * 2, 200); + } + // Grace period for the TUI to reach its blocking read. + if (child.exitCode === null) { + await new Promise((r) => setTimeout(r, graceMs)); + } +} + +/** + * Poll until the marker appears in stdout, the child exits, or maxWait elapses. + * THROWS if the marker never appeared (silent timeouts hide regressions). + * + * Accepts a string or RegExp as the marker so callers can assert on + * dynamic patterns (e.g. `tabbed-picker-force-partial-banner` tests). + */ +export async function waitForMarker( + getStdout: () => string, + isExited: () => boolean, + marker: string | RegExp, + maxWaitMs = 5_000, +): Promise { + const matches = (s: string) => (typeof marker === 'string' ? s.includes(marker) : marker.test(s)); + const startMs = Date.now(); + let delayMs = 100; + while (!isExited() && !matches(getStdout()) && Date.now() - startMs < maxWaitMs) { + await new Promise((r) => setTimeout(r, delayMs)); + delayMs = Math.min(delayMs * 2, 500); + } + if (!matches(getStdout())) { + const elapsedMs = Date.now() - startMs; + const stdout = getStdout(); + throw new Error( + `Timed out waiting for marker: ${String(marker)} after ${elapsedMs}ms ` + + `(exited=${String(isExited())})\nstdout tail:\n${stdout.slice(-2000)}`, + ); + } +} +import { mkdtemp, mkdir, rm, readFile, writeFile, chmod, utimes, readdir } from 'node:fs/promises'; +import { canonicalItemId } from '@ccaudit/internal'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -106,3 +164,639 @@ export async function readJsonl(filePath: string): Promise { .filter((line) => line.trim().length > 0) .map((line) => JSON.parse(line) as unknown); } + +// ── Phase 3 helpers ──────────────────────────────────────────────────────── + +const FAKE_PS_SCRIPT = `#!/bin/sh +# Fake ps used by ccaudit Phase 3 safety-invariant tests. +# Handles both \`ps -A -o pid=,comm=\` (system listing) and +# \`ps -o ppid= -p \` (parent chain walk). +case "$*" in + *-A*) + echo " 1 init" + ;; + *-o\\ ppid=*) + echo "1" + ;; + *) + echo " 1 init" + ;; +esac +`; + +/** Install a fake \`ps\` shim into /bin/ps. Returns the binDir path. */ +export async function buildFakePs(tmpHome: string): Promise { + const binDir = path.join(tmpHome, 'bin'); + await mkdir(binDir, { recursive: true }); + const psPath = path.join(binDir, 'ps'); + await writeFile(psPath, FAKE_PS_SCRIPT, 'utf8'); + await chmod(psPath, 0o755); + return binDir; +} + +/** Result type returned by the live ChildProcess from runCcauditGhost. */ +export interface SpawnedGhost { + child: ChildProcess; + /** Resolves when the child exits or is killed. */ + done: Promise; +} + +/** + * Spawn `ccaudit ghost ` as a subprocess. Unlike runCcauditCli (which + * resolves only after exit), this returns the live ChildProcess so callers + * can send signals (SIGINT for INV-S2). The `done` promise resolves with the + * usual {stdout, stderr, exitCode, durationMs} once the child exits. + * + * Default env: HOME, USERPROFILE, XDG_CONFIG_HOME, NO_COLOR=1, TZ=UTC, + * PATH=/bin (the fake-ps dir). Caller can overlay extra vars via opts.env. + */ +export function runCcauditGhost( + tmpHome: string, + flags: string[], + opts: RunOpts = {}, +): SpawnedGhost { + const start = Date.now(); + const child = spawn(process.execPath, [ccauditBin, 'ghost', ...flags], { + env: { + HOME: tmpHome, + USERPROFILE: tmpHome, + XDG_CONFIG_HOME: path.join(tmpHome, '.config'), + NO_COLOR: '1', + TZ: 'UTC', + PATH: path.join(tmpHome, 'bin'), + ...opts.env, + }, + cwd: opts.cwd ?? tmpHome, + stdio: ['pipe', 'pipe', 'pipe'], + }); + + let stdout = ''; + let stderr = ''; + let killed = false; + const ms = opts.timeout ?? 30_000; + + const done = new Promise((resolve, reject) => { + const timer = setTimeout(() => { + killed = true; + child.kill('SIGKILL'); + reject( + new Error( + `runCcauditGhost timed out after ${ms}ms\nstdout:\n${stdout.slice(-500)}\nstderr:\n${stderr.slice(-500)}`, + ), + ); + }, ms); + child.stdout!.on('data', (c: Buffer) => { + stdout += c.toString(); + }); + child.stderr!.on('data', (c: Buffer) => { + stderr += c.toString(); + }); + child.on('error', (err: Error) => { + clearTimeout(timer); + reject(err); + }); + child.on('close', (code: number | null) => { + clearTimeout(timer); + // Resolve in both killed and non-killed cases so callers always get output. + void killed; + resolve({ stdout, stderr, exitCode: code, durationMs: Date.now() - start }); + }); + }); + // Do NOT end stdin automatically — INV-S2 test may want to send signals first. + return { child, done }; +} + +/** + * Write a `~/.claude.json` containing exactly two MCP servers (serverA, serverB) + * under the global `mcpServers` nested schema. The serialized JSON deliberately + * uses 2-space indentation, a specific key order (serverA before serverB), and + * a trailing newline — so the byte-preservation invariant (INV-S1) is testable: + * a naive JSON.parse → JSON.stringify round-trip would NOT produce byte-identical + * output (key order / trailing newline differ). + * + * Returns the absolute path to the .claude.json file written. + */ +export async function createMcpFixture(tmpHome: string): Promise { + // Hand-crafted JSON with deliberate formatting: + // - 2-space indent + // - serverA defined BEFORE serverB (key order matters for byte-identity) + // - file ends with a newline (JSON.stringify omits this by default) + const body = + '{\n' + + ' "mcpServers": {\n' + + ' "serverA": {\n' + + ' "command": "npx",\n' + + ' "args": ["server-a"]\n' + + ' },\n' + + ' "serverB": {\n' + + ' "command": "npx",\n' + + ' "args": ["server-b", "--port", "9999"]\n' + + ' }\n' + + ' }\n' + + '}\n'; + const target = path.join(tmpHome, '.claude.json'); + await writeFile(target, body, 'utf8'); + return target; +} + +/** + * Build a fixture where the GSD framework is "partially-used": + * - 1 USED gsd-planner.md (recent mtime + session JSONL Task tool invocation) + * - 2 GHOST gsd-*.md (mtime 60 days ago → definite-ghost) + * - empty .claude.json + * - minimal session JSONL with a Task subagent_type='gsd-planner' invocation + * + * This setup makes the GSD framework's status='partially-used' so + * applyFrameworkProtection() locks the 2 ghosts unless --force-partial. + * + * Caller must invoke `await buildFakePs(tmpHome)` separately if subprocess + * tests need the running-Claude preflight to pass. + */ +export async function createFrameworkFixture(tmpHome: string): Promise { + const agentsDir = path.join(tmpHome, '.claude', 'agents'); + const xdgDir = path.join(tmpHome, '.config', 'claude'); + await mkdir(agentsDir, { recursive: true }); + await mkdir(xdgDir, { recursive: true }); + + // 1 USED gsd-planner — recent mtime + await writeFile(path.join(agentsDir, 'gsd-planner.md'), '# gsd-planner agent\n', 'utf8'); + + // 2 GHOST gsd-* — 60-day-old mtime + const sixtyDaysAgo = new Date(Date.now() - 60 * 86_400_000); + for (const name of ['gsd-researcher.md', 'gsd-verifier.md']) { + const p = path.join(agentsDir, name); + await writeFile(p, `# ${name}\n`, 'utf8'); + await utimes(p, sixtyDaysAgo, sixtyDaysAgo); + } + + // Empty .claude.json + await writeFile(path.join(tmpHome, '.claude.json'), '{}', 'utf8'); + + // Session JSONL with a Task subagent_type='gsd-planner' invocation + const sessionDir = path.join(tmpHome, '.claude', 'projects', 'fake-project'); + await mkdir(sessionDir, { recursive: true }); + const recentTs = new Date(Date.now() - 60 * 60 * 1000).toISOString(); + const sessionLines = [ + JSON.stringify({ + type: 'system', + subtype: 'init', + cwd: '/fake/project', + timestamp: recentTs, + sessionId: 'phase3-fwk', + }), + JSON.stringify({ + type: 'assistant', + timestamp: recentTs, + sessionId: 'phase3-fwk', + message: { + role: 'assistant', + content: [ + { + type: 'tool_use', + id: 't1', + name: 'Task', + input: { subagent_type: 'gsd-planner', prompt: 'plan something' }, + }, + ], + }, + }), + ]; + await writeFile(path.join(sessionDir, 'session-1.jsonl'), sessionLines.join('\n') + '\n', 'utf8'); +} + +/** + * Return the sorted basenames inside /.claude/ccaudit/manifests/. + * Returns [] if the directory does not exist (INV-S2 baseline + post-abort check). + */ +export async function listManifestsDir(tmpHome: string): Promise { + const dir = path.join(tmpHome, '.claude', 'ccaudit', 'manifests'); + try { + const entries = await readdir(dir); + return entries.sort(); + } catch { + return []; + } +} + +/** + * Read /.claude.json as raw bytes (NOT JSON.parse). INV-S1 asserts + * that the unselected MCP server's key + surrounding formatting are + * byte-identical post-bust, so the test must compare bytes, not parsed JSON. + */ +export function readMcpConfigBytes(tmpHome: string): Promise { + return readFile(path.join(tmpHome, '.claude.json')); +} + +/** Compute the canonical id for a global agent at /.claude/agents/. */ +export function agentItemId(tmpHome: string, fileName: string): string { + return canonicalItemId({ + name: path.basename(fileName, '.md'), + path: path.join(tmpHome, '.claude', 'agents', fileName), + scope: 'global', + category: 'agent', + projectPath: null, + }); +} + +/** + * Compute the canonical id for a global command .md file at + * `/.claude/commands/`, matching the scanner's + * resolveCommandName behavior (namespace-separator `:`). + * + * Example: + * commandItemId(tmpHome, 'sc/build.md', 'sc:build') + * → canonical id for {category:'command', scope:'global', name:'sc:build', path:} + */ +export function commandItemId(tmpHome: string, relPath: string, name: string): string { + return canonicalItemId({ + name, + path: path.join(tmpHome, '.claude', 'commands', relPath), + scope: 'global', + category: 'command', + projectPath: null, + }); +} + +// ── Phase 3.1 helpers (tabbed-picker integration tests) ─────────────────── + +/** + * Write key bytes to a spawned ccaudit picker's stdin with a small inter-key delay. + * + * The picker blocks on stdin inside @clack/core's readline loop; writes are processed + * asynchronously by the base class. A small delay between each keystroke lets the TUI + * re-render so the next key is applied to the post-render state. + * + * Key-byte reference: + * + * Tab → '\t' + * Shift-Tab → '\x1b[Z' + * ArrowUp → '\x1b[A' + * ArrowDown → '\x1b[B' + * ArrowRight → '\x1b[C' + * ArrowLeft → '\x1b[D' + * Enter → '\r' + * Space → ' ' + * Esc → '\x1b' + * Ctrl-C → '\x03' + * PageUp → '\x1b[5~' + * PageDown → '\x1b[6~' + * Home → '\x1b[H' + * End → '\x1b[F' + * + * @param child Live ChildProcess returned by {@link runCcauditGhost}. + * @param keys Ordered key byte sequences to transmit. + * @param delayMs Per-keystroke delay in milliseconds (default 75). + */ +export async function sendKeys( + child: ChildProcess, + keys: readonly string[], + delayMs = 75, +): Promise { + for (const k of keys) { + if (!child.stdin || child.stdin.destroyed) { + throw new Error('sendKeys: child stdin is not available or already destroyed'); + } + child.stdin.write(k); + await new Promise((r) => setTimeout(r, delayMs)); + } +} + +/** + * Scaffold N ghost agents under `/.claude/agents/` named + * `agent-01.md` through `agent-NN.md` (zero-padded to 2 digits). Also seeds: + * - empty `.claude.json` + * - one minimal session jsonl (so discoverSessionFiles returns ≥1 file) + * + * Caller is responsible for {@link buildFakePs} if the subprocess needs the + * running-Claude preflight to pass. + * + * Note: callers needing >99 ghosts should pass count ≤ 99 OR swap the `padStart(2,'0')` + * for a wider width. The 60-ghost overflow regression test (Phase 3.1 Plan 04 + * Task 2) uses 60, comfortably inside the two-digit range. + */ +export async function buildManyGhostsFixture(tmpHome: string, count: number): Promise { + if (count < 1 || count > 99) { + throw new Error(`buildManyGhostsFixture: count must be 1..99 (got ${count})`); + } + const agentsDir = path.join(tmpHome, '.claude', 'agents'); + await mkdir(agentsDir, { recursive: true }); + await mkdir(path.join(tmpHome, '.config', 'claude'), { recursive: true }); + + // N agents named agent-01..agent-NN with minimal content. + for (let i = 1; i <= count; i++) { + const name = `agent-${String(i).padStart(2, '0')}`; + await writeFile(path.join(agentsDir, `${name}.md`), `# ${name}\nunused\n`, 'utf8'); + } + + // Empty .claude.json so the scanner doesn't fail loading MCP servers. + await writeFile(path.join(tmpHome, '.claude.json'), '{}', 'utf8'); + + // Minimal session jsonl — discoverSessionFiles requires ≥1 file. + const sessionDir = path.join(tmpHome, '.claude', 'projects', 'many-ghosts-project'); + await mkdir(sessionDir, { recursive: true }); + const recentTs = new Date(Date.now() - 60 * 60 * 1000).toISOString(); + await writeFile( + path.join(sessionDir, 'session-1.jsonl'), + JSON.stringify({ + type: 'system', + subtype: 'init', + cwd: '/fake/many-ghosts', + timestamp: recentTs, + sessionId: 'many-ghosts-session', + }) + '\n', + 'utf8', + ); +} + +/** + * Compute the canonical id for a GLOBAL MCP server defined in /.claude.json + * under `mcpServers.`. Mirrors the scanner's MCP InventoryItem shape: + * path = /.claude.json (the source config file) + * scope = 'global', projectPath = null, name = serverName. + */ +export function mcpItemId(tmpHome: string, serverName: string): string { + return canonicalItemId({ + name: serverName, + path: path.join(tmpHome, '.claude.json'), + scope: 'global', + category: 'mcp-server', + projectPath: null, + }); +} + +// ── Phase 6 helpers (multi-framework + multi-config MCP fixtures) ───────── + +export interface MultiFrameworkSpec { + /** + * Curated framework prefix used to generate agent filenames + * (`-.md`). Must match a `KNOWN_FRAMEWORKS` id (e.g. `gsd` + * or `sc`) so the scanner attaches `framework: `. + */ + prefix: string; + /** Agent file suffixes that should be classified as USED. */ + usedMembers: readonly string[]; + /** Agent file suffixes that should be classified as GHOST (60d mtime). */ + ghostMembers: readonly string[]; + /** + * When the `prefix` is a session-tool name different from the agent + * subagent_type, override it here. Default is `-`. + */ + sessionSubagent?: string; +} + +/** + * Build a tmp home containing N curated-framework groups, each with mixed + * used/ghost membership so `applyFrameworkProtection` classifies them as + * `partially-used`. Designed for Phase 6 SC1/SC4 integration tests. + * + * Caller is responsible for {@link buildFakePs} if the subprocess needs the + * preflight to pass. + */ +export async function createMultiFrameworkFixture( + tmpHome: string, + frameworks: readonly MultiFrameworkSpec[], +): Promise { + const agentsDir = path.join(tmpHome, '.claude', 'agents'); + const xdgDir = path.join(tmpHome, '.config', 'claude'); + await mkdir(agentsDir, { recursive: true }); + await mkdir(xdgDir, { recursive: true }); + + const sixtyDaysAgo = new Date(Date.now() - 60 * 86_400_000); + const recentTs = new Date(Date.now() - 60 * 60 * 1000).toISOString(); + + // Per-framework used + ghost files, plus a per-framework session JSONL + // invoking the used subagent so the scanner classifies it as used. + const sessionLines: string[] = [ + JSON.stringify({ + type: 'system', + subtype: 'init', + cwd: '/fake/multi-fwk', + timestamp: recentTs, + sessionId: 'multi-fwk', + }), + ]; + + let toolCounter = 1; + for (const fw of frameworks) { + for (const suffix of fw.usedMembers) { + const name = `${fw.prefix}-${suffix}`; + await writeFile(path.join(agentsDir, `${name}.md`), `# ${name} used\n`, 'utf8'); + sessionLines.push( + JSON.stringify({ + type: 'assistant', + timestamp: recentTs, + sessionId: 'multi-fwk', + message: { + role: 'assistant', + content: [ + { + type: 'tool_use', + id: `t${toolCounter++}`, + name: 'Task', + input: { subagent_type: name, prompt: 'do work' }, + }, + ], + }, + }), + ); + } + for (const suffix of fw.ghostMembers) { + const name = `${fw.prefix}-${suffix}`; + const p = path.join(agentsDir, `${name}.md`); + await writeFile(p, `# ${name} ghost\n`, 'utf8'); + await utimes(p, sixtyDaysAgo, sixtyDaysAgo); + } + } + + await writeFile(path.join(tmpHome, '.claude.json'), '{}', 'utf8'); + + const sessionDir = path.join(tmpHome, '.claude', 'projects', 'multi-fwk'); + await mkdir(sessionDir, { recursive: true }); + await writeFile(path.join(sessionDir, 'session-1.jsonl'), sessionLines.join('\n') + '\n', 'utf8'); +} + +export interface MultiConfigMcpFixtureOpts { + /** Absolute path to the tmp HOME. */ + home: string; + /** + * Absolute project root (defaults to `/project`). A `.mcp.json` is + * written here when `alsoInProjectLocal` is true. + */ + projectRoot?: string; + /** MCP server key that will appear in multiple config files. */ + sharedKey: string; + /** When true, write `sharedKey` into `/.mcp.json`. */ + alsoInProjectLocal?: boolean; + /** + * When true, write `sharedKey` into `~/.claude/settings.json` under + * `mcpServers`. (Note: the ccaudit scanner reads `~/.claude.json`; we + * write to BOTH locations so the fixture matches the ticket text — the + * scanner walks `~/.claude.json`, which is the canonical user file here.) + */ + alsoInUser?: boolean; + /** + * Additional `.mcp.json` files at synthetic project roots. Each entry + * becomes another project dir with a `.mcp.json` containing `sharedKey`. + */ + extraProjectDirs?: readonly string[]; +} + +export interface MultiConfigMcpFixture { + /** Project roots to pass into `scanMcpServers(configPath, projectPaths)`. */ + projectPaths: string[]; + /** Absolute path to the root user claude config (`~/.claude.json`). */ + userConfigPath: string; +} + +/** + * Write one MCP `sharedKey` into 2+ config files so `scanMcpServers` sets + * `configRefs.length >= 2` on the emitted item. Used by Phase 6 SC3 (MCP + * multi-config warning hint) and the scanner-aggregation test. + * + * Writes the same server definition into every target config so the scanner + * treats them as genuinely shared. Each config is a well-formed JSON file. + */ +export async function createMultiConfigMcpFixture( + opts: MultiConfigMcpFixtureOpts, +): Promise { + const { home, sharedKey } = opts; + const projectRoot = opts.projectRoot ?? path.join(home, 'project'); + const alsoInProjectLocal = opts.alsoInProjectLocal ?? true; + const alsoInUser = opts.alsoInUser ?? true; + const extra = opts.extraProjectDirs ?? []; + + const serverDef = { command: 'npx', args: [sharedKey] }; + const projectPaths: string[] = []; + + // Root user config (~/.claude.json). Always written — this is the scanner's + // primary config source. + const userConfig: { mcpServers: Record } = { mcpServers: {} }; + if (alsoInUser) { + userConfig.mcpServers[sharedKey] = serverDef; + } + const userConfigPath = path.join(home, '.claude.json'); + await writeFile(userConfigPath, JSON.stringify(userConfig, null, 2) + '\n', 'utf8'); + + // Primary project root .mcp.json. + if (alsoInProjectLocal) { + await mkdir(projectRoot, { recursive: true }); + await writeFile( + path.join(projectRoot, '.mcp.json'), + JSON.stringify({ mcpServers: { [sharedKey]: serverDef } }, null, 2) + '\n', + 'utf8', + ); + projectPaths.push(projectRoot); + } + + // Additional project dirs with their own .mcp.json. + for (const rel of extra) { + const dir = path.isAbsolute(rel) ? rel : path.join(home, rel); + await mkdir(dir, { recursive: true }); + await writeFile( + path.join(dir, '.mcp.json'), + JSON.stringify({ mcpServers: { [sharedKey]: serverDef } }, null, 2) + '\n', + 'utf8', + ); + projectPaths.push(dir); + } + + return { projectPaths, userConfigPath }; +} + +// ── Phase 8 helpers (restore-interactive fixture) ───────────────────────── + +const RESTORE_INTERACTIVE_FIXTURE_DIR = path.join(here, '__fixtures__', 'restore-interactive'); + +/** + * Stage the v1.5 restore-interactive fixture into `tmpHome` (Plan 08-06, D8-19). + * + * Copies two seed JSONL manifests (a SUBSET bust archiving pencil-dev + + * pencil-review, then a newer FULL bust archiving code-reviewer + a duplicate + * pencil-review entry) into `/.claude/ccaudit/manifests/`, substituting + * the `{{TMPHOME}}` placeholder with the actual `tmpHome` absolute path so + * every `source_path` / `archive_path` / `selection_filter.ids` resolves + * correctly. + * + * Also populates `/.claude/ccaudit/archived/agents/` with the three + * archive files (pencil-dev.md, pencil-review.md, code-reviewer.md) so + * `executeRestore` actually has something to rename back to source paths. + * + * Sets per-file mtime so `discoverManifests` sorts the full (newer) bust + * first: that's the invariant dedupManifestOps relies on for newer-wins. + * + * Does NOT write `/.claude.json` or session JSONL — callers + * requiring the preflight's running-Claude check must invoke `buildFakePs` + * separately, and `.claude.json` is optional for `restore` (unused unless + * MCP disable ops appear in the manifest — these fixtures contain only + * archive ops). + */ +export async function stageRestoreInteractiveFixture(tmpHome: string): Promise { + const manifestsDir = path.join(tmpHome, '.claude', 'ccaudit', 'manifests'); + const archivedAgentsDir = path.join(tmpHome, '.claude', 'ccaudit', 'archived', 'agents'); + const claudeAgentsDir = path.join(tmpHome, '.claude', 'agents'); + await mkdir(manifestsDir, { recursive: true }); + await mkdir(archivedAgentsDir, { recursive: true }); + await mkdir(claudeAgentsDir, { recursive: true }); + + // Substitute {{TMPHOME}} placeholder in each fixture manifest, then write + // into the staged manifests dir. + const fixtureFiles = [ + 'bust-2026-04-17T10-00-00-000Z-aaaa.jsonl', + 'bust-2026-04-18T10-00-00-000Z-bbbb.jsonl', + ] as const; + for (const name of fixtureFiles) { + const src = path.join(RESTORE_INTERACTIVE_FIXTURE_DIR, name); + const raw = await readFile(src, 'utf8'); + const body = raw.split('{{TMPHOME}}').join(tmpHome); + await writeFile(path.join(manifestsDir, name), body, 'utf8'); + } + + // Force mtime ordering so `discoverManifests` sorts newer-first: + // the 2026-04-18 (full) bust must appear before the 2026-04-17 (subset). + const olderMtime = new Date('2026-04-17T10:00:00.000Z'); + const newerMtime = new Date('2026-04-18T10:00:00.000Z'); + await utimes( + path.join(manifestsDir, 'bust-2026-04-17T10-00-00-000Z-aaaa.jsonl'), + olderMtime, + olderMtime, + ); + await utimes( + path.join(manifestsDir, 'bust-2026-04-18T10-00-00-000Z-bbbb.jsonl'), + newerMtime, + newerMtime, + ); + + // Seed archive files that the manifests reference. Content is arbitrary + // (executeRestore moves by rename; sha256 is not re-verified on restore). + await writeFile( + path.join(archivedAgentsDir, 'pencil-dev.md'), + '# pencil-dev (archived)\n', + 'utf8', + ); + await writeFile( + path.join(archivedAgentsDir, 'pencil-review.md'), + '# pencil-review (archived)\n', + 'utf8', + ); + await writeFile( + path.join(archivedAgentsDir, 'code-reviewer.md'), + '# code-reviewer (archived)\n', + 'utf8', + ); + + // Seed a memory file referenced by a FlagOp in the full-bust manifest so + // the restore picker can surface and restore a MEMORY row. Some tests only + // need the item visible; the dedicated memory round-trip test also selects + // it, so the stub frontmatter includes the two ccaudit keys that restore + // is expected to remove. + await writeFile( + path.join(tmpHome, '.claude', 'CLAUDE.md'), + '---\nccaudit-stale: "2026-04-18T09:59:59Z"\nccaudit-flagged: "2026-04-18T09:59:59Z"\n---\n# memory (flagged)\n', + 'utf8', + ); +} + +/** Absolute path to the restore-interactive fixture source directory. */ +export function restoreInteractiveFixtureDir(): string { + return RESTORE_INTERACTIVE_FIXTURE_DIR; +} diff --git a/apps/ccaudit/src/__tests__/command-archival.test.ts b/apps/ccaudit/src/__tests__/command-archival.test.ts new file mode 100644 index 0000000..33d929a --- /dev/null +++ b/apps/ccaudit/src/__tests__/command-archival.test.ts @@ -0,0 +1,235 @@ +/** + * Phase 3.2 — Commands archival round-trip (SC1, SC3). + * + * Validates the Bug #1 fix: a command ghost selected via CCAUDIT_SELECT_IDS + * (the non-interactive analog of the picker's Space + Enter flow) is + * archived to ~/.claude/ccaudit/archived/commands/.md, the manifest + * records the op with category='command', and `ccaudit restore` reverses + * the move back to the source path. + * + * Pre-3.2 behavior: command ghosts fell through every buildChangePlan + * branch and were silently excluded. This test protects against regression. + */ +import { describe, it, expect, beforeAll, beforeEach, afterEach } from 'vitest'; +import { existsSync } from 'node:fs'; +import { mkdir, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + makeTmpHome, + cleanupTmpHome, + buildFakePs, + runCcauditCli, + commandItemId, +} from './_test-helpers.ts'; +import { readManifest } from '@ccaudit/internal'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const distPath = path.resolve(here, '..', '..', 'dist', 'index.js'); + +beforeAll(() => { + if (!existsSync(distPath)) { + throw new Error(`dist binary not found at ${distPath}. Run \`pnpm -F ccaudit build\` first.`); + } +}); + +describe.skipIf(process.platform === 'win32')('Phase 3.2 — command archival (SC1, SC3)', () => { + let tmpHome: string; + + beforeEach(async () => { + tmpHome = await makeTmpHome(); + await mkdir(path.join(tmpHome, '.claude', 'commands', 'sc'), { recursive: true }); + await mkdir(path.join(tmpHome, '.claude', 'agents'), { recursive: true }); + await mkdir(path.join(tmpHome, '.config', 'claude'), { recursive: true }); + + // Minimal session JSONL so discoverSessionFiles returns ≥1 file. + const sessionDir = path.join(tmpHome, '.claude', 'projects', 'fake-project'); + await mkdir(sessionDir, { recursive: true }); + await writeFile( + path.join(sessionDir, 'session-1.jsonl'), + JSON.stringify({ + type: 'system', + subtype: 'init', + cwd: '/fake/project', + timestamp: new Date(Date.now() - 60 * 60 * 1000).toISOString(), + sessionId: 'cmd-archival', + }) + '\n', + 'utf8', + ); + await writeFile(path.join(tmpHome, '.claude.json'), '{}', 'utf8'); + + // One ghost command (never referenced in any session → definite-ghost). + await writeFile( + path.join(tmpHome, '.claude', 'commands', 'sc', 'build.md'), + '---\nname: sc:build\ndescription: ghost command\n---\n# sc:build\n', + 'utf8', + ); + + // One ghost agent, so SC3 has a mixed selection to assert on. + await writeFile( + path.join(tmpHome, '.claude', 'agents', 'ghost-agent.md'), + '---\nname: ghost-agent\n---\n# ghost-agent\nunused\n', + 'utf8', + ); + + await buildFakePs(tmpHome); + }); + + afterEach(async () => { + await cleanupTmpHome(tmpHome); + }); + + function manifestPathFromEnvelope(stdout: string): string { + const parsed = JSON.parse(stdout) as Record; + const bust = parsed.bust as { manifestPath?: string } | undefined; + if (!bust?.manifestPath) { + throw new Error(`bust.manifestPath missing from JSON envelope: ${stdout.slice(0, 500)}`); + } + return bust.manifestPath; + } + + it('SC1: command selected via CCAUDIT_SELECT_IDS archives end-to-end and restores', async () => { + // Step 1: dry-run to write checkpoint + const dry = await runCcauditCli( + tmpHome, + ['ghost', '--dry-run', '--yes-proceed-busting', '--json'], + { + env: { + CCAUDIT_FORCE_TTY: '0', + PATH: `${path.join(tmpHome, 'bin')}:${process.env.PATH ?? ''}`, + }, + }, + ); + expect(dry.exitCode, `dry-run stderr: ${dry.stderr}`).toBe(0); + + // Step 2: subset-bust with ONLY the command selected. + const cmdId = commandItemId(tmpHome, 'sc/build.md', 'sc:build'); + const bust = await runCcauditCli( + tmpHome, + ['ghost', '--dangerously-bust-ghosts', '--yes-proceed-busting', '--json'], + { + env: { + CCAUDIT_SELECT_IDS: cmdId, + PATH: `${path.join(tmpHome, 'bin')}:${process.env.PATH ?? ''}`, + }, + }, + ); + expect(bust.exitCode, `bust stderr: ${bust.stderr}`).toBe(0); + + // Source .md gone, archive location populated. + expect(existsSync(path.join(tmpHome, '.claude', 'commands', 'sc', 'build.md'))).toBe(false); + const archivedDir = path.join(tmpHome, '.claude', 'ccaudit', 'archived', 'commands'); + expect(existsSync(archivedDir)).toBe(true); + const manifestPath = manifestPathFromEnvelope(bust.stdout); + const manifest = await readManifest(manifestPath); + expect(manifest.header!.planned_ops.archive).toBe(1); + const archiveOp = (manifest.ops ?? []).find( + (o: { op_type?: string }) => o.op_type === 'archive', + ) as + | { + op_type: 'archive'; + category: 'agent' | 'skill' | 'command'; + source_path: string; + archive_path: string; + } + | undefined; + expect(archiveOp).toBeDefined(); + expect(archiveOp!.category).toBe('command'); + expect(archiveOp!.source_path).toBe( + path.join(tmpHome, '.claude', 'commands', 'sc', 'build.md'), + ); + expect(existsSync(archiveOp!.archive_path)).toBe(true); + + // Step 3: restore, assert source reinstated. + const restore = await runCcauditCli(tmpHome, ['restore', '--json'], { + env: { PATH: `${path.join(tmpHome, 'bin')}:${process.env.PATH ?? ''}` }, + }); + expect(restore.exitCode, `restore stderr: ${restore.stderr}`).toBe(0); + expect(existsSync(path.join(tmpHome, '.claude', 'commands', 'sc', 'build.md'))).toBe(true); + expect(existsSync(archiveOp!.archive_path)).toBe(false); + }); + + it('SC3a: --dry-run --json envelope counts.commands is populated', async () => { + const dry = await runCcauditCli( + tmpHome, + ['ghost', '--dry-run', '--yes-proceed-busting', '--json'], + { + env: { + CCAUDIT_FORCE_TTY: '0', + PATH: `${path.join(tmpHome, 'bin')}:${process.env.PATH ?? ''}`, + }, + }, + ); + expect(dry.exitCode, `dry-run stderr: ${dry.stderr}`).toBe(0); + const dryEnvelope = JSON.parse(dry.stdout) as Record; + const dryPlan = dryEnvelope.changePlan as { counts?: Record } | undefined; + expect(dryPlan?.counts).toBeDefined(); + expect(typeof dryPlan!.counts!.commands).toBe('number'); + expect(dryPlan!.counts!.commands).toBeGreaterThanOrEqual(1); + }); + + it('M5: full-bust healthAfter > healthBefore when ghost commands are archived', async () => { + // Step 1: dry-run checkpoint (no CCAUDIT_SELECT_IDS → full plan) + const dry = await runCcauditCli( + tmpHome, + ['ghost', '--dry-run', '--yes-proceed-busting', '--json'], + { + env: { + CCAUDIT_FORCE_TTY: '0', + PATH: `${path.join(tmpHome, 'bin')}:${process.env.PATH ?? ''}`, + }, + }, + ); + expect(dry.exitCode, `dry-run stderr: ${dry.stderr}`).toBe(0); + + // Step 2: full-bust (no CCAUDIT_SELECT_IDS) + const bust = await runCcauditCli( + tmpHome, + ['ghost', '--dangerously-bust-ghosts', '--yes-proceed-busting', '--json'], + { + env: { + CCAUDIT_FORCE_TTY: '0', + PATH: `${path.join(tmpHome, 'bin')}:${process.env.PATH ?? ''}`, + }, + }, + ); + expect(bust.exitCode, `bust stderr: ${bust.stderr}`).toBe(0); + + const envelope = JSON.parse(bust.stdout) as { + bust: { + status: string; + summary: { healthBefore: number; healthAfter: number }; + }; + }; + expect(envelope.bust.status).toBe('success'); + // M5: healthAfter must be strictly better than healthBefore after archiving ghosts + // (previously full-bust left command entries in remainingEnriched, keeping + // healthAfter artificially low and equal to healthBefore). + expect(envelope.bust.summary.healthAfter).toBeGreaterThan(envelope.bust.summary.healthBefore); + }); + + it('SC3b: --dry-run text-mode output contains a tight commands row matching the locked regex', async () => { + // Produces the renderChangePlan text output (Plan 01 Task 4 extended this + // renderer to emit a `N commands → ~/.claude/ccaudit/archived/commands/` row). + // This regex is deliberately tight so future drift is caught — the label must + // be "commands", singular or plural, the arrow must be → or -> (NO_COLOR + // equivalence), and the destination must be ~/.claude/ccaudit/archived/commands/. + const dryText = await runCcauditCli(tmpHome, ['ghost', '--dry-run', '--yes-proceed-busting'], { + env: { + CCAUDIT_FORCE_TTY: '0', + PATH: `${path.join(tmpHome, 'bin')}:${process.env.PATH ?? ''}`, + }, + }); + expect(dryText.exitCode, `dry-run text stderr: ${dryText.stderr}`).toBe(0); + // Combined stream — renderChangePlan goes to stdout; we search stdout+stderr + // to survive any future relocation of the dry-run ceremony. + const combined = `${dryText.stdout}\n${dryText.stderr}`; + // Tight regex (B3 fix): require the `commands` token adjacent to the archived/commands/ path. + // Test asserts the literal `~/.claude/ccaudit/archived/commands/` as the locked path shape. + expect(combined).toMatch( + /\b1 commands?\b\s+(→|->)\s+~\/\.claude\/ccaudit\/archived\/commands\//, + ); + // Also assert the surrounding ARCHIVE block header is present — sanity. + expect(combined).toContain('Will ARCHIVE'); + }); +}); diff --git a/apps/ccaudit/src/__tests__/empty-inventory.test.ts b/apps/ccaudit/src/__tests__/empty-inventory.test.ts new file mode 100644 index 0000000..592251a --- /dev/null +++ b/apps/ccaudit/src/__tests__/empty-inventory.test.ts @@ -0,0 +1,101 @@ +/** + * Phase 9 D1 / SC1 — empty-inventory short-circuit. + * + * When the ghost scan finds zero ghosts across all categories: + * - `ccaudit ghost --interactive` exits 0, prints a single clean line + * to stdout, and writes no manifest. + * - `ccaudit ghost` (plain) exits 0 with no auto-open prompt in stdout. + * + * Fixture: tmp HOME with no agents, no skills, no MCP servers, no memory, + * no commands, no hooks. Minimal session JSONL so the scanner's session + * discovery does not crash, and fake `ps` so the TUI preflight passes. + */ +import { describe, it, expect, beforeAll, beforeEach, afterEach } from 'vitest'; +import { existsSync } from 'node:fs'; +import { mkdir, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + makeTmpHome, + cleanupTmpHome, + buildFakePs, + runCcauditGhost, + listManifestsDir, +} from './_test-helpers.ts'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const distPath = path.resolve(here, '..', '..', 'dist', 'index.js'); + +beforeAll(() => { + if (!existsSync(distPath)) { + throw new Error( + `dist binary not found at ${distPath}. Run \`pnpm -F ccaudit build\` before running this test.`, + ); + } +}); + +async function stageEmptyHome(tmpHome: string): Promise { + // Empty .claude scaffolding — no agents / skills / memory / commands / hooks. + await mkdir(path.join(tmpHome, '.claude', 'agents'), { recursive: true }); + await mkdir(path.join(tmpHome, '.claude', 'skills'), { recursive: true }); + await mkdir(path.join(tmpHome, '.config', 'claude'), { recursive: true }); + // Minimal session JSONL so discoverSessionFiles returns ≥1 file. + const sessionDir = path.join(tmpHome, '.claude', 'projects', 'empty'); + await mkdir(sessionDir, { recursive: true }); + await writeFile( + path.join(sessionDir, 'session-1.jsonl'), + JSON.stringify({ + type: 'system', + subtype: 'init', + cwd: '/fake/empty', + timestamp: new Date(Date.now() - 60 * 60 * 1000).toISOString(), + sessionId: 'empty-inventory', + }) + '\n', + 'utf8', + ); + // Empty .claude.json so MCP scanner loads cleanly with zero servers. + await writeFile(path.join(tmpHome, '.claude.json'), '{}', 'utf8'); + await buildFakePs(tmpHome); +} + +describe.skipIf(process.platform === 'win32')( + 'Phase 9 SC1 — empty inventory short-circuits --interactive and silences auto-open', + () => { + let tmpHome: string; + + beforeEach(async () => { + tmpHome = await makeTmpHome(); + await stageEmptyHome(tmpHome); + }); + + afterEach(async () => { + await cleanupTmpHome(tmpHome); + }); + + it('`ghost --interactive` on empty inventory: exit 0, clean message, no manifest', async () => { + const spawned = runCcauditGhost(tmpHome, ['--interactive'], { + env: { CCAUDIT_FORCE_TTY: '1' }, + timeout: 15_000, + }); + spawned.child.stdin?.end(); + const result = await spawned.done; + + expect(result.exitCode, `stderr:\n${result.stderr}`).toBe(0); + expect(result.stdout).toContain('Inventory is clean'); + expect(await listManifestsDir(tmpHome)).toEqual([]); + }); + + it('plain `ghost` on empty inventory: exit 0, no auto-open prompt', async () => { + const spawned = runCcauditGhost(tmpHome, [], { + env: { CCAUDIT_FORCE_TTY: '1' }, + timeout: 15_000, + }); + spawned.child.stdin?.end(); + const result = await spawned.done; + + expect(result.exitCode, `stderr:\n${result.stderr}`).toBe(0); + expect(result.stdout).not.toMatch(/open interactive picker/i); + expect(result.stdout).not.toContain('[Y/n]'); + }); + }, +); diff --git a/apps/ccaudit/src/__tests__/fixtures/ghost-500-items.ts b/apps/ccaudit/src/__tests__/fixtures/ghost-500-items.ts new file mode 100644 index 0000000..88d68ed --- /dev/null +++ b/apps/ccaudit/src/__tests__/fixtures/ghost-500-items.ts @@ -0,0 +1,74 @@ +/** + * Phase 9 Plan 02 (D3 / SC3) — 500+ synthetic ghost fixture factory. + * + * Produces deterministic `TokenCostResult[]` for pagination tests. No file + * I/O, no filesystem state — the picker is driven in-process. + * + * Names are zero-padded within the supported 1..999 count range, tokens + * decrement by 1 each (1000, 999, …) so sort-by-tokens is unambiguous. Mtime + * is a fixed epoch so staleness logic is deterministic. + */ + +import type { TokenCostResult } from '@ccaudit/internal'; + +export interface BuildGhosts500Opts { + /** Defaults to 500. Must be ≥ 1. */ + count?: number; + /** Fixed mtimeMs baseline — offsets by i so each item has a unique mtime. */ + baseMtimeMs?: number; + /** Name prefix. Defaults to 'agent'. */ + prefix?: string; + /** Category. Defaults to 'agent'. */ + category?: TokenCostResult['item']['category']; +} + +/** + * Build N synthetic ghosts with zero-padded 3-digit names. 500 fits in 3 + * digits (001..500); the fixture rejects count > 999 to keep the naming + * scheme stable. + */ +export function buildGhosts500(opts: BuildGhosts500Opts = {}): TokenCostResult[] { + const count = opts.count ?? 500; + if (count < 1 || count > 999) { + throw new Error(`buildGhosts500: count must be 1..999 (got ${count})`); + } + const baseMtime = opts.baseMtimeMs ?? Date.UTC(2026, 0, 1); + const prefix = opts.prefix ?? 'agent'; + const category = opts.category ?? 'agent'; + const out: TokenCostResult[] = []; + for (let i = 1; i <= count; i++) { + const name = `${prefix}-${String(i).padStart(3, '0')}`; + out.push({ + item: { + name, + category, + scope: 'global', + projectPath: null, + path: `/fake/${categoryDir(category)}/${name}.md`, + mtimeMs: baseMtime + i, // unique per item + }, + tier: 'definite-ghost', + lastUsed: null, + invocationCount: 0, + tokenEstimate: { tokens: 1000 - i, confidence: 'estimated', source: 'test' }, + }); + } + return out; +} + +function categoryDir(category: TokenCostResult['item']['category']): string { + switch (category) { + case 'agent': + return 'agents'; + case 'skill': + return 'skills'; + case 'command': + return 'commands'; + case 'mcp-server': + return 'mcp'; + case 'memory': + return 'memory'; + case 'hook': + return 'hooks'; + } +} diff --git a/apps/ccaudit/src/__tests__/fixtures/manual-qa-followups.md b/apps/ccaudit/src/__tests__/fixtures/manual-qa-followups.md new file mode 100644 index 0000000..61a430a --- /dev/null +++ b/apps/ccaudit/src/__tests__/fixtures/manual-qa-followups.md @@ -0,0 +1,41 @@ +# Manual QA follow-up fixtures + +These fixtures support regressions found during v1.5 manual QA after Phase 10. +They are intentionally filesystem-based so future tests can drive the real CLI +and TTY picker with a disposable `HOME`. + +Import from: + +```ts +import { + stageAlreadyPurgedFixture, + stageGlyphFixture, + stageInteractiveBustFixture, + stagePaginationFixture, + stagePurgeMixedFixture, +} from './fixtures/manual-qa-followups.ts'; +``` + +## Fixture map + +| Manual row | Builder | Purpose | +| ---------------- | --------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | +| Phase 9 G6 | `stageAlreadyPurgedFixture(tmpHome)` | Starts from a post-purge state with `archive_purge` follow-ups. `purge-archive --yes` should be idempotent/no-op. | +| Phase 9 G1/G3/G5 | `stagePurgeMixedFixture(tmpHome)` | Mixed archive classifier: reclaim, source-occupied drop, stale-missing drop, both-missing skip. | +| Phase 9 G4 | `stagePurgeMixedFixture(tmpHome, { includeFlagAndDisableOps: true })` | Adds a memory flag op and MCP disable op. Purge should touch archive ops only. | +| Phase 9 E1/E4 | `stageGlyphFixture(tmpHome)` | Creates selected/unselected-capable rows, protected framework row, multi-config MCP row, stale memory row. | +| Phase 9 D1/D2 | `stagePaginationFixture(tmpHome, 550)` | Real 550-agent HOME for TTY pagination/filter/sort tests. | +| Phase 9 H2 | `stageInteractiveBustFixture(tmpHome)` | Minimal real `ghost -i` archive then `restore --name h2-solo` smoke fixture. | + +## Notes + +- Always set `HOME`, `USERPROFILE`, and `XDG_CONFIG_HOME` to the temp HOME when + spawning the CLI. +- For interactive tests, use `CCAUDIT_FORCE_TTY=1` only when the test harness + intentionally simulates a TTY. Real tmux/pty tests should not need it. +- `stageGlyphFixture()` returns `{ projectRoot }`; run the CLI with `cwd` set to + that project root to ensure project-local `.mcp.json` participates in + multi-config MCP detection. +- `stageInteractiveBustFixture()` installs a fake `ps` shim in `/bin`; + prepend that directory to `PATH` so the running-Claude preflight does not see + the current development session. diff --git a/apps/ccaudit/src/__tests__/fixtures/manual-qa-followups.ts b/apps/ccaudit/src/__tests__/fixtures/manual-qa-followups.ts new file mode 100644 index 0000000..5e6dc88 --- /dev/null +++ b/apps/ccaudit/src/__tests__/fixtures/manual-qa-followups.ts @@ -0,0 +1,422 @@ +/** + * Manual QA follow-up fixture builders for v1.5 Phase 9/10 ship-gate gaps. + * + * These helpers intentionally create disposable HOME trees on disk. They are + * meant for regression tests that drive the real CLI/TUI after manual QA found + * mismatches in: + * - purge-archive idempotency (G6) + * - purge-archive ignoring flag + MCP disable ops (G4) + * - glyph rendering / help legend coverage (E1/E4) + * - 500+ item pagination + filter Esc behavior (D1/D2) + * - interactive bust -> restore smoke (H2) + * + * Callers supply `tmpHome` (usually from makeTmpHome()). All paths are written + * under that HOME only; no real ~/.claude state is touched. + */ +import { chmod, mkdir, unlink, utimes, writeFile } from 'node:fs/promises'; +import path from 'node:path'; + +const OLD_DATE = new Date('2020-01-01T00:00:00.000Z'); + +export const MANUAL_QA_PROJECT_ROOT = 'fixture-project'; + +export interface ManualQaFixturePaths { + home: string; + projectRoot: string; +} + +export interface ArchiveOpSpec { + /** Agent name used for source/archive `.md`. */ + name: string; + /** Whether the archive file exists on disk. */ + archiveOnDisk: boolean; + /** Whether the original source file exists on disk. */ + sourceOnDisk: boolean; +} + +export const PURGE_MIXED_SPECS: readonly ArchiveOpSpec[] = [ + // A: archive exists, source free -> reclaim candidate. + { name: 'a-reclaim', archiveOnDisk: true, sourceOnDisk: false }, + // B: archive exists, source occupied -> drop/source_occupied candidate. + { name: 'b-occupied', archiveOnDisk: true, sourceOnDisk: true }, + // C: archive missing, source exists -> stale_archive_missing candidate. + { name: 'c-stale', archiveOnDisk: false, sourceOnDisk: true }, + // D: archive missing, source missing -> both_missing broken-state skip. + { name: 'd-broken', archiveOnDisk: false, sourceOnDisk: false }, +] as const; + +export async function stageFakePs(tmpHome: string): Promise { + const binDir = path.join(tmpHome, 'bin'); + await mkdir(binDir, { recursive: true }); + const psPath = path.join(binDir, 'ps'); + await writeFile( + psPath, + `#!/bin/sh +case "$*" in + *-A*) echo " 1 init" ;; + *-o\\ ppid=*) echo "1" ;; + *) echo " 1 init" ;; +esac +`, + 'utf8', + ); + await chmod(psPath, 0o755); + return binDir; +} + +async function writeRecentSession(tmpHome: string, cwd: string): Promise { + const sessionDir = path.join(tmpHome, '.claude', 'projects', 'manual-qa'); + await mkdir(sessionDir, { recursive: true }); + const line = JSON.stringify({ + type: 'system', + subtype: 'init', + cwd, + timestamp: new Date().toISOString(), + sessionId: 'manual-qa-fixture', + }); + await writeFile(path.join(sessionDir, 'session-1.jsonl'), `${line}\n`, 'utf8'); +} + +async function writeTaskSession(tmpHome: string, cwd: string, subagentType: string): Promise { + const sessionDir = path.join(tmpHome, '.claude', 'projects', 'manual-qa'); + await mkdir(sessionDir, { recursive: true }); + const line = JSON.stringify({ + type: 'assistant', + timestamp: new Date().toISOString(), + sessionId: 'manual-qa-framework', + message: { + role: 'assistant', + content: [ + { + type: 'tool_use', + id: 'toolu_manual_qa_1', + name: 'Task', + input: { subagent_type: subagentType, prompt: 'fixture invocation' }, + }, + ], + }, + }); + // Include an init line too so project-root discovery has a cwd anchor. + const init = JSON.stringify({ + type: 'system', + subtype: 'init', + cwd, + timestamp: new Date().toISOString(), + sessionId: 'manual-qa-framework', + }); + await writeFile(path.join(sessionDir, 'session-1.jsonl'), `${init}\n${line}\n`, 'utf8'); +} + +function manifestsDir(tmpHome: string): string { + return path.join(tmpHome, '.claude', 'ccaudit', 'manifests'); +} + +function archivedAgentsDir(tmpHome: string): string { + return path.join(tmpHome, '.claude', 'ccaudit', 'archived', '.claude', 'agents'); +} + +function agentsDir(tmpHome: string): string { + return path.join(tmpHome, '.claude', 'agents'); +} + +function sourcePath(tmpHome: string, name: string): string { + return path.join(agentsDir(tmpHome), `${name}.md`); +} + +function archivePath(tmpHome: string, name: string): string { + return path.join(archivedAgentsDir(tmpHome), `${name}.md`); +} + +/** + * Stage the mixed purge classifier fixture used by Phase 9 G1/G3/G5/G6. + * + * By default it writes archive ops only. Set `includeFlagAndDisableOps` for + * Phase 9 G4 coverage: purge must reclaim/drop archive ops while leaving memory + * frontmatter flags and disabled MCP keys untouched. + */ +export async function stagePurgeMixedFixture( + tmpHome: string, + opts: { includeFlagAndDisableOps?: boolean } = {}, +): Promise { + await mkdir(manifestsDir(tmpHome), { recursive: true }); + await mkdir(archivedAgentsDir(tmpHome), { recursive: true }); + await mkdir(agentsDir(tmpHome), { recursive: true }); + await mkdir(path.join(tmpHome, '.claude'), { recursive: true }); + + const archiveOps = PURGE_MIXED_SPECS.map((spec) => ({ + op_id: `op-${spec.name}`, + op_type: 'archive', + timestamp: '2026-04-22T09:00:00.000Z', + status: 'completed', + category: 'agent', + scope: 'global', + source_path: sourcePath(tmpHome, spec.name), + archive_path: archivePath(tmpHome, spec.name), + content_sha256: '0'.repeat(64), + })); + + const extraOps: unknown[] = []; + if (opts.includeFlagAndDisableOps === true) { + const memoryPath = path.join(tmpHome, '.claude', 'CLAUDE.md'); + await writeFile( + memoryPath, + [ + '---', + 'ccaudit-stale: "2026-04-22T09:00:00.000Z"', + 'ccaudit-flagged: "2026-04-22T09:00:00.000Z"', + '---', + '# flagged memory fixture', + '', + ].join('\n'), + 'utf8', + ); + + const claudeJsonPath = path.join(tmpHome, '.claude.json'); + await writeFile( + claudeJsonPath, + JSON.stringify( + { + mcpServers: { + 'ccaudit-disabled:serverA': { command: 'npx', args: ['server-a'] }, + }, + }, + null, + 2, + ) + '\n', + 'utf8', + ); + + extraOps.push( + { + op_id: 'op-memory-flag', + op_type: 'flag', + timestamp: '2026-04-22T09:00:00.001Z', + status: 'completed', + file_path: memoryPath, + scope: 'global', + had_frontmatter: false, + had_ccaudit_stale: false, + patched_keys: ['ccaudit-stale', 'ccaudit-flagged'], + original_content_sha256: '1'.repeat(64), + }, + { + op_id: 'op-mcp-disable', + op_type: 'disable', + timestamp: '2026-04-22T09:00:00.002Z', + status: 'completed', + config_path: claudeJsonPath, + scope: 'global', + project_path: null, + original_key: 'serverA', + new_key: 'ccaudit-disabled:serverA', + original_value: { command: 'npx', args: ['server-a'] }, + }, + ); + } else { + await writeFile(path.join(tmpHome, '.claude.json'), '{}\n', 'utf8'); + } + + const header = { + record_type: 'header', + manifest_version: 1, + ccaudit_version: '1.5.0-test', + checkpoint_ghost_hash: 'manual-qa-purge-fixture', + checkpoint_timestamp: '2026-04-22T09:00:00.000Z', + since_window: '30d', + os: 'darwin', + node_version: 'v20.0.0', + planned_ops: { + archive: archiveOps.length, + disable: opts.includeFlagAndDisableOps === true ? 1 : 0, + flag: opts.includeFlagAndDisableOps === true ? 1 : 0, + }, + selection_filter: { mode: 'full' }, + }; + const footer = { + record_type: 'footer', + status: 'completed', + actual_ops: { + archive: { completed: archiveOps.length, failed: 0 }, + disable: { completed: opts.includeFlagAndDisableOps === true ? 1 : 0, failed: 0 }, + flag: { + completed: opts.includeFlagAndDisableOps === true ? 1 : 0, + failed: 0, + refreshed: 0, + skipped: 0, + }, + }, + duration_ms: 1, + exit_code: 0, + }; + + const records = [header, ...archiveOps, ...extraOps, footer]; + await writeFile( + path.join(manifestsDir(tmpHome), 'bust-2026-04-22T09-00-00-000Z-manual-qa.jsonl'), + records.map((r) => JSON.stringify(r)).join('\n') + '\n', + 'utf8', + ); + + for (const spec of PURGE_MIXED_SPECS) { + if (spec.archiveOnDisk) { + await writeFile(archivePath(tmpHome, spec.name), `# ${spec.name} archived\n`, 'utf8'); + } + if (spec.sourceOnDisk) { + await writeFile(sourcePath(tmpHome, spec.name), `# ${spec.name} source\n`, 'utf8'); + } + } +} + +/** + * Stage the post-first-purge state directly: original archive ops remain in the + * bust manifest, a purge manifest contains archive_purge follow-ups, and disk + * state matches a completed purge. `purge-archive --yes` should no-op here. + */ +export async function stageAlreadyPurgedFixture(tmpHome: string): Promise { + await stagePurgeMixedFixture(tmpHome); + + // Remove the archive files for a-reclaim and b-occupied so the disk state + // actually matches a completed purge (M1: archives must be absent). + for (const name of ['a-reclaim', 'b-occupied'] as const) { + try { + await unlink(archivePath(tmpHome, name)); + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err; + } + } + + // Disk state after a successful purge: A/B archives gone, A source restored, + // B/C sources present, D remains both-missing. + await writeFile(sourcePath(tmpHome, 'a-reclaim'), '# a-reclaim reclaimed\n', 'utf8'); + + const purgeHeader = { + record_type: 'header', + manifest_version: 1, + ccaudit_version: '1.5.0-test', + checkpoint_ghost_hash: 'manual-qa-purge-followup', + checkpoint_timestamp: '2026-04-22T09:01:00.000Z', + since_window: '30d', + os: 'darwin', + node_version: 'v20.0.0', + planned_ops: { archive: 0, disable: 0, flag: 0 }, + selection_filter: { mode: 'full' }, + }; + const purgeOps = [ + { original_op_id: 'op-a-reclaim', reason: 'reclaimed' }, + { original_op_id: 'op-b-occupied', reason: 'source_occupied' }, + { original_op_id: 'op-c-stale', reason: 'stale_archive_missing' }, + ].map((op, i) => ({ + op_id: `purge-${i + 1}`, + op_type: 'archive_purge', + timestamp: '2026-04-22T09:01:00.000Z', + status: 'completed', + original_op_id: op.original_op_id, + purged: true, + reason: op.reason, + })); + const purgeFooter = { + record_type: 'footer', + status: 'completed', + actual_ops: { + archive: { completed: 0, failed: 0 }, + disable: { completed: 0, failed: 0 }, + flag: { completed: 0, failed: 0, refreshed: 0, skipped: 0 }, + }, + duration_ms: 1, + exit_code: 0, + }; + + await writeFile( + path.join(manifestsDir(tmpHome), 'purge-2026-04-22T09-01-00-000Z-manual-qa.jsonl'), + [purgeHeader, ...purgeOps, purgeFooter].map((r) => JSON.stringify(r)).join('\n') + '\n', + 'utf8', + ); +} + +/** + * Fixture for glyph/manual TUI coverage. It contains: + * - selected/unselected-capable agent rows + * - a partially-used GSD framework member (protected without --force-partial) + * - multi-config MCP server (`shared`) in ~/.claude.json and project .mcp.json + * - stale memory file + * + * Run the CLI with cwd set to `projectRoot` so project-local MCP discovery is + * in scope. Use `--force-partial` when you want selectable framework rows; omit + * it when asserting the lock/protected glyph. + */ +export async function stageGlyphFixture(tmpHome: string): Promise { + const projectRoot = path.join(tmpHome, MANUAL_QA_PROJECT_ROOT); + await mkdir(path.join(tmpHome, '.claude', 'agents'), { recursive: true }); + await mkdir(projectRoot, { recursive: true }); + + const usedAgent = path.join(tmpHome, '.claude', 'agents', 'gsd-planner.md'); + const protectedGhost = path.join(tmpHome, '.claude', 'agents', 'gsd-researcher.md'); + const soloGhost = path.join(tmpHome, '.claude', 'agents', 'solo.md'); + await writeFile(usedAgent, '# gsd-planner used\n', 'utf8'); + await writeFile(protectedGhost, '# gsd-researcher ghost\n', 'utf8'); + await writeFile(soloGhost, '# solo ghost\n', 'utf8'); + await utimes(protectedGhost, OLD_DATE, OLD_DATE); + await utimes(soloGhost, OLD_DATE, OLD_DATE); + + const memoryPath = path.join(tmpHome, '.claude', 'CLAUDE.md'); + await writeFile(memoryPath, '# stale memory\n', 'utf8'); + await utimes(memoryPath, OLD_DATE, OLD_DATE); + + await writeTaskSession(tmpHome, projectRoot, 'gsd-planner'); + + const sharedServer = { command: 'npx', args: ['shared'] }; + await writeFile( + path.join(tmpHome, '.claude.json'), + JSON.stringify( + { + mcpServers: { + shared: sharedServer, + single: { command: 'npx', args: ['single'] }, + }, + }, + null, + 2, + ) + '\n', + 'utf8', + ); + await writeFile( + path.join(projectRoot, '.mcp.json'), + JSON.stringify({ mcpServers: { shared: sharedServer } }, null, 2) + '\n', + 'utf8', + ); + + return { home: tmpHome, projectRoot }; +} + +/** Build a real filesystem 500+ ghost inventory for TTY pagination tests. */ +export async function stagePaginationFixture(tmpHome: string, count = 550): Promise { + if (count < 1 || count > 999) { + throw new Error(`stagePaginationFixture: count must be 1..999 (got ${count})`); + } + await mkdir(path.join(tmpHome, '.claude', 'agents'), { recursive: true }); + const chunkSize = 50; + for (let start = 1; start <= count; start += chunkSize) { + const end = Math.min(count, start + chunkSize - 1); + await Promise.all( + Array.from({ length: end - start + 1 }, async (_, idx) => { + const i = start + idx; + const name = `agent-${String(i).padStart(3, '0')}`; + const filePath = path.join(tmpHome, '.claude', 'agents', `${name}.md`); + await writeFile(filePath, `# ${name} ghost\n`, 'utf8'); + await utimes(filePath, OLD_DATE, OLD_DATE); + }), + ); + } + await writeFile(path.join(tmpHome, '.claude.json'), '{}\n', 'utf8'); + await writeRecentSession(tmpHome, '/fixture/pagination'); +} + +/** Build the minimal HOME for a real `ghost -i` archive then restore smoke. */ +export async function stageInteractiveBustFixture(tmpHome: string): Promise { + await mkdir(path.join(tmpHome, '.claude', 'agents'), { recursive: true }); + const agentPath = path.join(tmpHome, '.claude', 'agents', 'h2-solo.md'); + await writeFile(agentPath, '# h2-solo ghost\n', 'utf8'); + await utimes(agentPath, OLD_DATE, OLD_DATE); + await writeFile(path.join(tmpHome, '.claude.json'), '{}\n', 'utf8'); + await writeRecentSession(tmpHome, '/fixture/h2'); + await stageFakePs(tmpHome); +} diff --git a/apps/ccaudit/src/__tests__/fixtures/tmux-e2e.md b/apps/ccaudit/src/__tests__/fixtures/tmux-e2e.md new file mode 100644 index 0000000..55f59d1 --- /dev/null +++ b/apps/ccaudit/src/__tests__/fixtures/tmux-e2e.md @@ -0,0 +1,119 @@ +# tmux E2E fixture driver + +`tmux-e2e.ts` is an optional test helper for driving ccaudit TUI flows with a +real terminal emulator layer. It is designed to automate as much of +`ccaudit-manual-tests.txt` as possible without replacing true human-only checks +like macOS Terminal.app drag-resize or GitHub browser rendering. + +## Capabilities + +The helper can: + +- start a detached tmux session with a controlled width/height +- run the built CLI with disposable `HOME` fixtures +- send real key events (`Space`, `Enter`, arrows, `Escape`, `C-c`, etc.) +- send literal filter text +- capture pane output with ANSI stripped by default +- resize the tmux window to simulate SIGWINCH +- wait for expected text in the pane +- cleanly kill the session in test teardown + +## Typical test shape + +```ts +import { describe, it, expect } from 'vitest'; +import path from 'node:path'; +import { makeTmpHome, cleanupTmpHome } from '../_test-helpers.ts'; +import { stagePaginationFixture } from './manual-qa-followups.ts'; +import { hasTmux, startTmuxE2E, TMUX_KEYS } from './tmux-e2e.ts'; + +const distPath = path.resolve('apps/ccaudit/dist/index.js'); + +describe.skipIf(process.platform === 'win32')('pagination TUI via tmux', () => { + it('keeps picker alive after filtering and Esc clearing', async () => { + if (!(await hasTmux())) return; + + const tmpHome = await makeTmpHome(); + try { + await stagePaginationFixture(tmpHome, 550); + const session = await startTmuxE2E({ + name: `ccaudit-pag-${Date.now()}`, + tmpDir: tmpHome, + cwd: process.cwd(), + width: 120, + height: 30, + command: [process.execPath, distPath, 'ghost', '-i'], + env: { + HOME: tmpHome, + USERPROFILE: tmpHome, + XDG_CONFIG_HOME: path.join(tmpHome, '.config'), + TZ: 'UTC', + }, + }); + + try { + await session.waitForText('AGENTS (0/550)'); + await session.sendKeys( + Array.from({ length: 80 }, () => TMUX_KEYS.down), + { delayMs: 5 }, + ); + await session.sendKeys(['/']); + await session.sendLiteral('agent-09'); + await session.sendKeys([TMUX_KEYS.enter]); + await session.waitForText('Filtered: 10 of 550 visible'); + await session.sendKeys([TMUX_KEYS.escape]); + expect(await session.isAlive()).toBe(true); + } finally { + await session.kill(); + } + } finally { + await cleanupTmpHome(tmpHome); + } + }); +}); +``` + +## Recommended coverage from manual QA + +Use this helper with `manual-qa-followups.ts` to automate: + +| Manual row | Fixture | tmux action | +| -------------------------- | -------------------------------------- | ---------------------------------------------------------------------------------------- | +| Phase 9 D1/D2 | `stagePaginationFixture(tmpHome, 550)` | Scroll, filter, Esc clear, sort. | +| Phase 9 E1/E4 | `stageGlyphFixture(tmpHome)` | Capture glyph rows, press `?`, assert legend text. | +| Phase 9 F1 partial | any non-empty picker fixture | `session.resize(width, height)` to simulate SIGWINCH. Still keep Terminal.app manual QA. | +| Phase 9 H2 | `stageInteractiveBustFixture(tmpHome)` | Select item, confirm archive, then run non-interactive restore. | +| Phase 8/8.1 restore picker | existing restore-interactive fixture | Tab to MEMORY, select, confirm/cancel. | + +## Claude Code preflight note + +Interactive archive flows run the same safety preflight as the real CLI. If a +fixture/test does **not** install and prepend the fake `ps` shim, then any open +Claude Code process can make the archive confirmation path refuse to mutate. + +For automated temp-HOME tests, prefer `stageInteractiveBustFixture(tmpHome)` from +`manual-qa-followups.ts`; it installs `/bin/ps`. The test must prepend +that directory to `PATH` when calling `startTmuxE2E()`: + +```ts +env: { + HOME: tmpHome, + USERPROFILE: tmpHome, + XDG_CONFIG_HOME: path.join(tmpHome, '.config'), + PATH: `${path.join(tmpHome, 'bin')}:${process.env.PATH ?? ''}`, +} +``` + +For manual tests against a real HOME / real PATH, close all Claude Code +instances first. Do not bypass the preflight outside disposable fixtures. + +## Limits + +This helper does **not** prove: + +- macOS Terminal.app physical drag-resize behavior +- Cmd-+/Cmd-- font-size changes +- green-dot maximize behavior +- GitHub README table rendering in a browser + +Those remain human QA rows. diff --git a/apps/ccaudit/src/__tests__/fixtures/tmux-e2e.ts b/apps/ccaudit/src/__tests__/fixtures/tmux-e2e.ts new file mode 100644 index 0000000..e2018e8 --- /dev/null +++ b/apps/ccaudit/src/__tests__/fixtures/tmux-e2e.ts @@ -0,0 +1,249 @@ +/** + * tmux-backed E2E driver for manual-QA-style interactive tests. + * + * This helper is intentionally optional: tests should skip when `tmux` is not + * installed or on platforms where tmux is unavailable. It gives us a pragmatic + * middle ground between pure stdin pipes and true human QA: real terminal size, + * real key events, pane capture, and SIGWINCH via `tmux resize-window`. + * + * It does NOT replace macOS Terminal.app GUI checks (drag resize, font zoom, + * green-dot maximize), but it can cover most picker flows from + * ccaudit-manual-tests.txt: tab navigation, Space/Enter/Esc, help overlay, + * pagination, filter/sort, and archive/restore confirmation. + */ +import { execFile as execFileCb } from 'node:child_process'; +import { chmod, unlink, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { promisify } from 'node:util'; + +const execFile = promisify(execFileCb); + +export interface TmuxRunResult { + stdout: string; + stderr: string; +} + +export interface StartTmuxE2EOpts { + /** Unique tmux session name. Existing session is killed by default. */ + name: string; + /** Command argv to run inside the pane. */ + command: readonly string[]; + /** Working directory for the command. */ + cwd: string; + /** Directory where the generated runner script is written. */ + tmpDir: string; + /** Env vars exported before the command runs. Defaults to process.env passthrough for unspecified keys. */ + env?: Readonly>; + /** Initial pane width. */ + width?: number; + /** Initial pane height. */ + height?: number; + /** Keep pane alive after command exit so tests can capture final output. Defaults to 2s. */ + afterExitSleepMs?: number; + /** Kill an existing session with the same name before starting. Defaults to true. */ + killExisting?: boolean; +} + +export interface WaitForTextOpts { + /** Poll timeout. Defaults to 5000ms. */ + timeoutMs?: number; + /** Poll interval. Defaults to 100ms. */ + intervalMs?: number; + /** Capture scrollback start. Defaults to -200. */ + startLine?: number; + /** Strip ANSI before matching. Defaults to true. */ + stripAnsi?: boolean; +} + +export interface SendKeysOpts { + /** Delay after each key. Defaults to 75ms. */ + delayMs?: number; +} + +export interface CaptureOpts { + /** Start line for capture-pane. Defaults to -200. */ + startLine?: number; + /** Preserve ANSI escape codes. Defaults to false. */ + ansi?: boolean; +} + +export const TMUX_KEYS = { + enter: 'Enter', + escape: 'Escape', + space: 'Space', + tab: 'Tab', + backTab: 'BTab', + up: 'Up', + down: 'Down', + left: 'Left', + right: 'Right', + pageUp: 'PageUp', + pageDown: 'PageDown', + home: 'Home', + end: 'End', + ctrlC: 'C-c', +} as const; + +export class TmuxE2ESession { + readonly name: string; + private readonly runnerPath?: string; + + constructor(name: string, runnerPath?: string) { + this.name = name; + this.runnerPath = runnerPath; + } + + async sendKeys(keys: readonly string[], opts: SendKeysOpts = {}): Promise { + const delayMs = opts.delayMs ?? 75; + for (const key of keys) { + await tmux(['send-keys', '-t', this.name, key]); + if (delayMs > 0) await sleep(delayMs); + } + } + + async sendLiteral(text: string, opts: SendKeysOpts = {}): Promise { + await tmux(['send-keys', '-t', this.name, '-l', text]); + const delayMs = opts.delayMs ?? 75; + if (delayMs > 0) await sleep(delayMs); + } + + async resize(width: number, height: number): Promise { + await tmux(['resize-window', '-t', this.name, '-x', String(width), '-y', String(height)]); + } + + async capture(opts: CaptureOpts = {}): Promise { + const args = ['capture-pane', '-t', this.name, '-p', '-S', String(opts.startLine ?? -200)]; + if (opts.ansi === true) args.splice(3, 0, '-e'); + const { stdout } = await tmux(args); + return opts.ansi === true ? stdout : stripAnsi(stdout); + } + + async isAlive(): Promise { + try { + await tmux(['has-session', '-t', this.name]); + return true; + } catch { + return false; + } + } + + async kill(): Promise { + try { + await tmux(['kill-session', '-t', this.name]); + } catch { + // Already gone. + } + if (this.runnerPath !== undefined) { + try { + await unlink(this.runnerPath); + } catch { + // Already gone. + } + } + } + + async waitForText(needle: string | RegExp, opts: WaitForTextOpts = {}): Promise { + const timeoutMs = opts.timeoutMs ?? 5_000; + const intervalMs = opts.intervalMs ?? 100; + const start = Date.now(); + let lastCapture = ''; + while (Date.now() - start <= timeoutMs) { + const raw = await this.capture({ + startLine: opts.startLine ?? -200, + ansi: opts.stripAnsi === false, + }); + lastCapture = opts.stripAnsi === false ? raw : stripAnsi(raw); + const matched = + typeof needle === 'string' ? lastCapture.includes(needle) : needle.test(lastCapture); + if (matched) return lastCapture; + await sleep(intervalMs); + } + throw new Error( + `Timed out waiting for ${String(needle)} in tmux session ${this.name}\n` + + `Last capture:\n${lastCapture.slice(-2000)}`, + ); + } +} + +export async function hasTmux(): Promise { + try { + await tmux(['-V']); + return true; + } catch { + return false; + } +} + +export async function startTmuxE2E(opts: StartTmuxE2EOpts): Promise { + if (opts.command.length === 0) { + throw new Error('startTmuxE2E: command must not be empty'); + } + + const session = new TmuxE2ESession(opts.name); + if (opts.killExisting ?? true) await session.kill(); + + const runnerPath = path.join(opts.tmpDir, `${opts.name}.tmux-runner.sh`); + await writeFile(runnerPath, buildRunnerScript(opts), 'utf8'); + await chmod(runnerPath, 0o755); + + await tmux([ + 'new-session', + '-d', + '-s', + opts.name, + '-x', + String(opts.width ?? 120), + '-y', + String(opts.height ?? 30), + runnerPath, + ]); + return new TmuxE2ESession(opts.name, runnerPath); +} + +export function stripAnsi(s: string): string { + /* eslint-disable no-control-regex -- ANSI/OSC stripping intentionally matches ESC/BEL bytes. */ + return s + .replace(/\x1b\[\?25[lh]/g, '') + .replace(/\x1b\[[0-9;?]*[A-Za-z]/g, '') + .replace(/\x1b\][^\x07]*(?:\x07|\x1b\\)/g, '') + .replace(/\r/g, '\n'); + /* eslint-enable no-control-regex */ +} + +function buildRunnerScript(opts: StartTmuxE2EOpts): string { + const envLines = Object.entries(opts.env ?? {}) + .filter((entry): entry is [string, string] => entry[1] !== undefined) + .map(([key, value]) => `export ${key}=${shellQuote(value)}`) + .join('\n'); + const command = opts.command.map(shellQuote).join(' '); + const sleepSeconds = Math.max(0, (opts.afterExitSleepMs ?? 2_000) / 1_000); + + return `#!/usr/bin/env bash +set -euo pipefail +cd ${shellQuote(opts.cwd)} +${envLines} +set +e +${command} +status=$? +set -e +printf '\n__CCAUDIT_TMUX_EXIT:%s__\n' "$status" +sleep ${sleepSeconds} +exit "$status" +`; +} + +function shellQuote(s: string): string { + return `'${s.replaceAll("'", `'\\''`)}'`; +} + +async function tmux(args: readonly string[]): Promise { + const { stdout, stderr } = await execFile('tmux', [...args], { + maxBuffer: 10 * 1024 * 1024, + timeout: 10_000, + }); + return { stdout, stderr }; +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/apps/ccaudit/src/__tests__/ghost-select-ids.test.ts b/apps/ccaudit/src/__tests__/ghost-select-ids.test.ts new file mode 100644 index 0000000..22f912f --- /dev/null +++ b/apps/ccaudit/src/__tests__/ghost-select-ids.test.ts @@ -0,0 +1,462 @@ +/** + * Subprocess integration tests for CCAUDIT_SELECT_IDS env var (Phase 1 Plan 03). + * + * Spawns the built binary (apps/ccaudit/dist/index.js) with HOME overridden + * to a tmpdir fixture and CCAUDIT_SELECT_IDS set to exercise subset bust paths. + * Mirrors the pattern from bust-command.test.ts: fake-ps shim, tmpHome layout, + * dry-run-then-bust sequencing, manifest assertion via readManifest. + * + * Coverage: + * INV-S4: manifest header.planned_ops reflects the selection subset + * INV-S5: bust.summary.freedTokens is subset-accurate; + * bust.summary.totalPlannedTokens preserves the full-plan figure + * Edge cases: unset env (v1.4.0 compat), empty string (no-op subset), unknown ids + * + * Local-vs-CI note + * ───────────────── + * Same fake-ps shim as bust-command.test.ts: each test writes a FAKE `ps` script + * into `/bin/ps` so the bust preflight finds no running Claude Code + * process and proceeds. Without it, tests run from inside a Claude Code session + * would fail with exit 3 every time. + */ +import { describe, it, expect, beforeAll, beforeEach, afterEach } from 'vitest'; +import { spawn } from 'node:child_process'; +import { mkdtemp, mkdir, writeFile, rm, chmod } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { readManifest, canonicalItemId } from '@ccaudit/internal'; +import type { InventoryItem } from '@ccaudit/internal'; + +// ── Resolve dist path ────────────────────────────────────────── + +const here = path.dirname(fileURLToPath(import.meta.url)); +const distPath = path.resolve(here, '..', '..', 'dist', 'index.js'); + +// ── Fake ps script body ──────────────────────────────────────── + +const FAKE_PS_SCRIPT = `#!/bin/sh +# Fake ps used by ccaudit bust integration tests. +# Handles both \`ps -A -o pid=,comm=\` (system listing) and +# \`ps -o ppid= -p \` (parent chain walk). +case "$*" in + *-A*) + echo " 1 init" + ;; + *-o\\ ppid=*) + echo "1" + ;; + *) + echo " 1 init" + ;; +esac +`; + +async function buildFakePs(tmpHome: string): Promise { + const binDir = path.join(tmpHome, 'bin'); + await mkdir(binDir, { recursive: true }); + const psPath = path.join(binDir, 'ps'); + await writeFile(psPath, FAKE_PS_SCRIPT, 'utf8'); + await chmod(psPath, 0o755); + return binDir; +} + +// ── Subprocess runner ────────────────────────────────────────── + +interface RunResult { + code: number | null; + stdout: string; + stderr: string; +} + +interface RunOpts { + /** Extra env vars merged on top of HOME/USERPROFILE/XDG_CONFIG_HOME/NO_COLOR/PATH. */ + env?: Record; + /** Optional PATH override. Defaults to `/bin` (the fake-ps dir). */ + pathOverride?: string; + /** Maximum duration before the subprocess is SIGKILL'd. */ + timeout?: number; +} + +async function runBustCommand( + tmpHome: string, + flags: string[], + opts: RunOpts = {}, +): Promise { + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, [distPath, ...flags], { + env: { + HOME: tmpHome, + USERPROFILE: tmpHome, + XDG_CONFIG_HOME: path.join(tmpHome, '.config'), + NO_COLOR: '1', + TZ: 'UTC', + PATH: opts.pathOverride ?? path.join(tmpHome, 'bin'), + ...opts.env, + }, + stdio: ['pipe', 'pipe', 'pipe'], + }); + + let stdout = ''; + let stderr = ''; + let killed = false; + const timeoutMs = opts.timeout ?? 30_000; + const timer = setTimeout(() => { + killed = true; + child.kill('SIGKILL'); + reject(new Error(`runBustCommand timed out after ${timeoutMs}ms`)); + }, timeoutMs); + + child.stdout.on('data', (c) => { + stdout += c.toString(); + }); + child.stderr.on('data', (c) => { + stderr += c.toString(); + }); + child.on('error', (err) => { + clearTimeout(timer); + reject(err); + }); + child.on('close', (code) => { + clearTimeout(timer); + if (!killed) resolve({ code, stdout, stderr }); + }); + + child.stdin.end(); + }); +} + +// ── Fixture builders ─────────────────────────────────────────── + +/** + * Build the minimum fixture for a bust test: .claude/ directory tree, + * empty ~/.claude.json, minimal session JSONL, and fake-ps shim on PATH. + */ +async function buildBaseFixture(tmpHome: string): Promise { + await mkdir(path.join(tmpHome, '.claude', 'agents'), { recursive: true }); + await mkdir(path.join(tmpHome, '.claude', 'skills'), { recursive: true }); + await mkdir(path.join(tmpHome, '.config', 'claude'), { recursive: true }); + const sessionDir = path.join(tmpHome, '.claude', 'projects', 'fake-project'); + await mkdir(sessionDir, { recursive: true }); + const sessionLine = JSON.stringify({ + type: 'system', + subtype: 'init', + cwd: '/fake/project', + timestamp: new Date(Date.now() - 60 * 60 * 1000).toISOString(), + sessionId: 'fixture-session', + }); + await writeFile(path.join(sessionDir, 'session-1.jsonl'), sessionLine + '\n', 'utf8'); + await writeFile(path.join(tmpHome, '.claude.json'), '{}', 'utf8'); + await buildFakePs(tmpHome); +} + +/** + * Run dry-run to produce a checkpoint (required for bust gate 1). + */ +function runDryRunFirst(tmpHome: string): Promise { + return runBustCommand(tmpHome, ['--dry-run', '--yes-proceed-busting', '--json']); +} + +/** + * Build the canonical id for a global agent file in tmpHome. + * Uses `canonicalItemId` to guarantee format stays in sync with the scanner. + */ +function agentItemId(tmpHome: string, fileName: string): string { + const item: InventoryItem = { + name: path.basename(fileName, '.md'), + path: path.join(tmpHome, '.claude', 'agents', fileName), + scope: 'global', + category: 'agent', + projectPath: null, + }; + return canonicalItemId(item); +} + +/** + * Resolve the path to the manifest written by a successful bust. + * Parses the --json envelope to find the manifestPath field. + */ +function manifestPathFromEnvelope(stdout: string): string { + const parsed = JSON.parse(stdout) as Record; + const bust = parsed.bust as { manifestPath: string }; + return bust.manifestPath; +} + +// ── Guard: dist must exist before any test runs ──────────────── + +beforeAll(() => { + if (!existsSync(distPath)) { + throw new Error( + `dist binary not found at ${distPath}. Run \`pnpm -F ccaudit build\` before running this test.`, + ); + } +}); + +// ── Subset bust tests ──────────────────────────────────────────── + +// Windows: fake `ps` shell scripts require /bin/sh; skip on win32. +describe.skipIf(process.platform === 'win32')( + 'CCAUDIT_SELECT_IDS — subset bust (INV-S4 + INV-S5)', + () => { + let tmpHome: string; + + beforeEach(async () => { + tmpHome = await mkdtemp(path.join(tmpdir(), 'ghost-select-')); + + await buildBaseFixture(tmpHome); + + // Seed 3 ghost agents: alpha, beta, gamma. + // Each file has distinct content sizes for deterministic token estimates. + // alpha: short description (~50-token range) + // beta: medium description (~100-token range) + // gamma: longer description (~150-token range) + await writeFile( + path.join(tmpHome, '.claude', 'agents', 'alpha.md'), + '# alpha\nA short alpha agent.', + 'utf8', + ); + await writeFile( + path.join(tmpHome, '.claude', 'agents', 'beta.md'), + '# beta\n' + 'B'.repeat(200) + '\nA medium beta agent with more content.', + 'utf8', + ); + await writeFile( + path.join(tmpHome, '.claude', 'agents', 'gamma.md'), + '# gamma\n' + 'G'.repeat(500) + '\nA longer gamma agent with even more content.', + 'utf8', + ); + }); + + afterEach(async () => { + await rm(tmpHome, { recursive: true, force: true }); + }); + + // ── Test A: INV-S4 — planned_ops reflects subset ────────────── + it('Test A — INV-S4: manifest planned_ops reflects selection subset', async () => { + // Step 1: dry-run to create checkpoint. + const dry = await runDryRunFirst(tmpHome); + expect(dry.code, `dry-run stderr: ${dry.stderr}`).toBe(0); + + // Step 2: compute the canonical id for agent alpha only. + const alphaId = agentItemId(tmpHome, 'alpha.md'); + + // Step 3: subset bust with only alpha selected. + const bust = await runBustCommand( + tmpHome, + ['--dangerously-bust-ghosts', '--yes-proceed-busting', '--json'], + { env: { CCAUDIT_SELECT_IDS: alphaId } }, + ); + expect(bust.code, `bust stderr: ${bust.stderr}`).toBe(0); + + // Step 4: parse the bust envelope and read the manifest. + const manifestPath = manifestPathFromEnvelope(bust.stdout); + const manifest = await readManifest(manifestPath); + + // Step 5: planned_ops counts reflect subset (1 archive, 0 others). + expect(manifest.header).not.toBeNull(); + expect(manifest.header!.planned_ops.archive).toBe(1); + expect(manifest.header!.planned_ops.disable).toBe(0); + expect(manifest.header!.planned_ops.flag).toBe(0); + + // Step 6: only 1 op record in manifest body. + expect(manifest.ops).toHaveLength(1); + expect(manifest.ops[0]!.op_type).toBe('archive'); + + // Step 7: selection_filter is subset with exactly alphaId. + expect(manifest.header!.selection_filter).toBeDefined(); + expect(manifest.header!.selection_filter!.mode).toBe('subset'); + const sf = manifest.header!.selection_filter as { mode: 'subset'; ids: string[] }; + expect(sf.ids).toEqual([alphaId]); + + // Step 8: alpha is archived; beta and gamma remain in place. + expect( + existsSync(path.join(tmpHome, '.claude', 'ccaudit', 'archived', 'agents', 'alpha.md')), + ).toBe(true); + expect(existsSync(path.join(tmpHome, '.claude', 'agents', 'alpha.md'))).toBe(false); + expect(existsSync(path.join(tmpHome, '.claude', 'agents', 'beta.md'))).toBe(true); + expect(existsSync(path.join(tmpHome, '.claude', 'agents', 'gamma.md'))).toBe(true); + }); + + // ── Test B: INV-S5 — freedTokens subset-accurate; totalPlannedTokens full ── + it('Test B — INV-S5: freedTokens subset-accurate; totalPlannedTokens preserves full figure', async () => { + // Step 1: dry-run to get full-plan token figures. + const dry = await runDryRunFirst(tmpHome); + expect(dry.code, `dry-run stderr: ${dry.stderr}`).toBe(0); + + // Extract full-plan savings.tokens from the dry-run envelope. + // The dry-run JSON envelope shape is: + // { dryRun: true, changePlan: { savings: { tokens: number }, ... } } + // (not `totalOverhead` which belongs to the ghost inventory view path) + const dryParsed = JSON.parse(dry.stdout) as { + changePlan: { savings: { tokens: number } }; + }; + const fullPlanTokens = dryParsed.changePlan.savings.tokens; + expect(fullPlanTokens).toBeGreaterThan(0); + + // Step 2: subset bust with only alpha selected. + const alphaId = agentItemId(tmpHome, 'alpha.md'); + const bust = await runBustCommand( + tmpHome, + ['--dangerously-bust-ghosts', '--yes-proceed-busting', '--json'], + { env: { CCAUDIT_SELECT_IDS: alphaId } }, + ); + expect(bust.code, `bust stderr: ${bust.stderr}`).toBe(0); + + // Step 3: parse the bust envelope summary. + const bustParsed = JSON.parse(bust.stdout) as { + bust: { + status: string; + summary: { + freedTokens: number; + totalPlannedTokens: number; + }; + }; + }; + expect(bustParsed.bust.status).toBe('success'); + + const { freedTokens, totalPlannedTokens } = bustParsed.bust.summary; + + // Step 4: totalPlannedTokens equals the full dry-run figure. + expect(totalPlannedTokens).toBe(fullPlanTokens); + + // Step 5: freedTokens is less than totalPlannedTokens (1-of-3 subset). + expect(freedTokens).toBeLessThan(totalPlannedTokens); + + // Step 6: freedTokens is positive (we archived something). + expect(freedTokens).toBeGreaterThan(0); + }); + }, +); + +// ── Edge case tests ────────────────────────────────────────────── + +describe.skipIf(process.platform === 'win32')('CCAUDIT_SELECT_IDS — edge cases', () => { + let tmpHome: string; + + beforeEach(async () => { + tmpHome = await mkdtemp(path.join(tmpdir(), 'ghost-select-edge-')); + + await buildBaseFixture(tmpHome); + + // Seed 3 ghost agents for the edge case tests. + await writeFile( + path.join(tmpHome, '.claude', 'agents', 'alpha.md'), + '# alpha\nA short alpha agent.', + 'utf8', + ); + await writeFile( + path.join(tmpHome, '.claude', 'agents', 'beta.md'), + '# beta\nA beta agent.', + 'utf8', + ); + await writeFile( + path.join(tmpHome, '.claude', 'agents', 'gamma.md'), + '# gamma\nA gamma agent.', + 'utf8', + ); + }); + + afterEach(async () => { + await rm(tmpHome, { recursive: true, force: true }); + }); + + // ── Test C: unset env preserves v1.4.0 full-inventory behavior ── + it('Test C — unset env: v1.4.0 full-inventory behavior preserved', async () => { + const dry = await runDryRunFirst(tmpHome); + expect(dry.code, `dry-run stderr: ${dry.stderr}`).toBe(0); + + // Bust WITHOUT the env var — no CCAUDIT_SELECT_IDS key at all. + const bust = await runBustCommand(tmpHome, [ + '--dangerously-bust-ghosts', + '--yes-proceed-busting', + '--json', + ]); + expect(bust.code, `bust stderr: ${bust.stderr}`).toBe(0); + + const manifestPath = manifestPathFromEnvelope(bust.stdout); + const manifest = await readManifest(manifestPath); + + // Full bust: all 3 agents archived. + expect(manifest.header).not.toBeNull(); + const totalOps = + manifest.header!.planned_ops.archive + + manifest.header!.planned_ops.disable + + manifest.header!.planned_ops.flag; + expect(totalOps).toBeGreaterThanOrEqual(3); + + // selection_filter is 'full'. + expect(manifest.header!.selection_filter).toBeDefined(); + expect(manifest.header!.selection_filter!.mode).toBe('full'); + + // freedTokens equals totalPlannedTokens for a full bust. + const bustParsed = JSON.parse(bust.stdout) as { + bust: { summary: { freedTokens: number; totalPlannedTokens: number } }; + }; + expect(bustParsed.bust.summary.freedTokens).toBe(bustParsed.bust.summary.totalPlannedTokens); + }); + + // ── Test D: empty string env is a no-op subset with a warning ── + it('Test D — empty string env: no-op subset with a warning', async () => { + const dry = await runDryRunFirst(tmpHome); + expect(dry.code, `dry-run stderr: ${dry.stderr}`).toBe(0); + + const bust = await runBustCommand( + tmpHome, + ['--dangerously-bust-ghosts', '--yes-proceed-busting', '--json'], + { env: { CCAUDIT_SELECT_IDS: '' } }, + ); + expect(bust.code, `bust stderr: ${bust.stderr}`).toBe(0); + + const manifestPath = manifestPathFromEnvelope(bust.stdout); + const manifest = await readManifest(manifestPath); + + // No ops planned. + expect(manifest.header).not.toBeNull(); + expect(manifest.header!.planned_ops.archive).toBe(0); + expect(manifest.header!.planned_ops.disable).toBe(0); + expect(manifest.header!.planned_ops.flag).toBe(0); + + // selection_filter is subset with empty ids. + expect(manifest.header!.selection_filter!.mode).toBe('subset'); + const sf = manifest.header!.selection_filter as { mode: 'subset'; ids: string[] }; + expect(sf.ids).toEqual([]); + + // Warning printed to stderr. + expect(bust.stderr).toMatch(/no items will be archived|empty set/i); + + // All source files untouched. + expect(existsSync(path.join(tmpHome, '.claude', 'agents', 'alpha.md'))).toBe(true); + expect(existsSync(path.join(tmpHome, '.claude', 'agents', 'beta.md'))).toBe(true); + expect(existsSync(path.join(tmpHome, '.claude', 'agents', 'gamma.md'))).toBe(true); + }); + + // ── Test E: unknown ids are silently ignored ────────────────── + it('Test E — unknown ids: silently ignored, no files moved', async () => { + const dry = await runDryRunFirst(tmpHome); + expect(dry.code, `dry-run stderr: ${dry.stderr}`).toBe(0); + + const bust = await runBustCommand( + tmpHome, + ['--dangerously-bust-ghosts', '--yes-proceed-busting', '--json'], + { env: { CCAUDIT_SELECT_IDS: 'agent|global||/this/path/does/not/match/anything.md' } }, + ); + expect(bust.code, `bust stderr: ${bust.stderr}`).toBe(0); + + const manifestPath = manifestPathFromEnvelope(bust.stdout); + const manifest = await readManifest(manifestPath); + + // No ops (unknown id filtered out — results in zero-item subset plan). + expect(manifest.header).not.toBeNull(); + const totalOps = + manifest.header!.planned_ops.archive + + manifest.header!.planned_ops.disable + + manifest.header!.planned_ops.flag; + expect(totalOps).toBe(0); + + // All source files untouched. + expect(existsSync(path.join(tmpHome, '.claude', 'agents', 'alpha.md'))).toBe(true); + expect(existsSync(path.join(tmpHome, '.claude', 'agents', 'beta.md'))).toBe(true); + expect(existsSync(path.join(tmpHome, '.claude', 'agents', 'gamma.md'))).toBe(true); + }); +}); diff --git a/apps/ccaudit/src/__tests__/help-output.test.ts b/apps/ccaudit/src/__tests__/help-output.test.ts index 5be0bd4..43528f1 100644 --- a/apps/ccaudit/src/__tests__/help-output.test.ts +++ b/apps/ccaudit/src/__tests__/help-output.test.ts @@ -40,6 +40,33 @@ function runHelp(args: string[]): string { return (result.stdout ?? '') + (result.stderr ?? ''); } +function findLine(output: string, needle: string): string | undefined { + return output.split(/\r?\n/).find((line) => line.includes(needle)); +} + +function countMatches(output: string, pattern: RegExp): number { + return [...output.matchAll(pattern)].length; +} + +function expectSingleVersionLine(output: string): void { + expect(countMatches(output, /^\s*-v,\s*--version\b/gm)).toBe(1); +} + +function expectCleanNegativeFlagLines(output: string): void { + const noColorLine = findLine(output, '--no-color'); + expect(noColorLine).toBeDefined(); + expect(noColorLine).toContain('Disable ANSI colors'); + expect(noColorLine).not.toContain('Negatable of'); + + const noGroupFrameworksLine = findLine(output, '--no-group-frameworks'); + expect(noGroupFrameworksLine).toBeDefined(); + expect(noGroupFrameworksLine).toContain('Disable framework grouping'); + expect(noGroupFrameworksLine).not.toContain('Negatable of'); + + expect(output).not.toContain('Negatable of --color'); + expect(output).not.toContain('Negatable of --group-frameworks'); +} + describe.skipIf(!binaryExists)('Gap #4 regression: --no-color visible in --help', () => { it('root ccaudit --help lists no-color', () => { const output = runHelp(['--help']); @@ -86,3 +113,26 @@ describe.skipIf(!binaryExists)('DOCS-04: v1.3.0 flag visibility in --help', () = expect(output).not.toContain('--force-partial'); }); }); + +describe.skipIf(!binaryExists)('Phase 8.1 help rendering regression guards', () => { + it('restore --help keeps one version line and clean negative flag descriptions', () => { + const output = runHelp(['restore', '--help']); + expectSingleVersionLine(output); + expectCleanNegativeFlagLines(output); + expect(output).toContain('--interactive'); + expect(output).toContain('--name'); + expect(output).toContain('--all-matching'); + expect(output).toContain('--json'); + expect(output).toContain('--verbose'); + }); + + it('ghost --help keeps one version line and clean negative flag descriptions', () => { + const output = runHelp(['ghost', '--help']); + expectSingleVersionLine(output); + expectCleanNegativeFlagLines(output); + expect(output).toContain('--verbose'); + expect(output).toContain('--dry-run'); + expect(output).toContain('--dangerously-bust-ghosts'); + expect(output).toContain('--force-partial'); + }); +}); diff --git a/apps/ccaudit/src/__tests__/interactive-smoke.test.ts b/apps/ccaudit/src/__tests__/interactive-smoke.test.ts new file mode 100644 index 0000000..2f01dc9 --- /dev/null +++ b/apps/ccaudit/src/__tests__/interactive-smoke.test.ts @@ -0,0 +1,246 @@ +/** + * Subprocess smoke integration tests for the interactive TUI guard paths (D-31, Phase 2). + * + * Coverage (3 guards that CAN be tested without a terminal emulator): + * Test A: `--interactive + --json` exits 2 with exact stderr (D-06) + * Test B: non-TTY `--interactive` prints D-07 fallback notice and exits 0 (no bust) + * Test C: auto-open prompt is suppressed under --json/--csv/--quiet/--ci and non-TTY (D-23) + * + * NOT tested here (Phase 3 responsibility — full picker flow requires terminal emulator fixtures): + * - Space/Enter/Ctrl+C inside the picker + * - Full interactive bust happy-path + * - Signal-based cancellation → zero writes + * - MCP byte-preservation across archive+restore cycles + * + * Pattern mirrors bust-command.test.ts: fake-ps shim, tmpHome layout, TZ=UTC env. + * NO hard-coded /Users/... or /home/... paths — all derived from tmpHome. + */ +import { describe, it, expect, beforeAll, beforeEach, afterEach } from 'vitest'; +import { spawn } from 'node:child_process'; +import { mkdtemp, mkdir, writeFile, rm, readdir, chmod } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import * as path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +// ── Resolve dist path ────────────────────────────────────────── + +const here = path.dirname(fileURLToPath(import.meta.url)); +const distPath = path.resolve(here, '..', '..', 'dist', 'index.js'); + +// ── Fake ps script body ──────────────────────────────────────── + +/** + * POSIX shell script that impersonates `ps` for the bust preflight. + * Emits only pid 1 (init) so the detector finds no running Claude Code + * process and proceeds. Same approach as bust-command.test.ts. + */ +const FAKE_PS_SCRIPT = `#!/bin/sh +# Fake ps used by ccaudit interactive smoke integration tests. +case "$*" in + *-A*) + echo " 1 init" + ;; + *-o\\ ppid=*) + echo "1" + ;; + *) + echo " 1 init" + ;; +esac +`; + +async function buildFakePs(tmpHome: string): Promise { + const binDir = path.join(tmpHome, 'bin'); + await mkdir(binDir, { recursive: true }); + const psPath = path.join(binDir, 'ps'); + await writeFile(psPath, FAKE_PS_SCRIPT, 'utf8'); + await chmod(psPath, 0o755); + return binDir; +} + +// ── Subprocess runner ────────────────────────────────────────── + +interface RunResult { + code: number | null; + stdout: string; + stderr: string; +} + +interface RunOpts { + /** Extra env vars merged on top of HOME/USERPROFILE/XDG_CONFIG_HOME/NO_COLOR/TZ/PATH. */ + env?: Record; + /** Optional PATH override. Defaults to `/bin` (the fake-ps dir). */ + pathOverride?: string; + /** Maximum duration before the subprocess is SIGKILL'd (default 30s). */ + timeout?: number; +} + +async function runGhostCommand( + tmpHome: string, + flags: string[], + opts: RunOpts = {}, +): Promise { + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, [distPath, 'ghost', ...flags], { + env: { + HOME: tmpHome, + USERPROFILE: tmpHome, + XDG_CONFIG_HOME: path.join(tmpHome, '.config'), + NO_COLOR: '1', + TZ: 'UTC', + PATH: opts.pathOverride ?? path.join(tmpHome, 'bin'), + ...opts.env, + }, + stdio: ['pipe', 'pipe', 'pipe'], + }); + + let stdout = ''; + let stderr = ''; + let killed = false; + const timeoutMs = opts.timeout ?? 30_000; + const timer = setTimeout(() => { + killed = true; + child.kill('SIGKILL'); + reject( + new Error( + `runGhostCommand timed out after ${timeoutMs}ms\nstdout: ${stdout.slice(-500)}\nstderr: ${stderr.slice(-500)}`, + ), + ); + }, timeoutMs); + + child.stdout.on('data', (c: Buffer) => { + stdout += c.toString(); + }); + child.stderr.on('data', (c: Buffer) => { + stderr += c.toString(); + }); + child.on('error', (err: Error) => { + clearTimeout(timer); + reject(err); + }); + child.on('close', (code: number | null) => { + clearTimeout(timer); + if (!killed) resolve({ code, stdout, stderr }); + }); + + child.stdin.end(); + }); +} + +// ── Fixture builders ─────────────────────────────────────────── + +/** + * Build the minimum fixture for a smoke test: + * - ~/.claude/agents/, ~/.claude/skills/, ~/.config/claude/ directories + * - Minimal session JSONL so discoverSessionFiles returns at least one file + * - Blank ~/.claude.json (no MCP entries) + * - Fake-ps shim on PATH + */ +async function buildBaseFixture(tmpHome: string): Promise { + await mkdir(path.join(tmpHome, '.claude', 'agents'), { recursive: true }); + await mkdir(path.join(tmpHome, '.claude', 'skills'), { recursive: true }); + await mkdir(path.join(tmpHome, '.config', 'claude'), { recursive: true }); + const sessionDir = path.join(tmpHome, '.claude', 'projects', 'smoke-project'); + await mkdir(sessionDir, { recursive: true }); + const sessionLine = JSON.stringify({ + type: 'system', + subtype: 'init', + cwd: '/fake/smoke', + timestamp: new Date(Date.now() - 60 * 60 * 1000).toISOString(), + sessionId: 'smoke-session', + }); + await writeFile(path.join(sessionDir, 'session-1.jsonl'), sessionLine + '\n', 'utf8'); + await writeFile(path.join(tmpHome, '.claude.json'), '{}', 'utf8'); + await buildFakePs(tmpHome); +} + +/** + * Seed a minimal ghost inventory in tmpHome. + * Writes one agent file that was never invoked in the session window, + * guaranteeing ≥1 ghost in the scan so suppression-related assertions are + * meaningful (D-23: "zero ghosts found" also suppresses the prompt, so we + * need at least one ghost to make the --json/--csv/--quiet/--ci tests + * definitively test flag-based suppression rather than zero-ghost suppression). + */ +async function seedMinimalInventory(tmpHome: string): Promise { + await writeFile( + path.join(tmpHome, '.claude', 'agents', 'smoke-test-agent.md'), + '# smoke-test-agent\n\nA minimal agent for smoke tests. Never invoked.\n', + 'utf8', + ); +} + +// ── Guard: dist must exist before any test runs ──────────────── + +beforeAll(() => { + if (!existsSync(distPath)) { + throw new Error( + `dist binary not found at ${distPath}. Run \`pnpm -F ccaudit build\` before running this test.`, + ); + } +}); + +// ── Smoke tests — windows: fake ps requires /bin/sh; skip on win32 ── + +describe.skipIf(process.platform === 'win32')( + 'interactive smoke tests — D-31 (3 non-interactive guards)', + () => { + let tmpHome: string; + + beforeEach(async () => { + tmpHome = await mkdtemp(path.join(tmpdir(), 'ccaudit-smoke-')); + await buildBaseFixture(tmpHome); + }); + + afterEach(async () => { + await rm(tmpHome, { recursive: true, force: true }); + }); + + // ── Test A: --interactive + --json hard-errors with exit 2 (D-06) ────── + it('exits 2 with exact stderr when --interactive is combined with --json', async () => { + const result = await runGhostCommand(tmpHome, ['--interactive', '--json']); + expect(result.code).toBe(2); + expect(result.stderr).toContain('Error: --interactive cannot be combined with --json.'); + }); + + // ── Test B: non-TTY --interactive falls back to dry-run (D-07) ───────── + it('falls back to dry-run under non-TTY when --interactive is explicit', async () => { + await seedMinimalInventory(tmpHome); + + const result = await runGhostCommand(tmpHome, ['--interactive']); + + // D-07: non-TTY with explicit --interactive → stderr notice + expect(result.stderr).toContain('No TTY detected — running in dry-run mode.'); + + // Dry-run is non-destructive → exit 0 + expect(result.code).toBe(0); + + // Manifests directory must remain absent (no bust happened) + const manifestsDir = path.join(tmpHome, '.claude', 'ccaudit', 'manifests'); + const manifests = await readdir(manifestsDir).catch(() => [] as string[]); + expect(manifests).toEqual([]); + }); + + // ── Test C: auto-open prompt is suppressed under output flags and non-TTY ── + it('suppresses auto-open prompt under --json, --csv, --quiet, --ci, and non-TTY (D-23)', async () => { + await seedMinimalInventory(tmpHome); + + // 4 suppression flags: --json, --csv, --quiet, --ci + for (const flag of ['--json', '--csv', '--quiet', '--ci']) { + const result = await runGhostCommand(tmpHome, [flag]); + // The auto-open prompt must NEVER appear in either stdout or stderr + // regardless of how many ghosts are found. + expect(result.stderr).not.toContain('Open interactive picker?'); + expect(result.stdout).not.toContain('Open interactive picker?'); + } + + // Bare `ghost` under non-TTY (our subprocess case) also suppresses. + // The subprocess has piped stdio → isTTY === false → checkTuiGuards returns + // suppress-auto-open (Rule 6) → prompt is never shown. + const bare = await runGhostCommand(tmpHome, []); + expect(bare.stderr).not.toContain('Open interactive picker?'); + expect(bare.stdout).not.toContain('Open interactive picker?'); + }); + }, +); diff --git a/apps/ccaudit/src/__tests__/no-interactive-env.test.ts b/apps/ccaudit/src/__tests__/no-interactive-env.test.ts new file mode 100644 index 0000000..91905d7 --- /dev/null +++ b/apps/ccaudit/src/__tests__/no-interactive-env.test.ts @@ -0,0 +1,153 @@ +/** + * Phase 9 D2 / SC2 — CCAUDIT_NO_INTERACTIVE env escape hatch. + * + * - `ccaudit ghost --interactive` under CCAUDIT_NO_INTERACTIVE=1 | =true: + * exit 2, stderr contains "refusing: CCAUDIT_NO_INTERACTIVE is set". + * - `ccaudit restore --interactive` under CCAUDIT_NO_INTERACTIVE=1: + * same exit 2, same message. + * - CCAUDIT_NO_INTERACTIVE=0 does NOT trigger the refusal (control). + * - Plain `ccaudit ghost` on a non-empty fixture with NO_INTERACTIVE=1: + * exit 0 or 1 (ghosts present), no auto-open prompt in stdout. + */ +import { describe, it, expect, beforeAll, beforeEach, afterEach } from 'vitest'; +import { existsSync } from 'node:fs'; +import { mkdir, writeFile, utimes } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + makeTmpHome, + cleanupTmpHome, + buildFakePs, + runCcauditCli, + runCcauditGhost, + readJsonl, +} from './_test-helpers.ts'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const distPath = path.resolve(here, '..', '..', 'dist', 'index.js'); + +beforeAll(() => { + if (!existsSync(distPath)) { + throw new Error( + `dist binary not found at ${distPath}. Run \`pnpm -F ccaudit build\` before running this test.`, + ); + } +}); + +/** Stage a home with 1 ghost agent so auto-open would normally trigger. */ +async function stageHomeWithOneGhost(tmpHome: string): Promise { + await mkdir(path.join(tmpHome, '.claude', 'agents'), { recursive: true }); + await mkdir(path.join(tmpHome, '.config', 'claude'), { recursive: true }); + + const agentPath = path.join(tmpHome, '.claude', 'agents', 'lonely-agent.md'); + await writeFile(agentPath, '# lonely-agent\n\nnever invoked\n', 'utf8'); + const sixtyDaysAgo = new Date(Date.now() - 60 * 86_400_000); + await utimes(agentPath, sixtyDaysAgo, sixtyDaysAgo); + + const sessionDir = path.join(tmpHome, '.claude', 'projects', 'ne'); + await mkdir(sessionDir, { recursive: true }); + await writeFile( + path.join(sessionDir, 'session-1.jsonl'), + JSON.stringify({ + type: 'system', + subtype: 'init', + cwd: '/fake/ne', + timestamp: new Date(Date.now() - 60 * 60 * 1000).toISOString(), + sessionId: 'ne-session', + }) + '\n', + 'utf8', + ); + await writeFile(path.join(tmpHome, '.claude.json'), '{}', 'utf8'); + await buildFakePs(tmpHome); +} + +describe.skipIf(process.platform === 'win32')( + 'Phase 9 SC2 — CCAUDIT_NO_INTERACTIVE refuses --interactive and silences auto-open', + () => { + let tmpHome: string; + + beforeEach(async () => { + tmpHome = await makeTmpHome(); + await stageHomeWithOneGhost(tmpHome); + }); + + afterEach(async () => { + await cleanupTmpHome(tmpHome); + }); + + it('`ghost --interactive` with CCAUDIT_NO_INTERACTIVE=1: exit 2 + refusal on stderr', async () => { + const result = await runCcauditCli(tmpHome, ['ghost', '--interactive'], { + env: { CCAUDIT_NO_INTERACTIVE: '1', CCAUDIT_FORCE_TTY: '1' }, + }); + expect(result.exitCode).toBe(2); + expect(result.stderr).toContain('refusing: CCAUDIT_NO_INTERACTIVE is set'); + }); + + it('`ghost --interactive` with CCAUDIT_NO_INTERACTIVE=true (case-insensitive): exit 2', async () => { + const result = await runCcauditCli(tmpHome, ['ghost', '--interactive'], { + env: { CCAUDIT_NO_INTERACTIVE: 'TRUE', CCAUDIT_FORCE_TTY: '1' }, + }); + expect(result.exitCode).toBe(2); + expect(result.stderr).toContain('refusing: CCAUDIT_NO_INTERACTIVE is set'); + }); + + it('`ghost --interactive` with CCAUDIT_NO_INTERACTIVE=0: NOT refused (control)', async () => { + // Without CCAUDIT_FORCE_TTY, the --interactive path hits the non-TTY + // fallback (effectiveDryRun=true). What matters: no exit 2, no refusal. + const result = await runCcauditCli(tmpHome, ['ghost', '--interactive'], { + env: { CCAUDIT_NO_INTERACTIVE: '0' }, + }); + expect(result.exitCode, `stderr:\n${result.stderr}`).not.toBe(2); + expect(result.stderr).not.toContain('refusing: CCAUDIT_NO_INTERACTIVE is set'); + }); + + it('`restore --interactive` with CCAUDIT_NO_INTERACTIVE=1: exit 2 + refusal on stderr', async () => { + const result = await runCcauditCli(tmpHome, ['restore', '--interactive'], { + env: { CCAUDIT_NO_INTERACTIVE: '1', CCAUDIT_FORCE_TTY: '1' }, + }); + expect(result.exitCode).toBe(2); + expect(result.stderr).toContain('refusing: CCAUDIT_NO_INTERACTIVE is set'); + }); + + it('(M3) `ghost --interactive` with CCAUDIT_NO_INTERACTIVE=1: finally block runs and history.jsonl is written', async () => { + // M3 fix: process.exitCode = 2; return; instead of process.exit(2). + // The finally block must now execute, meaning recordHistory is called and + // history.jsonl receives an entry for this ghost invocation. + const result = await runCcauditCli(tmpHome, ['ghost', '--interactive'], { + env: { CCAUDIT_NO_INTERACTIVE: '1', CCAUDIT_FORCE_TTY: '1' }, + }); + expect(result.exitCode).toBe(2); + expect(result.stderr).toContain('refusing: CCAUDIT_NO_INTERACTIVE is set'); + + // The finally block writes history — assert the file exists and has an entry. + const historyPath = path.join(tmpHome, '.claude', 'ccaudit', 'history.jsonl'); + expect(existsSync(historyPath), `history.jsonl not found at ${historyPath}`).toBe(true); + + const lines = await readJsonl(historyPath); + // history.jsonl format: line 0 is the HistoryHeader, line 1+ are HistoryEntry records. + expect(lines.length).toBeGreaterThanOrEqual(2); + + // The entry must record a 'ghost' command invocation. + const entry = lines.find( + (l) => (l as Record)['record_type'] === 'entry', + ) as Record; + expect(entry, 'expected a history entry record').toBeDefined(); + expect(entry['command']).toBe('ghost'); + expect(entry['exit_code']).toBe(2); + }); + + it('plain `ghost` with CCAUDIT_NO_INTERACTIVE=1: no auto-open prompt in stdout', async () => { + const spawned = runCcauditGhost(tmpHome, [], { + env: { CCAUDIT_NO_INTERACTIVE: '1' }, + timeout: 15_000, + }); + spawned.child.stdin?.end(); + const result = await spawned.done; + + expect(result.stdout).not.toMatch(/open interactive picker/i); + expect(result.stdout).not.toContain('[Y/n]'); + // Exit is 0 (no ghosts) or 1 (ghosts present); we only assert we did not refuse. + expect(result.exitCode).not.toBe(2); + }); + }, +); diff --git a/apps/ccaudit/src/__tests__/pagination-500.test.ts b/apps/ccaudit/src/__tests__/pagination-500.test.ts new file mode 100644 index 0000000..e8c2940 --- /dev/null +++ b/apps/ccaudit/src/__tests__/pagination-500.test.ts @@ -0,0 +1,90 @@ +/** + * Phase 9 Plan 02 (D3 / SC3) — 500-item pagination integration test. + * + * Drives the TabbedGhostPicker in-process (no pty) because: + * (a) pty-driving 500 rows is flaky across CI terminals, and + * (b) the invariant under test is pure render + cursor state, not + * keystroke decoding — which existing Phase 3.1 / 5 tests already + * cover end-to-end. + * + * Asserts: + * 1. With 500 ghosts and a bounded viewport, the rendered frame contains + * only a viewport-sized slice of agent rows (no terminal overflow). + * 2. End jumps cursor to last row; an above-indicator is visible. + * 3. The applyScroll reducer round-trips cursor position across filter + * on/off and clamps when the row set narrows below the saved cursor. + * 4. Toggling many rows keeps the rendered frame bounded. + */ +import { describe, it, expect } from 'vitest'; +import { TabbedGhostPicker } from '../../../../packages/terminal/src/tui/tabbed-picker.ts'; +import { applyScroll } from '../../../../packages/terminal/src/tui/_viewport.ts'; +import { buildGhosts500 } from './fixtures/ghost-500-items.ts'; + +// Strip ANSI — `_renderFrame()` returns strings with picocolors escapes. +/* eslint-disable no-control-regex */ +function stripAnsi(s: string): string { + return s.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, ''); +} +/* eslint-enable no-control-regex */ + +function makePicker(rows = 500): TabbedGhostPicker { + return new TabbedGhostPicker({ + ghosts: buildGhosts500({ count: rows }), + useAscii: true, + stdoutRows: 24, + terminalCols: 100, + }); +} + +describe('Phase 9 Plan 02 — 500-item pagination (D3 / SC3)', () => { + it('renders only a viewport-sized slice on a 500-item tab (no overflow)', () => { + const picker = makePicker(500); + const frame = stripAnsi(picker._renderFrame()); + // stdoutRows=24 → viewportHeight = max(8, 24-10) = 14. + // De-dupe: each rendered row includes the name once in the label and + // once in the path (/fake/agents/agent-NNN.md). Count unique names. + const agentMatches = new Set(frame.match(/agent-\d{3}/g) ?? []); + expect(agentMatches.size).toBeGreaterThanOrEqual(10); + expect(agentMatches.size).toBeLessThanOrEqual(20); + // "more below" indicator must be present (cursor at row 0, many below). + expect(frame).toMatch(/\bmore\b/); + expect(frame).toContain('v '); // ASCII "↓" fallback under useAscii=true + }); + + it('End jumps cursor to last row; above-indicator rendered', () => { + const picker = makePicker(500); + picker.cursorEnd(); + expect(picker.tabs[0]!.cursor).toBe(499); + const frame = stripAnsi(picker._renderFrame()); + expect(frame).toContain('^ '); // ASCII "↑" fallback + expect(frame).toMatch(/\bmore\b/); + }); + + it('applyScroll reducer round-trips cursor across filter on/off and clamps on narrowing', () => { + const s0 = { cursor: 300, savedCursorPreFilter: null as number | null }; + const s1 = applyScroll(s0, { type: 'filterOn' }); + const s2 = applyScroll(s1, { type: 'filterQueryChange' }); + const s3 = applyScroll(s2, { type: 'filterOff', rowsLen: 500 }); + expect(s3.cursor).toBe(300); + expect(s3.savedCursorPreFilter).toBeNull(); + + const narrowed = applyScroll(s2, { type: 'filterOff', rowsLen: 50 }); + expect(narrowed.cursor).toBeLessThanOrEqual(49); + expect(narrowed.savedCursorPreFilter).toBeNull(); + }); + + it('toggling across 500 items keeps the rendered frame bounded', () => { + // Regression: selecting many items must not bleed rows outside the viewport. + const picker = makePicker(500); + for (let i = 0; i < 250; i++) { + picker.toggleCurrentRow(); + picker.cursorDown(); + } + const frame = stripAnsi(picker._renderFrame()); + const lines = frame.split('\n'); + // Row lines are ≤ 14 (viewport) + a few chrome lines (tab bar, header, + // footer, hint, above/below indicators). Overall line count must stay + // well under 500 — bound generously at 30. + expect(lines.length).toBeLessThan(30); + }); +}); diff --git a/apps/ccaudit/src/__tests__/preflight-retry.test.ts b/apps/ccaudit/src/__tests__/preflight-retry.test.ts new file mode 100644 index 0000000..a3fa691 --- /dev/null +++ b/apps/ccaudit/src/__tests__/preflight-retry.test.ts @@ -0,0 +1,412 @@ +/** + * Phase 3.2 — Preflight retry loop + HOOKS hidden + advisory (SC5, SC5a/b, SC6, SC7). + * + * SC5 (byte-identical stderr across BOTH CLI paths): The interactive entry + * preflight AND the non-interactive --dangerously-bust-ghosts switch + * case emit the SAME rendered copy — both assertions compare stderr + * against the SAME renderRunningProcessMessage(input) output using + * `.toContain(...)`, guaranteeing "A vs B" equivalence. A future + * reformatter that drifts the wording will break at least one of the + * two assertions. + * SC5b (selection preservation — BOTH entry and bust-time retry paths): + * After the picker opens and the user confirms, if bust-time preflight + * trips, retrying preserves the original selectedItems Set verbatim — + * no picker re-open, no selection loss. The test drives the wrapped + * detector through multiple fake-dirty invocations and asserts: + * (a) stderr contains "Retry preflight?" AND "Retry bust?" + * (b) diagnostic marker [PREFLIGHT_DIRTY] appears ≥ 4 times + * (c) final manifest's selection_filter.ids matches the original + * 2-item selection verbatim (no picker re-open, no loss) + * SC6 (HOOKS hidden): A fixture with hook ghosts renders a tab bar that + * does NOT include "HOOKS". + * SC7 (advisory suppression): `ccaudit ghost` text mode prints the + * advisory once; --json/--csv/--quiet/--ci modes all suppress it. + */ +import { describe, it, expect, beforeAll, beforeEach, afterEach } from 'vitest'; +import { existsSync } from 'node:fs'; +import { mkdir, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + makeTmpHome, + cleanupTmpHome, + buildFakePs, + buildManyGhostsFixture, + runCcauditGhost, + runCcauditCli, + sendKeys, + listManifestsDir, + agentItemId, +} from './_test-helpers.ts'; +import { readManifest } from '@ccaudit/internal'; +import { renderRunningProcessMessage, type RunningProcessInput } from '@ccaudit/terminal'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const distPath = path.resolve(here, '..', '..', 'dist', 'index.js'); + +beforeAll(() => { + if (!existsSync(distPath)) { + throw new Error(`dist binary not found at ${distPath}. Run \`pnpm -F ccaudit build\` first.`); + } +}); + +/** Wait until the buffer matches `pattern` or timeout elapses. W1 fix — single-form. */ +async function waitFor(getBuf: () => string, pattern: RegExp, timeoutMs: number): Promise { + const start = Date.now(); + while (!pattern.test(getBuf())) { + if (Date.now() - start > timeoutMs) { + throw new Error( + `Timed out waiting for ${pattern} after ${timeoutMs}ms. Got: ${getBuf().slice(-300)}`, + ); + } + await new Promise((r) => setTimeout(r, 50)); + } +} + +describe.skipIf(process.platform === 'win32')( + 'Phase 3.2 — preflight retry + HOOKS + advisory', + () => { + let tmpHome: string; + + beforeEach(async () => { + tmpHome = await makeTmpHome(); + await buildFakePs(tmpHome); // default: empty processes output (preflight clear) + }); + + afterEach(async () => { + await cleanupTmpHome(tmpHome); + }); + + // ───────────────────────────────────────────────────────────────────── + // SC5 — byte-identical preflight copy across BOTH CLI paths (B1 fix) + // ───────────────────────────────────────────────────────────────────── + it( + 'SC5: renderRunningProcessMessage output appears verbatim in BOTH the non-interactive and interactive entry preflight stderr', + { timeout: 30_000 }, + async () => { + // Seed ghosts so the plan is non-empty and the entry preflight runs before the picker. + await buildManyGhostsFixture(tmpHome, 1); + // Checkpoint for the non-interactive path. + await runCcauditCli(tmpHome, ['ghost', '--dry-run', '--yes-proceed-busting', '--json']); + + // Expected bytes: the pure helper's output for a "pids: 99999" external-pid case. + // CCAUDIT_TEST_PREFLIGHT_DIRTY=1 makes the wrapped runCommand return one synthetic + // claude pid — matching exactly this input shape. + const expectedInput: RunningProcessInput = { selfInvocation: false, pids: [99999] }; + const expected = renderRunningProcessMessage(expectedInput); + + // Path A — Non-interactive --dangerously-bust-ghosts: + // Plan 04 EDIT 4 refactored the switch-case to call the helper. + // CCAUDIT_TEST_PREFLIGHT_DIRTY makes runBust's internal preflight return + // 'running-process' on the first call. + const nonInteractive = await runCcauditCli( + tmpHome, + ['ghost', '--dangerously-bust-ghosts', '--yes-proceed-busting'], + { env: { CCAUDIT_TEST_PREFLIGHT_DIRTY: '1', CCAUDIT_FORCE_TTY: '0' } }, + ); + // running-process is a failure exit code (per bustResultToExitCode) — non-zero expected. + expect(nonInteractive.exitCode).not.toBe(0); + expect(nonInteractive.stderr).toContain(expected); + + // Path B — Interactive entry preflight (plan 04 EDIT 2): + // CCAUDIT_TEST_PREFLIGHT_DIRTY=1 drives the entry preflight to render the same copy. + // NOTE: runCcauditGhost auto-injects `'ghost'` as argv[2] — pass flags only (B1 fix). + const spawned = runCcauditGhost(tmpHome, ['--interactive'], { + env: { + CCAUDIT_FORCE_TTY: '1', + CCAUDIT_TEST_PREFLIGHT_DIRTY: '1', + LINES: '24', + COLUMNS: '80', + }, + timeout: 20_000, + }); + let bufStdout = ''; + let bufStderr = ''; + spawned.child.stdout!.on('data', (c: Buffer) => { + bufStdout += c.toString(); + }); + spawned.child.stderr!.on('data', (c: Buffer) => { + bufStderr += c.toString(); + }); + + // Wait for the retry prompt — @clack/prompts.confirm writes to stdout, + // while the preflight copy goes to stderr. When the stdout prompt is + // visible the stderr copy has already been written. + await waitFor(() => bufStdout, /Retry preflight\?/, 10_000); + + // Press 'n' to cancel — clack's ConfirmPrompt resolves on 'n' alone. + await sendKeys(spawned.child, ['n']); + spawned.child.stdin!.end(); // CRITICAL: prevent clack event-loop pin. + + const interactiveResult = await spawned.done; + expect(interactiveResult.exitCode).toBe(0); // cancel → exit 0 ("No changes made.") + // SC5 enforcement: the SAME expected string appears in BOTH paths' stderr. + expect(bufStderr).toContain(expected); + }, + ); + + // ───────────────────────────────────────────────────────────────────── + // SC5b — selection preservation across BOTH entry AND bust-time retry (Option A) + // ───────────────────────────────────────────────────────────────────── + it( + 'SC5b: selectedItems survives across entry-preflight retry AND bust-time runBust retry; [PREFLIGHT_DIRTY] marker confirms runBust actually returned running-process', + { timeout: 90_000 }, + async () => { + // Fixture: 2 ghost agents. + await buildManyGhostsFixture(tmpHome, 2); + + // Dry run to write the checkpoint. + const dry = await runCcauditCli(tmpHome, [ + 'ghost', + '--dry-run', + '--yes-proceed-busting', + '--json', + ]); + expect(dry.exitCode, `dry-run stderr: ${dry.stderr}`).toBe(0); + + // Accounting — CCAUDIT_TEST_PREFLIGHT_DIRTY= gives EACH layer's wrapped + // detector its own counter of N synthetic dirty calls. With N=2 the entry + // preflight: initial detect (call 1, dirty) → loop uses initialResult, prompts, + // user confirms → re-detect (call 2, dirty), prompts, confirms → re-detect + // (call 3, CLEAN) → clears. That exercises 2 confirms in the entry layer. + // The bust-time layer (separate counter with N=2): runBust's internal detect + // (call 1, dirty) → returns 'running-process' → CLI retry loop prompts, user + // confirms → re-detect (call 2, dirty) → prompts, confirms → re-detect + // (call 3, CLEAN) → returns 'clear' → runBust re-invoked, succeeds. Total + // marker count across both layers: ≥ 4 (2 from entry + 2 from bust, not + // counting the optional initial caller call in entry). + const spawned = runCcauditGhost(tmpHome, ['--interactive'], { + env: { + CCAUDIT_FORCE_TTY: '1', + CCAUDIT_TEST_PREFLIGHT_DIRTY: '2', + LINES: '24', + COLUMNS: '80', + }, + timeout: 60_000, + }); + + let stdoutBuf = ''; + let stderrBuf = ''; + spawned.child.stdout!.on('data', (c: Buffer) => { + stdoutBuf += c.toString(); + }); + spawned.child.stderr!.on('data', (c: Buffer) => { + stderrBuf += c.toString(); + }); + + // Drive the entry retry loop. Count "fresh prompt" events by the number + // of `◆ Retry preflight?` markers in stdout — clack writes this exact + // glyph when a NEW prompt becomes interactive (not a repaint). Each time + // this count increments, press 'y\r' once. When the counter plateaus + // either AGENTS appears (entry cleared, picker ready) or the test times + // out. + const activePromptRe = /◆\s+Retry preflight\?/g; + const driveDeadline = Date.now() + 30_000; + let entryRetryConfirms = 0; + let lastPromptCount = 0; + while (Date.now() < driveDeadline) { + if (/AGENTS/.test(stdoutBuf)) break; // picker is ready + const promptCount = (stdoutBuf.match(activePromptRe) ?? []).length; + if (promptCount > lastPromptCount) { + // clack's ConfirmPrompt resolves on 'y' alone (emits "confirm" with true); + // sending '\r' in addition would leak Enter to the NEXT prompt and commit + // its default value (No). Send ONLY 'y'. + await sendKeys(spawned.child, ['y'], 120); + entryRetryConfirms++; + lastPromptCount = promptCount; + // Wait a beat for the confirm to resolve and the next detect to fire. + await new Promise((r) => setTimeout(r, 500)); + continue; + } + await new Promise((r) => setTimeout(r, 150)); + } + expect(entryRetryConfirms).toBeGreaterThanOrEqual(1); + await waitFor(() => stdoutBuf, /AGENTS/, 15_000); + + // Select the 2 agents: Space, ArrowDown, Space, Enter. + await sendKeys(spawned.child, [' ', '\x1b[B', ' ', '\r']); + + // Wait for the "Proceed?" confirmation — fail loudly on timeout (WARNING fix). + await waitFor(() => stdoutBuf, /Proceed\?|Archiving/, 10_000); + // clack ConfirmPrompt resolves on 'y' alone. Do NOT send '\r' — that would + // leak an Enter to the NEXT clack prompt ('Retry bust?') which commits its + // default value (No). + await sendKeys(spawned.child, ['y'], 120); + + // Bust runs. With remaining dirty calls, runBust's internal preflight returns + // 'running-process' → CLI retry loop prompts "Retry bust?". Count fresh + // prompt events via the `◆ Retry bust?` glyph, identical strategy to + // the entry loop. + const bustPromptRe = /◆\s+Retry bust\?/g; + const bustDeadline = Date.now() + 30_000; + let bustRetryConfirms = 0; + let lastBustPromptCount = 0; + while (Date.now() < bustDeadline) { + const promptCount = (stdoutBuf.match(bustPromptRe) ?? []).length; + if (promptCount > lastBustPromptCount) { + // clack's ConfirmPrompt resolves on 'y' alone (emits "confirm" with true); + // sending '\r' in addition would leak Enter to the NEXT prompt and commit + // its default value (No). Send ONLY 'y'. + await sendKeys(spawned.child, ['y'], 120); + bustRetryConfirms++; + lastBustPromptCount = promptCount; + await new Promise((r) => setTimeout(r, 500)); + continue; + } + if (spawned.child.exitCode !== null) break; + await new Promise((r) => setTimeout(r, 150)); + } + expect(bustRetryConfirms).toBeGreaterThanOrEqual(1); + + spawned.child.stdin!.end(); // CRITICAL: prevent clack event-loop pin. + + const result = await spawned.done; + expect(result.exitCode, `result stderr: ${stderrBuf.slice(-500)}`).toBe(0); + + // ── Assertions ───────────────────────────────────────────────── + // (a) BOTH retry layers fired. clack-prompts writes the confirmation + // prompts to stdout; the preflight copy itself goes to stderr. + expect(stdoutBuf).toContain('Retry preflight?'); + expect(stdoutBuf).toContain('Retry bust?'); + + // (b) Diagnostic marker count (B2 fix): the wrapped detector fired at least 4 + // times across both layers. Proves bust-time preflight inside runBust + // actually returned 'running-process' — without this, only the CLI-layer + // retry could have tripped. + const markerCount = (stderrBuf.match(/\[PREFLIGHT_DIRTY\] synthetic dirty/g) ?? []).length; + expect(markerCount).toBeGreaterThanOrEqual(4); + + // (c) Manifest reflects the original 2-item subset selection (no picker re-open + // between retries — selectedItems preserved through BOTH retry layers). + const manifests = await listManifestsDir(tmpHome); + expect(manifests.length).toBe(1); + const manifestFullPath = path.join( + tmpHome, + '.claude', + 'ccaudit', + 'manifests', + manifests[0]!, + ); + const manifest = await readManifest(manifestFullPath); + expect(manifest.header!.selection_filter!.mode).toBe('subset'); + const selection = manifest.header!.selection_filter as { + mode: 'subset'; + ids: string[]; + }; + expect(selection.ids.length).toBe(2); + const expected1 = agentItemId(tmpHome, 'agent-01.md'); + const expected2 = agentItemId(tmpHome, 'agent-02.md'); + const actualSorted = [...selection.ids].sort(); + const expectedSorted = [expected1, expected2].sort(); + expect(actualSorted).toEqual(expectedSorted); + }, + ); + + // ───────────────────────────────────────────────────────────────────── + // SC6 — HOOKS tab absent from the picker when hook ghosts exist + // ───────────────────────────────────────────────────────────────────── + it( + 'SC6: fixture with hook ghosts produces a tab bar that does NOT include HOOKS', + { timeout: 30_000 }, + async () => { + await buildManyGhostsFixture(tmpHome, 1); + const settingsPath = path.join(tmpHome, '.claude', 'settings.json'); + await writeFile( + settingsPath, + JSON.stringify({ + hooks: { + PreToolUse: [ + { matcher: '.*', hooks: [{ type: 'command', command: 'echo stale-hook' }] }, + ], + }, + }), + 'utf8', + ); + + await runCcauditCli(tmpHome, ['ghost', '--dry-run', '--yes-proceed-busting', '--json']); + + // NOTE: runCcauditGhost auto-injects `'ghost'` as argv[2] — pass flags only (B1 fix). + const spawned = runCcauditGhost(tmpHome, ['--interactive'], { + env: { CCAUDIT_FORCE_TTY: '1', LINES: '24', COLUMNS: '100' }, + timeout: 15_000, + }); + let stdoutBuf = ''; + spawned.child.stdout!.on('data', (c: Buffer) => { + stdoutBuf += c.toString(); + }); + + await waitFor(() => stdoutBuf, /AGENTS/, 8_000); + + expect(stdoutBuf).toMatch(/AGENTS/); + expect(stdoutBuf).not.toMatch(/\bHOOKS\b/); + + await sendKeys(spawned.child, ['\x1b']); // Esc to cancel + spawned.child.stdin!.end(); + const result = await spawned.done; + expect(result.exitCode).toBe(0); + }, + ); + + // ───────────────────────────────────────────────────────────────────── + // SC7 — advisory surfaced once; suppressed under --json / --csv / --quiet / --ci (B4 fix) + // ───────────────────────────────────────────────────────────────────── + it( + 'SC7: "Hook archival deferred" appears in text-mode output; is ABSENT under --json, --csv, --quiet, AND --ci', + { timeout: 60_000 }, + async () => { + await mkdir(path.join(tmpHome, '.claude'), { recursive: true }); + await writeFile(path.join(tmpHome, '.claude.json'), '{}', 'utf8'); + await writeFile( + path.join(tmpHome, '.claude', 'settings.json'), + JSON.stringify({ + hooks: { + PreToolUse: [ + { matcher: '.*', hooks: [{ type: 'command', command: 'echo stale-hook' }] }, + ], + }, + }), + 'utf8', + ); + const sessionDir = path.join(tmpHome, '.claude', 'projects', 'advisory-test'); + await mkdir(sessionDir, { recursive: true }); + await writeFile( + path.join(sessionDir, 'session-1.jsonl'), + JSON.stringify({ + type: 'system', + subtype: 'init', + cwd: '/fake', + timestamp: new Date().toISOString(), + sessionId: 'adv', + }) + '\n', + 'utf8', + ); + + // Text mode → advisory present. + const text = await runCcauditCli(tmpHome, ['ghost']); + expect(text.stdout).toContain( + 'Hook archival deferred — selectable archive coming in a future phase', + ); + + // JSON mode → advisory absent (stdout AND stderr). + const json = await runCcauditCli(tmpHome, ['ghost', '--json']); + expect(json.stdout).not.toContain('Hook archival deferred'); + expect(json.stderr).not.toContain('Hook archival deferred'); + + // CSV mode → advisory absent (B4 fix: was missing). + const csv = await runCcauditCli(tmpHome, ['ghost', '--csv']); + expect(csv.stdout).not.toContain('Hook archival deferred'); + expect(csv.stderr).not.toContain('Hook archival deferred'); + + // Quiet mode → advisory absent. + const quiet = await runCcauditCli(tmpHome, ['ghost', '--quiet']); + expect(quiet.stdout).not.toContain('Hook archival deferred'); + expect(quiet.stderr).not.toContain('Hook archival deferred'); + + // CI mode → advisory absent (B4 fix: was missing). + const ci = await runCcauditCli(tmpHome, ['ghost', '--ci']); + expect(ci.stdout).not.toContain('Hook archival deferred'); + expect(ci.stderr).not.toContain('Hook archival deferred'); + }, + ); + }, +); diff --git a/apps/ccaudit/src/__tests__/purge-archive.test.ts b/apps/ccaudit/src/__tests__/purge-archive.test.ts new file mode 100644 index 0000000..0eaa599 --- /dev/null +++ b/apps/ccaudit/src/__tests__/purge-archive.test.ts @@ -0,0 +1,562 @@ +/** + * Phase 9 SC6 — `ccaudit purge-archive` integration test. + * + * Fixture shapes (archive op classes): + * A: archive exists, source FREE → reclaim candidate + * B: archive exists, source OCCUPIED → drop / source_occupied + * C: archive MISSING, source EXISTS → drop / stale_archive_missing (Phase 8.2) + * D: archive MISSING, source MISSING → skip (both_missing, preserved) + * + * Asserts per Plan 09-04: + * 1. Default (no flags) is dry-run; exit 0; stdout mentions each class with + * correct classification; manifests dir unchanged; archive dir unchanged. + * 2. `--yes` real purge: + * - A moved back to source (archive gone, source now present) + * - B archive unlinked; source untouched + * - C no disk mutation (file already gone); follow-up op still appended + * - D untouched (skip) + * A single purge-*.jsonl manifest is appended. + * 3. `--json --yes` envelope: purge.summary counts match, failures: []. + * 4. `--dry-run --yes` → exit 1 with "mutually exclusive" error. + * 5. CCAUDIT_NO_INTERACTIVE=1 orthogonality (command is non-interactive, + * env var must not interfere with dry-run). + * 6. CCAUDIT_NO_HISTORY=1 → no entry appended to history.jsonl after --yes. + * 7. Failure path: --yes with one unwritable archive path surfaces a + * failures[] entry but does NOT abort the batch and exit remains 0. + * 8. NEW-M2: corrupt manifest is skipped with warning; valid manifests are + * still processed and exit code remains 0. + * 9. NEW-M2: all manifests corrupt → graceful empty plan, exit 0, no crash. + */ +import { describe, it, expect, beforeAll, beforeEach, afterEach } from 'vitest'; +import { existsSync } from 'node:fs'; +import { mkdir, writeFile, readFile, readdir, chmod } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { makeTmpHome, cleanupTmpHome, runCcauditCli } from './_test-helpers.ts'; +import { + stageAlreadyPurgedFixture, + stagePurgeMixedFixture, +} from './fixtures/manual-qa-followups.ts'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const distPath = path.resolve(here, '..', '..', 'dist', 'index.js'); + +beforeAll(() => { + if (!existsSync(distPath)) { + throw new Error(`dist binary not found at ${distPath}. Run \`pnpm -F ccaudit build\` first.`); + } +}); + +// -- Fixture helpers ---------------------------------------------------- + +interface ArchiveOpSpec { + /** Agent name used to build source + archive paths under `/.claude/agents/.md`. */ + name: string; + /** Write the archive file to disk. */ + archiveOnDisk: boolean; + /** Write the source file to disk. */ + sourceOnDisk: boolean; +} + +/** + * Stage a single manifest JSONL containing one archive op per entry. + * + * Each spec produces paths: + * source_path = /.claude/agents/.md + * archive_path = /.claude/ccaudit/archived/.claude/agents/.md + * + * (Layout mirrors Phase 8.2 stale-filter fixture + reclaim-command.test.ts.) + */ +async function stageMixedFixture(tmpHome: string, specs: ArchiveOpSpec[]): Promise { + const manifestsDir = path.join(tmpHome, '.claude', 'ccaudit', 'manifests'); + const archivedAgentsDir = path.join( + tmpHome, + '.claude', + 'ccaudit', + 'archived', + '.claude', + 'agents', + ); + const agentsDir = path.join(tmpHome, '.claude', 'agents'); + await mkdir(manifestsDir, { recursive: true }); + await mkdir(archivedAgentsDir, { recursive: true }); + await mkdir(agentsDir, { recursive: true }); + + const header = { + record_type: 'header', + manifest_version: 1, + ccaudit_version: '1.5.0-test', + checkpoint_ghost_hash: 'fixture-purge', + checkpoint_timestamp: '2026-04-22T09:00:00.000Z', + since_window: '30d', + os: 'darwin', + node_version: 'v20.0.0', + planned_ops: { archive: specs.length, disable: 0, flag: 0 }, + selection_filter: { mode: 'full' }, + }; + const ops = specs.map((s) => ({ + op_id: `op-${s.name}`, + op_type: 'archive', + timestamp: '2026-04-22T09:00:00.000Z', + status: 'completed', + category: 'agent', + scope: 'global', + source_path: path.join(agentsDir, `${s.name}.md`), + archive_path: path.join(archivedAgentsDir, `${s.name}.md`), + content_sha256: '0000000000000000000000000000000000000000000000000000000000000001', + })); + const footer = { + record_type: 'footer', + status: 'completed', + actual_ops: { + archive: { completed: specs.length, failed: 0 }, + disable: { completed: 0, failed: 0 }, + flag: { completed: 0, failed: 0, refreshed: 0, skipped: 0 }, + }, + duration_ms: 1, + exit_code: 0, + }; + const body = + [JSON.stringify(header), ...ops.map((o) => JSON.stringify(o)), JSON.stringify(footer)].join( + '\n', + ) + '\n'; + await writeFile( + path.join(manifestsDir, 'bust-2026-04-22T09-00-00-000Z-fixt.jsonl'), + body, + 'utf8', + ); + + for (const s of specs) { + if (s.archiveOnDisk) { + await writeFile( + path.join(archivedAgentsDir, `${s.name}.md`), + `# ${s.name} (archived)\n`, + 'utf8', + ); + } + if (s.sourceOnDisk) { + await writeFile(path.join(agentsDir, `${s.name}.md`), `# ${s.name} (at source)\n`, 'utf8'); + } + } +} + +const MIXED_SPECS: ArchiveOpSpec[] = [ + // A: reclaim (archive-only) + { name: 'a-reclaim', archiveOnDisk: true, sourceOnDisk: false }, + // B: drop/source_occupied (both) + { name: 'b-occupied', archiveOnDisk: true, sourceOnDisk: true }, + // C: drop/stale_archive_missing (source-only) + { name: 'c-stale', archiveOnDisk: false, sourceOnDisk: true }, + // D: skip/both_missing (neither) + { name: 'd-broken', archiveOnDisk: false, sourceOnDisk: false }, +]; + +function agentsPath(tmpHome: string, name: string): string { + return path.join(tmpHome, '.claude', 'agents', `${name}.md`); +} +function archivePath(tmpHome: string, name: string): string { + return path.join(tmpHome, '.claude', 'ccaudit', 'archived', '.claude', 'agents', `${name}.md`); +} +function manifestsDir(tmpHome: string): string { + return path.join(tmpHome, '.claude', 'ccaudit', 'manifests'); +} +function historyPath(tmpHome: string): string { + return path.join(tmpHome, '.claude', 'ccaudit', 'history.jsonl'); +} + +async function countPurgeManifests(tmpHome: string): Promise { + const entries = await readdir(manifestsDir(tmpHome)); + return entries.filter((m) => m.startsWith('purge-') && m.endsWith('.jsonl')).length; +} + +// -- Tests -------------------------------------------------------------- + +describe.skipIf(process.platform === 'win32')('ccaudit purge-archive (Phase 9 SC6)', () => { + let tmpHome: string; + + beforeEach(async () => { + tmpHome = await makeTmpHome(); + await stageMixedFixture(tmpHome, MIXED_SPECS); + }); + + afterEach(async () => { + await cleanupTmpHome(tmpHome); + }); + + // -- 1. Dry-run default --------------------------------------------- + + it('default (no flags) runs in dry-run: classifies 4 ops, writes nothing', async () => { + const before = await readdir(manifestsDir(tmpHome)); + const r = await runCcauditCli(tmpHome, ['purge-archive']); + expect(r.exitCode, `stderr:\n${r.stderr}\nstdout:\n${r.stdout}`).toBe(0); + + // Each classification surfaces in stdout with its marker. + expect(r.stdout).toContain('a-reclaim'); + expect(r.stdout).toContain('b-occupied'); + expect(r.stdout).toContain('source_occupied'); + expect(r.stdout).toContain('c-stale'); + expect(r.stdout).toContain('stale_archive_missing'); + expect(r.stdout).toContain('d-broken'); + expect(r.stdout).toContain('both_missing'); + expect(r.stdout).toMatch(/Dry-run/i); + + // Manifests dir unchanged. + const after = await readdir(manifestsDir(tmpHome)); + expect(after.sort()).toEqual(before.sort()); + + // Archive + source layout unchanged. + expect(existsSync(archivePath(tmpHome, 'a-reclaim'))).toBe(true); + expect(existsSync(archivePath(tmpHome, 'b-occupied'))).toBe(true); + expect(existsSync(agentsPath(tmpHome, 'b-occupied'))).toBe(true); + }); + + // -- 2. --yes real purge -------------------------------------------- + + it('--yes executes the plan: reclaim + drop + stale follow-up + skip preserved', async () => { + const r = await runCcauditCli(tmpHome, ['purge-archive', '--yes']); + expect(r.exitCode, `stderr:\n${r.stderr}\nstdout:\n${r.stdout}`).toBe(0); + expect(r.stdout).toMatch(/reclaimed/); + expect(r.stdout).toMatch(/purged/); + + // A: archive gone, source present + expect(existsSync(archivePath(tmpHome, 'a-reclaim'))).toBe(false); + expect(existsSync(agentsPath(tmpHome, 'a-reclaim'))).toBe(true); + + // B: archive gone, source still intact (must NOT be overwritten) + expect(existsSync(archivePath(tmpHome, 'b-occupied'))).toBe(false); + expect(existsSync(agentsPath(tmpHome, 'b-occupied'))).toBe(true); + const bContents = await readFile(agentsPath(tmpHome, 'b-occupied'), 'utf8'); + expect(bContents).toBe('# b-occupied (at source)\n'); + + // C: no archive to begin with; source untouched + expect(existsSync(archivePath(tmpHome, 'c-stale'))).toBe(false); + expect(existsSync(agentsPath(tmpHome, 'c-stale'))).toBe(true); + + // D: both still missing + expect(existsSync(archivePath(tmpHome, 'd-broken'))).toBe(false); + expect(existsSync(agentsPath(tmpHome, 'd-broken'))).toBe(false); + + // Exactly one purge-*.jsonl manifest was appended. + const manifests = await readdir(manifestsDir(tmpHome)); + const purgeManifests = manifests.filter((m) => m.startsWith('purge-') && m.endsWith('.jsonl')); + expect(purgeManifests).toHaveLength(1); + }); + + it('is idempotent: a second --yes no-ops and writes no duplicate purge manifest', async () => { + await cleanupTmpHome(tmpHome); + tmpHome = await makeTmpHome(); + await stagePurgeMixedFixture(tmpHome); + + const first = await runCcauditCli(tmpHome, ['purge-archive', '--yes']); + expect(first.exitCode, `stderr:\n${first.stderr}\nstdout:\n${first.stdout}`).toBe(0); + expect(await countPurgeManifests(tmpHome)).toBe(1); + + const second = await runCcauditCli(tmpHome, ['purge-archive', '--yes']); + expect(second.exitCode, `stderr:\n${second.stderr}\nstdout:\n${second.stdout}`).toBe(0); + expect(second.stdout).not.toContain('a-reclaim [stale_archive_missing]'); + expect(second.stdout).not.toContain('b-occupied [stale_archive_missing]'); + expect(second.stdout).not.toContain('c-stale [stale_archive_missing]'); + expect(second.stdout).toMatch(/Summary: 0 reclaimed, 0 purged\./); + expect(await countPurgeManifests(tmpHome)).toBe(1); + + const listed = await runCcauditCli(tmpHome, ['restore', '--list']); + expect(listed.exitCode, `stderr:\n${listed.stderr}\nstdout:\n${listed.stdout}`).toBe(0); + expect(listed.stdout).not.toContain('a-reclaim'); + expect(listed.stdout).not.toContain('b-occupied'); + expect(listed.stdout).not.toContain('c-stale'); + }); + + it('already-purged fixture has no new purge candidates and no duplicate manifest', async () => { + await cleanupTmpHome(tmpHome); + tmpHome = await makeTmpHome(); + await stageAlreadyPurgedFixture(tmpHome); + const before = await countPurgeManifests(tmpHome); + + const r = await runCcauditCli(tmpHome, ['purge-archive', '--yes']); + expect(r.exitCode, `stderr:\n${r.stderr}\nstdout:\n${r.stdout}`).toBe(0); + expect(r.stdout).not.toContain('a-reclaim [stale_archive_missing]'); + expect(r.stdout).not.toContain('b-occupied [stale_archive_missing]'); + expect(r.stdout).not.toContain('c-stale [stale_archive_missing]'); + expect(r.stdout).toMatch(/Summary: 0 reclaimed, 0 purged\./); + expect(await countPurgeManifests(tmpHome)).toBe(before); + }); + + // -- 3. JSON envelope ------------------------------------------------ + + it('--json --yes emits envelope with purge.summary matching classification', async () => { + const r = await runCcauditCli(tmpHome, ['purge-archive', '--json', '--yes']); + expect(r.exitCode, `stderr:\n${r.stderr}\nstdout:\n${r.stdout}`).toBe(0); + + const env = JSON.parse(r.stdout.trim()) as { + meta: { command: string; exitCode: number }; + purge: { + summary: { + purgedCount: number; + reclaimedCount: number; + skippedOccupiedCount: number; + staleFilteredCount: number; + }; + failures: Array<{ path: string; reason: string }>; + dryRun: boolean; + manifestPath: string | null; + }; + }; + expect(env.meta.command).toBe('purge-archive'); + expect(env.purge.dryRun).toBe(false); + expect(env.purge.summary).toEqual({ + purgedCount: 2, + reclaimedCount: 1, + skippedOccupiedCount: 1, + staleFilteredCount: 1, + }); + expect(env.purge.failures).toEqual([]); + expect(env.purge.manifestPath).toMatch(/purge-.+\.jsonl$/); + }); + + // -- 4. Mutual exclusion -------------------------------------------- + + it('--dry-run --yes → exit 1 with mutual-exclusion error and records history', async () => { + const r = await runCcauditCli(tmpHome, ['purge-archive', '--dry-run', '--yes']); + expect(r.exitCode).toBe(1); + expect(r.stderr).toMatch(/mutually exclusive/); + const history = await readFile(historyPath(tmpHome), 'utf8'); + expect(history).toContain('"command":"purge-archive"'); + expect(history).toContain('"exit_code":1'); + }); + + it('--dry-run --yes --json → exit 1 with structured envelope', async () => { + const r = await runCcauditCli(tmpHome, ['purge-archive', '--dry-run', '--yes', '--json']); + expect(r.exitCode).toBe(1); + expect(r.stderr).toBe(''); + const env = JSON.parse(r.stdout.trim()) as { + meta: { command: string; exitCode: number }; + purge: { + failures: Array<{ path: string; reason: string }>; + dryRun: boolean; + manifestPath: string | null; + }; + }; + expect(env.meta.command).toBe('purge-archive'); + expect(env.meta.exitCode).toBe(1); + expect(env.purge.dryRun).toBe(true); + expect(env.purge.manifestPath).toBeNull(); + expect(env.purge.failures[0]?.reason).toMatch(/mutually exclusive/); + }); + + // -- 5. CCAUDIT_NO_INTERACTIVE orthogonality ------------------------- + + it('CCAUDIT_NO_INTERACTIVE=1 does not interfere with --dry-run', async () => { + const r = await runCcauditCli(tmpHome, ['purge-archive', '--dry-run'], { + env: { CCAUDIT_NO_INTERACTIVE: '1' }, + }); + expect(r.exitCode).toBe(0); + expect(r.stdout).toMatch(/Dry-run/i); + }); + + // -- 6. History opt-out --------------------------------------------- + + it('CCAUDIT_NO_HISTORY=1 suppresses history write on --yes', async () => { + const r = await runCcauditCli(tmpHome, ['purge-archive', '--yes'], { + env: { CCAUDIT_NO_HISTORY: '1' }, + }); + expect(r.exitCode).toBe(0); + expect(existsSync(historyPath(tmpHome))).toBe(false); + }); + + // -- 8. NEW-M2: corrupt manifest skipped, valid manifests processed ---- + + it('NEW-M2: one corrupt manifest emits warning but valid manifests are still classified (exit 0)', async () => { + // Write a second valid manifest with one extra reclaim-able item. + const manifestsDir2 = path.join(tmpHome, '.claude', 'ccaudit', 'manifests'); + const archivedAgentsDir = path.join( + tmpHome, + '.claude', + 'ccaudit', + 'archived', + '.claude', + 'agents', + ); + const agentsDir = path.join(tmpHome, '.claude', 'agents'); + + // Second valid manifest: one archive op for item 'e-extra' (reclaim candidate). + const header2 = { + record_type: 'header', + manifest_version: 1, + ccaudit_version: '1.5.0-test', + checkpoint_ghost_hash: 'fixture-corrupt-test', + checkpoint_timestamp: '2026-04-23T10:00:00.000Z', + since_window: '30d', + os: 'darwin', + node_version: 'v20.0.0', + planned_ops: { archive: 1, disable: 0, flag: 0 }, + selection_filter: { mode: 'full' }, + }; + const op2 = { + op_id: 'op-e-extra', + op_type: 'archive', + timestamp: '2026-04-23T10:00:00.000Z', + status: 'completed', + category: 'agent', + scope: 'global', + source_path: path.join(agentsDir, 'e-extra.md'), + archive_path: path.join(archivedAgentsDir, 'e-extra.md'), + content_sha256: '0000000000000000000000000000000000000000000000000000000000000002', + }; + const footer2 = { + record_type: 'footer', + status: 'completed', + actual_ops: { + archive: { completed: 1, failed: 0 }, + disable: { completed: 0, failed: 0 }, + flag: { completed: 0, failed: 0, refreshed: 0, skipped: 0 }, + }, + duration_ms: 1, + exit_code: 0, + }; + await writeFile( + path.join(manifestsDir2, 'bust-2026-04-23T10-00-00-000Z-valid2.jsonl'), + [JSON.stringify(header2), JSON.stringify(op2), JSON.stringify(footer2)].join('\n') + '\n', + 'utf8', + ); + // Place e-extra in the archive (reclaim candidate). + await writeFile(path.join(archivedAgentsDir, 'e-extra.md'), '# e-extra (archived)\n', 'utf8'); + + // Write a corrupt manifest: invalid JSON on line 2 (not the last line), which forces a + // parse error because readManifest only tolerates truncated JSON on the *last* line. + const corruptPath = path.join(manifestsDir2, 'bust-2026-04-23T11-00-00-000Z-corrupt.jsonl'); + await writeFile( + corruptPath, + JSON.stringify({ record_type: 'header', manifest_version: 1 }) + + '\nNOT VALID JSON\n' + + JSON.stringify({ record_type: 'footer', status: 'completed' }) + + '\n', + 'utf8', + ); + + const r = await runCcauditCli(tmpHome, ['purge-archive', '--dry-run', '--json']); + + // stderr must warn about the corrupt manifest + expect(r.stderr).toContain('[ccaudit] warning: skipping unreadable manifest'); + expect(r.stderr).toContain(corruptPath); + + // stdout must be valid JSON + expect(r.exitCode, `stderr:\n${r.stderr}\nstdout:\n${r.stdout}`).toBe(0); + const env = JSON.parse(r.stdout.trim()) as { + meta: { exitCode: number }; + purge: { + summary: { purgedCount: number; reclaimedCount: number }; + failures: unknown[]; + dryRun: boolean; + manifestErrors?: Array<{ path: string; reason: string }>; + }; + }; + expect(env.meta.exitCode).toBe(0); + expect(env.purge.dryRun).toBe(true); + + // Classification from valid manifests (original 4 + e-extra) must be present. + // reclaimedCount reflects a-reclaim + e-extra = 2. + expect(env.purge.summary.reclaimedCount).toBeGreaterThanOrEqual(1); + + // manifestErrors field surfaces the skipped corrupt manifest. + expect(env.purge.manifestErrors).toBeDefined(); + expect(env.purge.manifestErrors!.length).toBeGreaterThanOrEqual(1); + expect(env.purge.manifestErrors![0]!.path).toBe(corruptPath); + + // Filesystem must be untouched (--dry-run). + expect(existsSync(archivePath(tmpHome, 'a-reclaim'))).toBe(true); + expect(existsSync(path.join(archivedAgentsDir, 'e-extra.md'))).toBe(true); + }); + + // -- 9. NEW-M2: all manifests corrupt → graceful empty plan, exit 0 ---- + + it('NEW-M2: all manifests corrupt → 3 stderr warnings, empty plan, exit 0', async () => { + // Replace the staged manifest with 3 corrupt manifests. + await cleanupTmpHome(tmpHome); + tmpHome = await makeTmpHome(); + const manifestsDir3 = path.join(tmpHome, '.claude', 'ccaudit', 'manifests'); + await mkdir(manifestsDir3, { recursive: true }); + + const corruptPaths: string[] = []; + for (let i = 1; i <= 3; i++) { + const p = path.join(manifestsDir3, `bust-2026-04-24T0${i}-00-00-000Z-bad.jsonl`); + await writeFile(p, `NOT JSON LINE ${i}\n{"partial":\n`, 'utf8'); + corruptPaths.push(p); + } + + const r = await runCcauditCli(tmpHome, ['purge-archive', '--dry-run', '--json']); + + // Three warnings emitted to stderr. + for (const cp of corruptPaths) { + expect(r.stderr).toContain(cp); + } + const warningCount = ( + r.stderr.match(/\[ccaudit\] warning: skipping unreadable manifest/g) ?? [] + ).length; + expect(warningCount).toBe(3); + + // Must exit 0 — graceful empty plan, not a crash. + expect(r.exitCode, `stderr:\n${r.stderr}\nstdout:\n${r.stdout}`).toBe(0); + + // stdout is a valid JSON envelope with zero counts. + const env = JSON.parse(r.stdout.trim()) as { + meta: { exitCode: number }; + purge: { + summary: { + purgedCount: number; + reclaimedCount: number; + skippedOccupiedCount: number; + staleFilteredCount: number; + }; + failures: unknown[]; + dryRun: boolean; + manifestErrors?: Array<{ path: string; reason: string }>; + }; + }; + expect(env.meta.exitCode).toBe(0); + expect(env.purge.summary.purgedCount).toBe(0); + expect(env.purge.summary.reclaimedCount).toBe(0); + expect(env.purge.summary.skippedOccupiedCount).toBe(0); + expect(env.purge.summary.staleFilteredCount).toBe(0); + expect(env.purge.failures).toEqual([]); + expect(env.purge.manifestErrors).toBeDefined(); + expect(env.purge.manifestErrors!.length).toBe(3); + }); + + // -- 7. Partial failure path ---------------------------------------- + + it('--yes with an unlink failure records failure[] but still exits 0 for survivors', async () => { + // Make the B archive's parent directory read-only so unlink fails. + // We chmod the file to 0o000 on the parent dir combination; a simpler + // approach is to chmod the archive file itself to 0 then remove write + // on the parent. On macOS/Linux unlink needs write on parent dir. + const parentDir = path.dirname(archivePath(tmpHome, 'b-occupied')); + await chmod(parentDir, 0o500); // r-x only — denies unlink + + try { + const r = await runCcauditCli(tmpHome, ['purge-archive', '--json', '--yes']); + // Restore perms before any assertion failure so afterEach cleanup works. + await chmod(parentDir, 0o755); + + expect(r.exitCode, `stderr:\n${r.stderr}\nstdout:\n${r.stdout}`).toBe(0); + const env = JSON.parse(r.stdout.trim()) as { + purge: { + summary: { purgedCount: number; reclaimedCount: number }; + failures: Array<{ path: string; reason: string }>; + }; + }; + // A (reclaim) + C (stale) should still succeed (A lived in a + // different parent dir — archived/.claude/agents/ — but chmod on it + // prevents BOTH A's rename + B's unlink because they share that dir. + // We only assert the contract: at least 1 failure recorded and the + // batch did NOT abort (exit 0). Survivor count is flexible. + expect(env.purge.failures.length).toBeGreaterThanOrEqual(1); + // Stale branch never touches the archived dir, so it always succeeds + // → staleFilteredCount === 1 in summary regardless. + // Reading from summary fields is more deterministic than from raw counts. + expect(env.purge.summary.reclaimedCount + env.purge.summary.purgedCount).toBeGreaterThan(0); + } finally { + // Guarantee permissions are restored for afterEach rm -rf. + await chmod(parentDir, 0o755); + } + }); +}); diff --git a/apps/ccaudit/src/__tests__/regime-detection-cc-2-1-117-compat.test.ts b/apps/ccaudit/src/__tests__/regime-detection-cc-2-1-117-compat.test.ts new file mode 100644 index 0000000..ed6d130 --- /dev/null +++ b/apps/ccaudit/src/__tests__/regime-detection-cc-2-1-117-compat.test.ts @@ -0,0 +1,112 @@ +/** + * Phase 10 Plan 01 (SC1) — regime-detection golden test. + * + * Pins MCP regime resolution against two layouts of the Claude Code config + * tree: pre-2.1.116 (mcpServers in `~/.claude.json` only) and post-2.1.117 + * (mcpServers also written into `~/.claude/settings.json` per the cc + * 2.1.116/117 mcpServers + hooks loading refactor). Both fixtures encode + * the same semantic inventory (12 stdio servers); the resolver must produce + * a byte-identical regime + reason at fixed inputs (cc 2.1.117, 200K context, + * no override). + * + * If the two snapshots ever diverge, that's drift introduced by a future + * scanner refactor — investigate before re-locking the snapshots. + * + * Self-contained: no real HOME mutation. Uses `makeTmpHome` to copy each + * fixture tree into a fresh tmpdir before invoking `scanMcpServers`. + */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { cp } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { scanMcpServers, resolveMcpRegime, perToolTokens } from '@ccaudit/internal'; +import { makeTmpHome, cleanupTmpHome } from './_test-helpers.ts'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const FIXTURES = path.join(here, '__fixtures__'); + +interface RegimeOutcome { + regime: 'eager' | 'deferred' | 'unknown'; + reason: string; + serverCount: number; + totalMcpToolTokens: number; +} + +async function computeRegimeForFixture( + fixtureDir: string, + tmpHome: string, +): Promise { + // Copy the fixture tree into the tmpHome so scanMcpServers reads from + // an isolated filesystem under our control. The fixture is the SOURCE; + // tmpHome is the SANDBOX. + await cp(fixtureDir, tmpHome, { recursive: true }); + + const claudeConfigPath = path.join(tmpHome, '.claude.json'); + const items = await scanMcpServers(claudeConfigPath, []); + const serverCount = items.length; + + // Scanner does not introspect tool schemas. Each emitted server is + // billed at the eager per-tool rate to feed the resolver. This mirrors + // how the live token pipeline computes totalMcpToolTokens before the + // resolver decides eager vs deferred. + const totalMcpToolTokens = serverCount * perToolTokens('eager'); + + const { regime, reason } = resolveMcpRegime({ + totalMcpToolTokens, + contextWindow: 200_000, + ccVersion: '2.1.117', + override: null, + }); + + return { regime, reason, serverCount, totalMcpToolTokens }; +} + +describe.skipIf(process.platform === 'win32')( + 'Phase 10 SC1 regime detection — cc 2.1.116/117 mcpServers + hooks loading refactor compat', + () => { + let tmpHome: string; + let origHome: string | undefined; + + beforeEach(async () => { + tmpHome = await makeTmpHome(); + origHome = process.env['HOME']; + process.env['HOME'] = tmpHome; + }); + + afterEach(async () => { + if (origHome === undefined) delete process.env['HOME']; + else process.env['HOME'] = origHome; + await cleanupTmpHome(tmpHome); + }); + + it('pre-2.1.116 layout resolves to the locked snapshot', async () => { + const result = await computeRegimeForFixture( + path.join(FIXTURES, 'regime-pre-2-1-116'), + tmpHome, + ); + expect(result).toMatchInlineSnapshot(` + { + "reason": "cc >=2.1.7 but MCP <=10% ctx — ToolSearch not triggered", + "regime": "eager", + "serverCount": 12, + "totalMcpToolTokens": 6000, + } + `); + }); + + it('post-2.1.117 layout resolves to the SAME snapshot (byte-identical regime + reason)', async () => { + const result = await computeRegimeForFixture( + path.join(FIXTURES, 'regime-post-2-1-117'), + tmpHome, + ); + expect(result).toMatchInlineSnapshot(` + { + "reason": "cc >=2.1.7 but MCP <=10% ctx — ToolSearch not triggered", + "regime": "eager", + "serverCount": 12, + "totalMcpToolTokens": 6000, + } + `); + }); + }, +); diff --git a/apps/ccaudit/src/__tests__/restore-after-purge.test.ts b/apps/ccaudit/src/__tests__/restore-after-purge.test.ts new file mode 100644 index 0000000..477f8f3 --- /dev/null +++ b/apps/ccaudit/src/__tests__/restore-after-purge.test.ts @@ -0,0 +1,243 @@ +/** + * RE-M9 follow-up — full-mode `restore` must skip archive ops whose op_id + * was referenced by an `archive_purge` op in a purge manifest. + * + * Before this fix, `executeRestore` full-mode did NOT call collectPurgedOpIds, + * so after a purge it would attempt to restore already-drained archives, fail + * with "archive file missing", and report `partial-success` instead of `success`. + * + * Fixture: + * - One bust manifest: archive ops for items A, B, C. + * A and B archive files are GONE from disk (purged). + * C archive file is present on disk. + * - One purge manifest: archive_purge ops referencing op_ids of A and B. + * + * Expected behaviour after fix: + * - restore --json exits 0 with status === 'success' + * - counts.unarchived.moved === 1 (only C was attempted and moved) + * - counts.unarchived.failed === 0 (A and B are silently skipped, not failed) + * - The archive file for C is restored to its source path. + */ +import { describe, it, expect, beforeAll, beforeEach, afterEach } from 'vitest'; +import { existsSync } from 'node:fs'; +import { mkdir, writeFile, utimes } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { makeTmpHome, cleanupTmpHome, buildFakePs, runCcauditCli } from './_test-helpers.ts'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const distPath = path.resolve(here, '..', '..', 'dist', 'index.js'); + +beforeAll(() => { + if (!existsSync(distPath)) { + throw new Error( + `dist binary not found at ${distPath}. Run \`pnpm -F ccaudit build\` before running this test.`, + ); + } +}); + +interface RestoreEnvelope { + meta: { command: string; exitCode: number }; + status: string; + manifest_path?: string; + counts?: { + unarchived: { moved: number; alreadyAtSource: number; failed: number }; + reenabled: { completed: number; failed: number }; + stripped: { completed: number; failed: number }; + }; +} + +describe.skipIf(process.platform === 'win32')( + 'RE-M9 follow-up — full-mode restore skips purged archive ops', + () => { + let tmpHome: string; + + beforeEach(async () => { + tmpHome = await makeTmpHome(); + await buildFakePs(tmpHome); + + const manifestsDir = path.join(tmpHome, '.claude', 'ccaudit', 'manifests'); + // Archive dir layout mirrors the actual bust output structure. + const archivedAgentsDir = path.join( + tmpHome, + '.claude', + 'ccaudit', + 'archived', + '.claude', + 'agents', + ); + const agentsDir = path.join(tmpHome, '.claude', 'agents'); + await mkdir(manifestsDir, { recursive: true }); + await mkdir(archivedAgentsDir, { recursive: true }); + await mkdir(agentsDir, { recursive: true }); + + const bustMtime = new Date('2026-04-20T10:00:00.000Z'); + const purgeMtime = new Date('2026-04-21T10:00:00.000Z'); + + // Three items: A and B were purged (archive files absent), C is present. + const items = ['item-a', 'item-b', 'item-c'] as const; + const archivePaths: Record = {}; + const sourcePaths: Record = {}; + for (const name of items) { + archivePaths[name] = path.join(archivedAgentsDir, `${name}.md`); + sourcePaths[name] = path.join(agentsDir, `${name}.md`); + } + + // Only C's archive file exists on disk; A and B were purged. + await writeFile(archivePaths['item-c']!, '# item-c (archived)\n', 'utf8'); + + // Bust manifest: archive ops for A, B, C. + const bustHeader = { + record_type: 'header', + manifest_version: 1, + ccaudit_version: '1.5.0-test', + checkpoint_ghost_hash: 'deadbeef-rem9', + checkpoint_timestamp: '2026-04-20T10:00:00.000Z', + since_window: '30d', + os: 'darwin', + node_version: 'v20.0.0', + planned_ops: { archive: 3, disable: 0, flag: 0 }, + selection_filter: { mode: 'full' }, + }; + const bustOps = items.map((name) => ({ + op_id: `op-${name}-rem9`, + op_type: 'archive', + timestamp: bustMtime.toISOString(), + status: 'completed', + category: 'agent', + scope: 'global', + source_path: sourcePaths[name], + archive_path: archivePaths[name], + content_sha256: '0'.repeat(64), + })); + const bustFooter = { + record_type: 'footer', + status: 'completed', + actual_ops: { + archive: { completed: 3, failed: 0 }, + disable: { completed: 0, failed: 0 }, + flag: { completed: 0, failed: 0, refreshed: 0, skipped: 0 }, + }, + duration_ms: 10, + exit_code: 0, + }; + const bustBody = + [bustHeader, ...bustOps, bustFooter].map((r) => JSON.stringify(r)).join('\n') + '\n'; + const bustManifestPath = path.join(manifestsDir, 'bust-2026-04-20T10-00-00-000Z-rem9.jsonl'); + await writeFile(bustManifestPath, bustBody, 'utf8'); + await utimes(bustManifestPath, bustMtime, bustMtime); + + // Purge manifest: archive_purge ops for A and B only. + const purgeHeader = { + record_type: 'header', + manifest_version: 1, + ccaudit_version: '1.5.0-test', + checkpoint_ghost_hash: `purge:${purgeMtime.toISOString()}`, + checkpoint_timestamp: purgeMtime.toISOString(), + since_window: 'n/a', + os: 'darwin', + node_version: 'v20.0.0', + planned_ops: { archive: 0, disable: 0, flag: 0 }, + selection_filter: { mode: 'full' }, + }; + const purgeOps = (['item-a', 'item-b'] as const).map((name) => ({ + op_id: `purge-op-${name}-rem9`, + op_type: 'archive_purge', + timestamp: purgeMtime.toISOString(), + status: 'completed', + original_op_id: `op-${name}-rem9`, + purged: true, + reason: 'reclaimed', + })); + const purgeFooter = { + record_type: 'footer', + status: 'completed', + actual_ops: { + archive: { completed: 0, failed: 0 }, + disable: { completed: 0, failed: 0 }, + flag: { completed: 0, failed: 0, refreshed: 0, skipped: 0 }, + }, + duration_ms: 5, + exit_code: 0, + }; + const purgeBody = + [purgeHeader, ...purgeOps, purgeFooter].map((r) => JSON.stringify(r)).join('\n') + '\n'; + const purgeManifestPath = path.join( + manifestsDir, + 'purge-2026-04-21T10-00-00-000Z-rem9.jsonl', + ); + await writeFile(purgeManifestPath, purgeBody, 'utf8'); + await utimes(purgeManifestPath, purgeMtime, purgeMtime); + }); + + afterEach(async () => { + await cleanupTmpHome(tmpHome); + }); + + it('exits 0 with status=success when purged ops are skipped (not failed)', async () => { + const r = await runCcauditCli(tmpHome, ['restore', '--json'], { + env: { PATH: `${path.join(tmpHome, 'bin')}:${process.env.PATH ?? ''}` }, + }); + expect(r.exitCode, `stderr:\n${r.stderr}\nstdout:\n${r.stdout}`).toBe(0); + + const parsed = JSON.parse(r.stdout.trim()) as RestoreEnvelope; + expect(parsed.meta.command).toBe('restore'); + expect(parsed.meta.exitCode).toBe(0); + + // Must be 'success', not 'partial-success' — purged ops are skipped silently. + expect( + parsed.status, + `expected 'success' but got '${parsed.status}'; spurious failures mean purged ops were attempted`, + ).toBe('success'); + expect(path.basename(parsed.manifest_path ?? '')).toMatch(/^bust-/); + }); + + it('counts reflect only C being restored (moved=1, failed=0)', async () => { + const r = await runCcauditCli(tmpHome, ['restore', '--json'], { + env: { PATH: `${path.join(tmpHome, 'bin')}:${process.env.PATH ?? ''}` }, + }); + expect(r.exitCode, `stderr:\n${r.stderr}\nstdout:\n${r.stdout}`).toBe(0); + + const parsed = JSON.parse(r.stdout.trim()) as RestoreEnvelope; + expect(parsed.counts).toBeDefined(); + + // Only C was attempted — A and B were suppressed by the purged-op-id check. + expect(parsed.counts!.unarchived.moved, 'expected exactly 1 item moved (item-c)').toBe(1); + + // No spurious failures from attempting to restore missing archives for A and B. + expect( + parsed.counts!.unarchived.failed, + 'expected 0 failures — purged ops must be skipped, not attempted', + ).toBe(0); + }); + + it('item-c archive file is moved back to its source path', async () => { + const agentsDir = path.join(tmpHome, '.claude', 'agents'); + const archivedAgentsDir = path.join( + tmpHome, + '.claude', + 'ccaudit', + 'archived', + '.claude', + 'agents', + ); + + await runCcauditCli(tmpHome, ['restore', '--json'], { + env: { PATH: `${path.join(tmpHome, 'bin')}:${process.env.PATH ?? ''}` }, + }); + + // item-c should now be at its source path. + const sourcePath = path.join(agentsDir, 'item-c.md'); + expect(existsSync(sourcePath), `item-c.md should have been restored to ${sourcePath}`).toBe( + true, + ); + + // item-c archive should be gone (it was moved, not copied). + const archivePath = path.join(archivedAgentsDir, 'item-c.md'); + expect( + existsSync(archivePath), + `item-c.md archive should no longer exist at ${archivePath}`, + ).toBe(false); + }); + }, +); diff --git a/apps/ccaudit/src/__tests__/restore-all-matching.test.ts b/apps/ccaudit/src/__tests__/restore-all-matching.test.ts new file mode 100644 index 0000000..1407c1c --- /dev/null +++ b/apps/ccaudit/src/__tests__/restore-all-matching.test.ts @@ -0,0 +1,98 @@ +/** + * Phase 08 Plan 06 — `ccaudit restore --all-matching ` happy path + * (D8-10, RESTORE-03). + * + * Stages the restore-interactive fixture (subset manifest + full manifest + * with an overlapping archive_path for pencil-review), runs + * `ccaudit restore --all-matching pencil`, and asserts: + * - exit 0 + * - both pencil-dev.md and pencil-review.md are back at their source paths + * - code-reviewer.md (not matching "pencil") remains in archived/ + * - no new manifest was written by the restore run + */ +import { describe, it, expect, beforeAll, beforeEach, afterEach } from 'vitest'; +import { existsSync } from 'node:fs'; +import { readdir } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + makeTmpHome, + cleanupTmpHome, + buildFakePs, + stageRestoreInteractiveFixture, + runCcauditCli, + listManifestsDir, +} from './_test-helpers.ts'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const distPath = path.resolve(here, '..', '..', 'dist', 'index.js'); + +beforeAll(() => { + if (!existsSync(distPath)) { + throw new Error( + `dist binary not found at ${distPath}. Run \`pnpm -F ccaudit build\` before running this test.`, + ); + } +}); + +describe.skipIf(process.platform === 'win32')( + 'Phase 08 Plan 06 — restore --all-matching happy path (RESTORE-03)', + () => { + let tmpHome: string; + + beforeEach(async () => { + tmpHome = await makeTmpHome(); + await stageRestoreInteractiveFixture(tmpHome); + await buildFakePs(tmpHome); + }); + + afterEach(async () => { + await cleanupTmpHome(tmpHome); + }); + + it('restores every pencil-* item and leaves code-reviewer in archived/', async () => { + const baselineManifests = await listManifestsDir(tmpHome); + + const r = await runCcauditCli(tmpHome, ['restore', '--all-matching', 'pencil'], { + env: { PATH: `${path.join(tmpHome, 'bin')}:${process.env.PATH ?? ''}` }, + }); + expect(r.exitCode, `stderr:\n${r.stderr}\nstdout:\n${r.stdout}`).toBe(0); + + // Both pencil items back at source. + expect(existsSync(path.join(tmpHome, '.claude', 'agents', 'pencil-dev.md'))).toBe(true); + expect(existsSync(path.join(tmpHome, '.claude', 'agents', 'pencil-review.md'))).toBe(true); + + // code-reviewer unchanged (never matched the pattern). + expect(existsSync(path.join(tmpHome, '.claude', 'agents', 'code-reviewer.md'))).toBe(false); + expect( + existsSync( + path.join(tmpHome, '.claude', 'ccaudit', 'archived', 'agents', 'code-reviewer.md'), + ), + ).toBe(true); + + // Archive directory no longer contains the restored pencil items. + const archivedAfter = await readdir( + path.join(tmpHome, '.claude', 'ccaudit', 'archived', 'agents'), + ).catch(() => [] as string[]); + expect(archivedAfter).not.toContain('pencil-dev.md'); + expect(archivedAfter).not.toContain('pencil-review.md'); + + // Restore MUST NOT write new manifests (restore is a consumer, not a producer). + const postManifests = await listManifestsDir(tmpHome); + expect(postManifests).toEqual(baselineManifests); + }); + + // Phase 8.1 Plan 05 (D81-05): pins the CLI-side pre-dispatch gate added in + // plan 08.1-03 — `--all-matching ` with zero matches exits 1 with + // stderr wording and empty stdout. Fixture has pencil-* + code-reviewer; + // 'no-such-thing-xyz' matches nothing. + it('no-match: exits 1 with stderr wording, no stdout', async () => { + const r = await runCcauditCli(tmpHome, ['restore', '--all-matching', 'no-such-thing-xyz'], { + env: { PATH: `${path.join(tmpHome, 'bin')}:${process.env.PATH ?? ''}` }, + }); + expect(r.exitCode).toBe(1); + expect(r.stderr).toContain('no archived item matches "no-such-thing-xyz"'); + expect(r.stdout).toBe(''); + }); + }, +); diff --git a/apps/ccaudit/src/__tests__/restore-command.test.ts b/apps/ccaudit/src/__tests__/restore-command.test.ts index 98e8b9f..e28c8d3 100644 --- a/apps/ccaudit/src/__tests__/restore-command.test.ts +++ b/apps/ccaudit/src/__tests__/restore-command.test.ts @@ -1016,17 +1016,22 @@ describe.skipIf(process.platform === 'win32')('ccaudit restore (subprocess integ const parsed = JSON.parse(result.stdout) as Record; expect(parsed.status).toBe('success'); - // The key assertion: moved = 0, alreadyAtSource = 2 + // Phase 8.2 semantic shift: the two ops satisfy the stale-archive + // predicate (archive_path missing AND source_path exists) and are + // suppressed at collection time. They no longer reach the executor, + // so alreadyAtSource stays 0 and the suppression is surfaced via + // the additive `filteredStaleCount` envelope field. moved and + // failed remain 0 — the underlying "nothing actually moved" + // invariant still holds. const counts = parsed.counts as { unarchived: { moved: number; alreadyAtSource: number; failed: number }; }; expect(counts.unarchived.moved, 'moved should be 0 (nothing actually moved)').toBe(0); - expect(counts.unarchived.alreadyAtSource, 'already-at-source should be 2').toBe(2); + expect( + counts.unarchived.alreadyAtSource, + 'already-at-source should be 0 (stale ops filtered upstream in Phase 8.2)', + ).toBe(0); expect(counts.unarchived.failed, 'failed should be 0').toBe(0); - - // The rendered output must mention "already at source" separately - const rendered = await runRestore(tmpH, []); - const combined = rendered.stdout + rendered.stderr; - expect(combined).toMatch(/already.at.source|already at source/i); + expect(parsed.filteredStaleCount, 'stale archive ops surface via filteredStaleCount').toBe(2); }, 60_000); }); diff --git a/apps/ccaudit/src/__tests__/restore-corrupt-manifest.test.ts b/apps/ccaudit/src/__tests__/restore-corrupt-manifest.test.ts new file mode 100644 index 0000000..fdaa52f --- /dev/null +++ b/apps/ccaudit/src/__tests__/restore-corrupt-manifest.test.ts @@ -0,0 +1,210 @@ +/** + * M4 — `restore` pre-dispatch flows covered by the outer try/catch. + * + * When `findManifestsForRestore` or `readManifest` encounters a corrupt or + * unreadable manifest, the CLI must emit structured stderr/JSON output and + * exit non-zero — NOT crash with an uncaught exception / stack trace. + * + * This test injects a JSONL file whose first line is not valid JSON so that + * `readManifest` throws during the pre-dispatch --name / --all-matching / + * default-full flows. The outer try/catch (M4 fix) must intercept the error + * and route it into the graceful degradation path. + * + * Note: `restore --list` silently skips corrupt manifests (header===null) by + * design (that code path is unchanged). The crash risk M4 addresses is in the + * pre-dispatch dedup flows that call readManifest then iterate the ops. + * + * M4-subset — when a corrupt manifest coexists with a valid one, subset + * restore paths (--name, --all-matching, --interactive pre-check) must skip + * the corrupt file with a warning and continue — not hard-fail the whole + * restore (CodeRabbit finding 3c50af1d-2a62-412a-bd4e-9fda9d53a388). + */ +import { describe, it, expect, beforeAll, beforeEach, afterEach } from 'vitest'; +import { existsSync } from 'node:fs'; +import { mkdir, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { makeTmpHome, cleanupTmpHome, buildFakePs, runCcauditCli } from './_test-helpers.ts'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const distPath = path.resolve(here, '..', '..', 'dist', 'index.js'); + +beforeAll(() => { + if (!existsSync(distPath)) { + throw new Error( + `dist binary not found at ${distPath}. Run \`pnpm -F ccaudit build\` before running this test.`, + ); + } +}); + +/** + * Write a manifest file whose header line is syntactically invalid JSON so + * that readManifest() throws a SyntaxError during JSON.parse. + */ +async function writeCorruptManifest(tmpHome: string): Promise { + const manifestsDir = path.join(tmpHome, '.claude', 'ccaudit', 'manifests'); + await mkdir(manifestsDir, { recursive: true }); + const manifestPath = path.join(manifestsDir, 'bust-2026-04-20T10-00-00-000Z-m4tt.jsonl'); + // First line is not valid JSON — will cause readManifest to throw. + await writeFile(manifestPath, 'THIS IS NOT JSON\n{"op_type":"archive"}\n', 'utf8'); + return manifestPath; +} + +describe.skipIf(process.platform === 'win32')( + 'M4 — corrupt manifest in pre-dispatch path emits structured error (not stack trace)', + () => { + let tmpHome: string; + + beforeEach(async () => { + tmpHome = await makeTmpHome(); + await buildFakePs(tmpHome); + await writeCorruptManifest(tmpHome); + }); + + afterEach(async () => { + await cleanupTmpHome(tmpHome); + }); + + it('restore (full) with corrupt manifest: exits non-zero, no stack trace on stderr', async () => { + const r = await runCcauditCli(tmpHome, ['restore'], { + env: { PATH: `${path.join(tmpHome, 'bin')}:${process.env.PATH ?? ''}` }, + }); + // Must exit non-zero (1 from manifest-corrupt or 2 from caught error) + expect(r.exitCode, `stdout:\n${r.stdout}`).not.toBe(0); + // Must NOT emit a raw Node.js stack trace + expect(r.stderr).not.toContain('at Object.'); + expect(r.stderr).not.toContain('Error: '); + // stdout must be empty (not a stack trace dump) + expect(r.stdout.trim()).toBe(''); + }); + + it('restore --json with corrupt manifest: emits structured JSON envelope, exits non-zero', async () => { + const r = await runCcauditCli(tmpHome, ['restore', '--json'], { + env: { PATH: `${path.join(tmpHome, 'bin')}:${process.env.PATH ?? ''}` }, + }); + // Must exit non-zero + expect(r.exitCode, `stdout:\n${r.stdout}\nstderr:\n${r.stderr}`).not.toBe(0); + // stdout must be a parseable JSON object (structured envelope or error envelope) + const stdout = r.stdout.trim(); + expect(stdout.length, 'stdout must not be empty for --json').toBeGreaterThan(0); + let parsed: Record; + expect(() => { + parsed = JSON.parse(stdout) as Record; + }, `stdout must be valid JSON, got:\n${stdout}`).not.toThrow(); + // The envelope must have a meta block (standard ccaudit JSON envelope shape) + expect(parsed!).toHaveProperty('meta'); + // Must not be a raw stack trace in stdout + expect(stdout).not.toContain('at Object.'); + }); + + it('restore --name foo with corrupt manifest: exits non-zero with structured output', async () => { + const r = await runCcauditCli(tmpHome, ['restore', '--name', 'foo'], { + env: { PATH: `${path.join(tmpHome, 'bin')}:${process.env.PATH ?? ''}` }, + }); + expect(r.exitCode, `stdout:\n${r.stdout}\nstderr:\n${r.stderr}`).not.toBe(0); + expect(r.stderr).not.toContain('at Object.'); + expect(r.stdout).not.toContain('at Object.'); + }); + }, +); + +// --------------------------------------------------------------------------- +// M4-subset: corrupt manifest coexists with a valid one +// --------------------------------------------------------------------------- + +/** + * Write a minimal valid manifest (header + one archive op + footer). + * The archive_path does NOT need to exist on disk — --name / --all-matching + * only need to resolve the canonical_id; the actual file moves happen later. + */ +async function writeValidManifestWithKnownItem(tmpHome: string): Promise { + const manifestsDir = path.join(tmpHome, '.claude', 'ccaudit', 'manifests'); + await mkdir(manifestsDir, { recursive: true }); + const archivePath = path.join(tmpHome, '.claude', 'ccaudit', 'archived', 'agents', 'canary.md'); + const sourcePath = path.join(tmpHome, '.claude', 'agents', 'canary.md'); + const manifestPath = path.join(manifestsDir, 'bust-2026-04-19T10-00-00-000Z-vld1.jsonl'); + const lines = [ + JSON.stringify({ + record_type: 'header', + manifest_version: 1, + ccaudit_version: '1.5.0-test', + checkpoint_ghost_hash: 'cafebabe', + checkpoint_timestamp: '2026-04-19T09:59:59.000Z', + since_window: '30d', + os: 'darwin', + node_version: 'v20.0.0', + planned_ops: { archive: 1, disable: 0, flag: 0 }, + }), + JSON.stringify({ + op_id: 'op-canary', + op_type: 'archive', + timestamp: '2026-04-19T10:00:00.000Z', + status: 'completed', + category: 'agent', + scope: 'global', + source_path: sourcePath, + archive_path: archivePath, + content_sha256: '0'.repeat(64), + }), + JSON.stringify({ record_type: 'footer', completed_at: '2026-04-19T10:00:01.000Z' }), + ]; + await writeFile(manifestPath, lines.join('\n') + '\n', 'utf8'); +} + +describe.skipIf(process.platform === 'win32')( + 'M4-subset — corrupt manifest alongside valid one: subset paths skip corrupt and proceed', + () => { + let tmpHome: string; + + beforeEach(async () => { + tmpHome = await makeTmpHome(); + await buildFakePs(tmpHome); + // Corrupt manifest first (lexicographically newer = listed first by mtime logic) + await writeCorruptManifest(tmpHome); + // Valid manifest with a known item + await writeValidManifestWithKnownItem(tmpHome); + }); + + afterEach(async () => { + await cleanupTmpHome(tmpHome); + }); + + it('restore --name skips corrupt manifest and exits with no-match or success (no crash)', async () => { + const r = await runCcauditCli(tmpHome, ['restore', '--name', 'canary'], { + env: { PATH: `${path.join(tmpHome, 'bin')}:${process.env.PATH ?? ''}` }, + }); + // No stack trace regardless of exit code + expect(r.stderr).not.toContain('at Object.'); + expect(r.stdout).not.toContain('at Object.'); + // Must not be a hard crash (exit code 2 = unhandled exception in our ladder) + expect(r.exitCode, `stderr:\n${r.stderr}`).not.toBe(2); + }); + + it('restore --all-matching skips corrupt manifest and exits with no-match or success (no crash)', async () => { + const r = await runCcauditCli(tmpHome, ['restore', '--all-matching', 'canary'], { + env: { PATH: `${path.join(tmpHome, 'bin')}:${process.env.PATH ?? ''}` }, + }); + expect(r.stderr).not.toContain('at Object.'); + expect(r.stdout).not.toContain('at Object.'); + expect(r.exitCode, `stderr:\n${r.stderr}`).not.toBe(2); + }); + + it('restore --name with mixed manifests: exits 1 (no-match), no crash', async () => { + const r = await runCcauditCli(tmpHome, ['restore', '--name', 'does-not-exist'], { + env: { PATH: `${path.join(tmpHome, 'bin')}:${process.env.PATH ?? ''}` }, + }); + expect(r.exitCode, `stderr:\n${r.stderr}`).toBe(1); + expect(r.stderr).not.toContain('at Object.'); + expect(r.stdout).not.toContain('at Object.'); + }); + + it('restore --all-matching with mixed manifests: exits 1 (no-match), no crash', async () => { + const r = await runCcauditCli(tmpHome, ['restore', '--all-matching', 'does-not-exist'], { + env: { PATH: `${path.join(tmpHome, 'bin')}:${process.env.PATH ?? ''}` }, + }); + expect(r.exitCode, `stderr:\n${r.stderr}`).toBe(1); + expect(r.stderr).not.toContain('at Object.'); + expect(r.stdout).not.toContain('at Object.'); + }); + }, +); diff --git a/apps/ccaudit/src/__tests__/restore-flag-mutual-exclusion.test.ts b/apps/ccaudit/src/__tests__/restore-flag-mutual-exclusion.test.ts new file mode 100644 index 0000000..5f76ebf --- /dev/null +++ b/apps/ccaudit/src/__tests__/restore-flag-mutual-exclusion.test.ts @@ -0,0 +1,76 @@ +/** + * Phase 08 Plan 06 — `--interactive`, `--name`, `--all-matching` mutual + * exclusion (D8-11). + * + * Combining any two of the three mode-selecting flags is a hard error that + * must exit 1 with a `flags are mutually exclusive` message on stderr + * BEFORE any discovery, preflight, or filesystem access runs. No archive + * listing is produced; no manifest is read; no fixture filesystem state + * mutates. + * + * This test does NOT stage a manifest fixture — the error must fire even + * on an empty home (the mutual-exclusion gate runs before discovery). + */ +import { describe, it, expect, beforeAll, beforeEach, afterEach } from 'vitest'; +import { existsSync } from 'node:fs'; +import { mkdir, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { makeTmpHome, cleanupTmpHome, buildFakePs, runCcauditCli } from './_test-helpers.ts'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const distPath = path.resolve(here, '..', '..', 'dist', 'index.js'); + +beforeAll(() => { + if (!existsSync(distPath)) { + throw new Error( + `dist binary not found at ${distPath}. Run \`pnpm -F ccaudit build\` before running this test.`, + ); + } +}); + +describe.skipIf(process.platform === 'win32')( + 'Phase 08 Plan 06 — restore flag mutual exclusion (D8-11)', + () => { + let tmpHome: string; + + beforeEach(async () => { + tmpHome = await makeTmpHome(); + await mkdir(path.join(tmpHome, '.claude'), { recursive: true }); + await writeFile(path.join(tmpHome, '.claude.json'), '{}', 'utf8'); + await buildFakePs(tmpHome); + }); + + afterEach(async () => { + await cleanupTmpHome(tmpHome); + }); + + it('--interactive --name foo → exit 1, "flags are mutually exclusive" on stderr', async () => { + const r = await runCcauditCli(tmpHome, ['restore', '--interactive', '--name', 'foo'], { + env: { PATH: `${path.join(tmpHome, 'bin')}:${process.env.PATH ?? ''}` }, + }); + expect(r.exitCode).toBe(1); + expect(r.stderr).toContain('flags are mutually exclusive'); + }); + + it('--name foo --all-matching foo → exit 1, "flags are mutually exclusive" on stderr', async () => { + const r = await runCcauditCli( + tmpHome, + ['restore', '--name', 'foo', '--all-matching', 'foo'], + { env: { PATH: `${path.join(tmpHome, 'bin')}:${process.env.PATH ?? ''}` } }, + ); + expect(r.exitCode).toBe(1); + expect(r.stderr).toContain('flags are mutually exclusive'); + }); + + it('--interactive --all-matching foo → exit 1, "flags are mutually exclusive" on stderr', async () => { + const r = await runCcauditCli( + tmpHome, + ['restore', '--interactive', '--all-matching', 'foo'], + { env: { PATH: `${path.join(tmpHome, 'bin')}:${process.env.PATH ?? ''}` } }, + ); + expect(r.exitCode).toBe(1); + expect(r.stderr).toContain('flags are mutually exclusive'); + }); + }, +); diff --git a/apps/ccaudit/src/__tests__/restore-interactive-memory-roundtrip.test.ts b/apps/ccaudit/src/__tests__/restore-interactive-memory-roundtrip.test.ts new file mode 100644 index 0000000..2a12fe3 --- /dev/null +++ b/apps/ccaudit/src/__tests__/restore-interactive-memory-roundtrip.test.ts @@ -0,0 +1,228 @@ +/** + * Regression guard for the Phase 8.1 memory-restore blocker. + * + * The picker already surfaced MEMORY rows, but the subset executor still + * resolved selected ids against dedupManifestOps(), which drops flag/refresh + * ops. That let a mixed selection (agents + memory) confirm successfully while + * silently discarding the memory op before execution. + * + * These end-to-end tests drive the real TUI and assert both the human-rendered + * and --json paths execute the selected memory item for real. + */ +import { describe, it, expect, beforeAll, beforeEach, afterEach } from 'vitest'; +import { spawn, type ChildProcess } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { readFile, readdir, stat } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + makeTmpHome, + cleanupTmpHome, + buildFakePs, + stageRestoreInteractiveFixture, + listManifestsDir, + sendKeys, + waitForPicker, +} from './_test-helpers.ts'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const distPath = path.resolve(here, '..', '..', 'dist', 'index.js'); + +beforeAll(() => { + if (!existsSync(distPath)) { + throw new Error( + `dist binary not found at ${distPath}. Run \`pnpm -F ccaudit build\` before running this test.`, + ); + } +}); + +interface SpawnedRestore { + child: ChildProcess; + done: Promise<{ stdout: string; stderr: string; exitCode: number | null }>; +} + +interface RestoreJsonEnvelope { + meta: { command: string; exitCode: number }; + status: string; + counts: { + unarchived: { moved: number; alreadyAtSource: number; failed: number }; + reenabled: { completed: number; failed: number }; + stripped: { completed: number; failed: number }; + }; + selectionFilter: { mode: string; ids: string[] } | null; +} + +function spawnRestoreInteractive(tmpHome: string, extraArgs: string[] = []): SpawnedRestore { + const child = spawn(process.execPath, [distPath, 'restore', '--interactive', ...extraArgs], { + env: { + HOME: tmpHome, + USERPROFILE: tmpHome, + XDG_CONFIG_HOME: path.join(tmpHome, '.config'), + NO_COLOR: '1', + TZ: 'UTC', + CCAUDIT_FORCE_TTY: '1', + PATH: `${path.join(tmpHome, 'bin')}:${process.env.PATH ?? ''}`, + COLUMNS: '120', + LINES: '40', + }, + cwd: tmpHome, + stdio: ['pipe', 'pipe', 'pipe'], + }); + + let stdout = ''; + let stderr = ''; + const done = new Promise<{ stdout: string; stderr: string; exitCode: number | null }>( + (resolve, reject) => { + const timer = setTimeout(() => { + child.kill('SIGKILL'); + reject( + new Error( + `spawnRestoreInteractive timed out after 15000ms\nstdout:\n${stdout.slice(-500)}\nstderr:\n${stderr.slice(-500)}`, + ), + ); + }, 15_000); + child.stdout!.on('data', (c: Buffer) => { + stdout += c.toString(); + }); + child.stderr!.on('data', (c: Buffer) => { + stderr += c.toString(); + }); + child.on('error', (err: Error) => { + clearTimeout(timer); + reject(err); + }); + child.on('close', (code: number | null) => { + clearTimeout(timer); + resolve({ stdout, stderr, exitCode: code }); + }); + }, + ); + + return { child, done }; +} + +async function selectAgentsAndMemoryAndConfirm(child: ChildProcess): Promise { + // AGENTS tab is focused first. Toggle all 3 archived agents, move to MEMORY, + // toggle the flagged memory row, submit, then flip clack's confirm from No → Yes. + await sendKeys(child, ['a'], 100); + await sendKeys(child, ['\x1b[C'], 125); + await sendKeys(child, [' '], 100); + await sendKeys(child, ['\r'], 200); + await new Promise((r) => setTimeout(r, 300)); + await sendKeys(child, ['\x1b[D'], 100); + await sendKeys(child, ['\r'], 100); +} + +/* eslint-disable no-control-regex -- ANSI stripping requires literal escape bytes */ +function stripAnsi(s: string): string { + return s + .replace(/\x1b\[\?25[lh]/g, '') + .replace(/\x1b\[[0-9;]*[mGKHFABCD]/g, '') + .replace(/\x1b\[\d*[JST]/g, ''); +} +/* eslint-enable no-control-regex */ + +function extractJsonLine(stdout: string): string { + const lines = stripAnsi(stdout) + .replace(/\r/g, '\n') + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.startsWith('{') && line.endsWith('}')); + const jsonLine = lines.at(-1); + if (jsonLine === undefined) { + throw new Error(`no JSON line found in interactive stdout:\n${stripAnsi(stdout).slice(-1000)}`); + } + return jsonLine; +} + +describe.skipIf(process.platform === 'win32')('restore --interactive memory round-trip', () => { + let tmpHome: string; + + beforeEach(async () => { + tmpHome = await makeTmpHome(); + await stageRestoreInteractiveFixture(tmpHome); + await buildFakePs(tmpHome); + }); + + afterEach(async () => { + await cleanupTmpHome(tmpHome); + }); + + it('restores the selected memory item and preserves mtime in the rendered flow', async () => { + const baselineManifests = await listManifestsDir(tmpHome); + const memoryPath = path.join(tmpHome, '.claude', 'CLAUDE.md'); + const beforeMemoryStat = await stat(memoryPath); + + const spawned = spawnRestoreInteractive(tmpHome); + await waitForPicker(spawned.child); + expect(spawned.child.exitCode, 'subprocess exited before we sent keystrokes').toBeNull(); + + await selectAgentsAndMemoryAndConfirm(spawned.child); + const result = await spawned.done; + const plain = stripAnsi(result.stdout).replace(/\r/g, '\n'); + + expect( + result.exitCode, + `restore --interactive exited with ${result.exitCode}\nstderr:\n${result.stderr.slice(-1000)}\nstdout:\n${plain.slice(-1000)}`, + ).toBe(0); + + for (const fileName of ['pencil-dev.md', 'pencil-review.md', 'code-reviewer.md']) { + expect(existsSync(path.join(tmpHome, '.claude', 'agents', fileName))).toBe(true); + } + const archivedAfter = await readdir( + path.join(tmpHome, '.claude', 'ccaudit', 'archived', 'agents'), + ).catch(() => [] as string[]); + expect(archivedAfter).toEqual([]); + + const memoryAfter = await readFile(memoryPath, 'utf8'); + expect(memoryAfter).not.toContain('ccaudit-stale'); + expect(memoryAfter).not.toContain('ccaudit-flagged'); + const afterMemoryStat = await stat(memoryPath); + expect(Math.abs(afterMemoryStat.mtimeMs - beforeMemoryStat.mtimeMs)).toBeLessThan(1); + + expect(plain).toContain('3 items restored to their original locations'); + expect(plain).toContain('1 memory files cleaned (ccaudit flags removed)'); + + const postManifests = await listManifestsDir(tmpHome); + expect(postManifests).toEqual(baselineManifests); + }, 20_000); + + it('interactive --json keeps selectionFilter aligned with executed memory ops', async () => { + const baselineManifests = await listManifestsDir(tmpHome); + const memoryPath = path.join(tmpHome, '.claude', 'CLAUDE.md'); + + const spawned = spawnRestoreInteractive(tmpHome, ['--json']); + await waitForPicker(spawned.child); + expect(spawned.child.exitCode, 'subprocess exited before we sent keystrokes').toBeNull(); + + await selectAgentsAndMemoryAndConfirm(spawned.child); + const result = await spawned.done; + expect( + result.exitCode, + `restore --interactive --json exited with ${result.exitCode}\nstderr:\n${result.stderr.slice(-1000)}\nstdout:\n${stripAnsi(result.stdout).slice(-1000)}`, + ).toBe(0); + + const parsed = JSON.parse(extractJsonLine(result.stdout)) as RestoreJsonEnvelope; + expect(parsed.meta.command).toBe('restore'); + expect(parsed.meta.exitCode).toBe(0); + expect(parsed.status).toBe('success'); + expect(parsed.selectionFilter).not.toBeNull(); + expect(parsed.selectionFilter?.mode).toBe('subset'); + expect(parsed.selectionFilter?.ids).toHaveLength(4); + // M8: canonical_id for memory ops now includes op_type + op_id for uniqueness (INV-S3). + // The fixture flag op has op_id='op-stale-memo-flag'; match by prefix to avoid + // hardcoding the full id while still verifying the memory file is included. + expect( + parsed.selectionFilter?.ids.some((id) => id.startsWith(`memory:flag:${memoryPath}:`)), + ).toBe(true); + expect(parsed.counts.unarchived.moved).toBe(3); + expect(parsed.counts.stripped.completed).toBe(1); + + const memoryAfter = await readFile(memoryPath, 'utf8'); + expect(memoryAfter).not.toContain('ccaudit-stale'); + expect(memoryAfter).not.toContain('ccaudit-flagged'); + + const postManifests = await listManifestsDir(tmpHome); + expect(postManifests).toEqual(baselineManifests); + }, 20_000); +}); diff --git a/apps/ccaudit/src/__tests__/restore-interactive-roundtrip.test.ts b/apps/ccaudit/src/__tests__/restore-interactive-roundtrip.test.ts new file mode 100644 index 0000000..cee5dd5 --- /dev/null +++ b/apps/ccaudit/src/__tests__/restore-interactive-roundtrip.test.ts @@ -0,0 +1,185 @@ +/** + * Phase 08 Plan 06 — INV-S3 round-trip via `ccaudit restore --interactive` + * (D8-20). + * + * Stages the restore-interactive fixture (subset manifest archiving + * pencil-dev + pencil-review, then a newer full manifest archiving + * code-reviewer + a duplicate pencil-review entry), spawns + * `ccaudit restore --interactive` under CCAUDIT_FORCE_TTY=1, scripts the + * keystrokes that toggle every archived item across the agent tab, confirms + * the picker and the follow-up "Restore N items?" prompt, and asserts: + * + * - all three source paths (.claude/agents/{pencil-dev,pencil-review,code-reviewer}.md) + * are back at their original locations + * - the archive directory is empty of those items + * - manifests/ directory is unchanged (restore is a consumer, not a producer) + * + * Dedup behavior is exercised by construction: both manifests reference the + * same archive_path for pencil-review.md; dedupManifestOps keeps newer-wins + * and the picker sees it once. Thus pressing `a` (toggle-all-in-tab) on the + * agent tab selects exactly 3 items — pencil-dev (from the older subset + * manifest), pencil-review (from the newer full manifest, newer wins), and + * code-reviewer (from the newer full manifest). End-to-end round-trip + * succeeds only if both manifests contribute via a single restore session. + */ +import { describe, it, expect, beforeAll, beforeEach, afterEach } from 'vitest'; +import { spawn, type ChildProcess } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { readdir } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + makeTmpHome, + cleanupTmpHome, + buildFakePs, + stageRestoreInteractiveFixture, + listManifestsDir, + sendKeys, + waitForPicker, +} from './_test-helpers.ts'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const distPath = path.resolve(here, '..', '..', 'dist', 'index.js'); + +beforeAll(() => { + if (!existsSync(distPath)) { + throw new Error( + `dist binary not found at ${distPath}. Run \`pnpm -F ccaudit build\` before running this test.`, + ); + } +}); + +interface SpawnedRestore { + child: ChildProcess; + done: Promise<{ stdout: string; stderr: string; exitCode: number | null }>; +} + +/** + * Spawn `ccaudit restore --interactive` against `tmpHome`. + * Mirror of runCcauditGhost but for the `restore` subcommand; inlined here + * because the helper module's `runCcauditGhost` is hard-coded to prepend + * `ghost`, and adding another helper for one test is overkill. + */ +function spawnRestoreInteractive(tmpHome: string): SpawnedRestore { + const child = spawn(process.execPath, [distPath, 'restore', '--interactive'], { + env: { + HOME: tmpHome, + USERPROFILE: tmpHome, + XDG_CONFIG_HOME: path.join(tmpHome, '.config'), + NO_COLOR: '1', + TZ: 'UTC', + CCAUDIT_FORCE_TTY: '1', + PATH: `${path.join(tmpHome, 'bin')}:${process.env.PATH ?? ''}`, + COLUMNS: '120', + LINES: '40', + }, + cwd: tmpHome, + stdio: ['pipe', 'pipe', 'pipe'], + }); + + let stdout = ''; + let stderr = ''; + const done = new Promise<{ stdout: string; stderr: string; exitCode: number | null }>( + (resolve, reject) => { + const timer = setTimeout(() => { + child.kill('SIGKILL'); + reject( + new Error( + `spawnRestoreInteractive timed out after 15000ms\nstdout:\n${stdout.slice(-500)}\nstderr:\n${stderr.slice(-500)}`, + ), + ); + }, 15_000); + child.stdout!.on('data', (c: Buffer) => { + stdout += c.toString(); + }); + child.stderr!.on('data', (c: Buffer) => { + stderr += c.toString(); + }); + child.on('error', (err: Error) => { + clearTimeout(timer); + reject(err); + }); + child.on('close', (code: number | null) => { + clearTimeout(timer); + resolve({ stdout, stderr, exitCode: code }); + }); + }, + ); + + return { child, done }; +} + +describe.skipIf(process.platform === 'win32')( + 'Phase 08 Plan 06 — INV-S3 subset + full manifest round-trip via restore --interactive', + () => { + let tmpHome: string; + + beforeEach(async () => { + tmpHome = await makeTmpHome(); + await stageRestoreInteractiveFixture(tmpHome); + await buildFakePs(tmpHome); + }); + + afterEach(async () => { + await cleanupTmpHome(tmpHome); + }); + + it('picker toggles all 3 deduped items across both manifests → all 3 restored', async () => { + const baselineManifests = await listManifestsDir(tmpHome); + + const spawned = spawnRestoreInteractive(tmpHome); + + // Wait for the picker to reach its blocking read loop. + await waitForPicker(spawned.child); + + expect( + spawned.child.exitCode, + 'subprocess exited before we sent keystrokes — dump:', + ).toBeNull(); + + // Keystroke script: + // 'a' — toggle-all-in-active-tab (D3.1-15). All 3 agents selected. + // '\r' — Enter: submit picker selection. + // '\x1b[D' — ArrowLeft: toggle clack's confirm prompt from No → Yes + // (initialValue: false at select-restore.ts:120). + // '\r' — Enter: confirm restore. + await sendKeys(spawned.child, ['a'], 100); + await sendKeys(spawned.child, ['\r'], 200); + // Grace period so the picker closes and the confirm prompt mounts + // before we try to toggle it. + await new Promise((r) => setTimeout(r, 300)); + await sendKeys(spawned.child, ['\x1b[D'], 100); + await sendKeys(spawned.child, ['\r'], 100); + + const result = await spawned.done; + expect( + result.exitCode, + `restore --interactive exited with ${result.exitCode}\nstderr:\n${result.stderr.slice(-1000)}\nstdout:\n${result.stdout.slice(-1000)}`, + ).toBe(0); + + // All three source paths present. + expect(existsSync(path.join(tmpHome, '.claude', 'agents', 'pencil-dev.md'))).toBe(true); + expect(existsSync(path.join(tmpHome, '.claude', 'agents', 'pencil-review.md'))).toBe(true); + expect(existsSync(path.join(tmpHome, '.claude', 'agents', 'code-reviewer.md'))).toBe(true); + + // Archive dir no longer holds the restored items. + const archivedAfter = await readdir( + path.join(tmpHome, '.claude', 'ccaudit', 'archived', 'agents'), + ).catch(() => [] as string[]); + expect(archivedAfter).not.toContain('pencil-dev.md'); + expect(archivedAfter).not.toContain('pencil-review.md'); + expect(archivedAfter).not.toContain('code-reviewer.md'); + + // INV-S2 mirror: restore does not create new manifest files. + const postManifests = await listManifestsDir(tmpHome); + expect(postManifests).toEqual(baselineManifests); + + // Phase 8.1 Plan 05 (D81-05) — footer wording + MEMORY tab visibility. + // Footer template from D8-03 uses middle-dot U+00B7 separator. + expect(result.stdout).toMatch(/\d+ selected \u00B7 \d+ archived/); + // MEMORY tab appears because the full-bust manifest contains a FlagOp; + // collectRestoreableItems surfaces memory ops in the picker (D81-01). + expect(result.stdout).toContain('MEMORY'); + }, 20_000); + }, +); diff --git a/apps/ccaudit/src/__tests__/restore-interactive-source-exists.test.ts b/apps/ccaudit/src/__tests__/restore-interactive-source-exists.test.ts new file mode 100644 index 0000000..fe6b5ea --- /dev/null +++ b/apps/ccaudit/src/__tests__/restore-interactive-source-exists.test.ts @@ -0,0 +1,100 @@ +/** + * Phase 08 Plan 06 — source-exists skip contract (D8-14, INV-S3 mirror). + * + * Pre-populates the source path for `pencil-review.md` before running + * `ccaudit restore --all-matching pencil --json`. Asserts: + * - exit 0 (partial-skip is success, not failure — D8-18) + * - `data.status === 'success'` + * - `data.skipped` contains exactly one entry with + * `{ reason: 'source_exists', canonicalId: 'agent:.../pencil-review.md' }` + * - stderr contains `warning: skipped — source already exists` + * - pencil-dev.md IS restored to its source path (the other match wasn't skipped) + * - the pre-existing pencil-review.md content is preserved byte-for-byte + * (restore NEVER overwrites) + * - manifests dir unchanged (INV-S2 mirror: restore is a consumer, not a producer) + */ +import { describe, it, expect, beforeAll, beforeEach, afterEach } from 'vitest'; +import { existsSync } from 'node:fs'; +import { writeFile, readFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + makeTmpHome, + cleanupTmpHome, + buildFakePs, + stageRestoreInteractiveFixture, + runCcauditCli, + listManifestsDir, +} from './_test-helpers.ts'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const distPath = path.resolve(here, '..', '..', 'dist', 'index.js'); + +beforeAll(() => { + if (!existsSync(distPath)) { + throw new Error( + `dist binary not found at ${distPath}. Run \`pnpm -F ccaudit build\` before running this test.`, + ); + } +}); + +interface RestoreEnvelope { + status: string; + skipped: Array<{ reason: string; path: string; canonicalId: string }>; +} + +describe.skipIf(process.platform === 'win32')( + 'Phase 08 Plan 06 — source_exists skip (D8-14)', + () => { + let tmpHome: string; + + beforeEach(async () => { + tmpHome = await makeTmpHome(); + await stageRestoreInteractiveFixture(tmpHome); + await buildFakePs(tmpHome); + }); + + afterEach(async () => { + await cleanupTmpHome(tmpHome); + }); + + it('pre-populated source path → skipped with source_exists, other match restored', async () => { + const baselineManifests = await listManifestsDir(tmpHome); + + // Pre-populate pencil-review.md at its source path BEFORE the restore. + // This content must survive the restore (D8-14: never overwrite). + const prePopulated = '# pencil-review PRE-EXISTING — MUST NOT be overwritten\n'; + const reviewSource = path.join(tmpHome, '.claude', 'agents', 'pencil-review.md'); + await writeFile(reviewSource, prePopulated, 'utf8'); + + const r = await runCcauditCli(tmpHome, ['restore', '--all-matching', 'pencil', '--json'], { + env: { PATH: `${path.join(tmpHome, 'bin')}:${process.env.PATH ?? ''}` }, + }); + expect(r.exitCode, `stderr:\n${r.stderr}\nstdout:\n${r.stdout}`).toBe(0); + + const parsed = JSON.parse(r.stdout.trim()) as RestoreEnvelope; + expect(parsed.status).toBe('success'); + + // Exactly one skipped entry for the pre-populated path. + expect(parsed.skipped).toHaveLength(1); + expect(parsed.skipped[0]?.reason).toBe('source_exists'); + expect(parsed.skipped[0]?.path).toBe(reviewSource); + expect(parsed.skipped[0]?.canonicalId).toMatch(/^agent:.*pencil-review\.md$/); + + // stderr carries the human-readable warning (D8-14). + expect(r.stderr).toContain('source already exists'); + expect(r.stderr).toContain(reviewSource); + + // pencil-dev.md was restored (the other match was not skipped). + expect(existsSync(path.join(tmpHome, '.claude', 'agents', 'pencil-dev.md'))).toBe(true); + + // Pre-populated content preserved byte-for-byte. + const after = await readFile(reviewSource, 'utf8'); + expect(after).toBe(prePopulated); + + // INV-S2 mirror: restore does NOT write manifests. + const postManifests = await listManifestsDir(tmpHome); + expect(postManifests).toEqual(baselineManifests); + }); + }, +); diff --git a/apps/ccaudit/src/__tests__/restore-json-envelope.test.ts b/apps/ccaudit/src/__tests__/restore-json-envelope.test.ts new file mode 100644 index 0000000..e3ca7bd --- /dev/null +++ b/apps/ccaudit/src/__tests__/restore-json-envelope.test.ts @@ -0,0 +1,87 @@ +/** + * Phase 08 Plan 06 — `ccaudit restore --all-matching --json` + * envelope contract (D8-16, D8-17). + * + * Asserts the v1.5 additive fields land in the envelope: + * - `meta.command === 'restore'`, `meta.exitCode === 0` + * - `status === 'success'` + * - `selectionFilter.mode === 'subset'` + * - `selectionFilter.ids` has exactly 2 entries (pencil-dev + pencil-review) + * - `Array.isArray(skipped)` is true (empty on the happy path, but present) + * + * Source-exists skip + skipped[] contents are covered by + * restore-interactive-source-exists.test.ts. + */ +import { describe, it, expect, beforeAll, beforeEach, afterEach } from 'vitest'; +import { existsSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + makeTmpHome, + cleanupTmpHome, + buildFakePs, + stageRestoreInteractiveFixture, + runCcauditCli, +} from './_test-helpers.ts'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const distPath = path.resolve(here, '..', '..', 'dist', 'index.js'); + +beforeAll(() => { + if (!existsSync(distPath)) { + throw new Error( + `dist binary not found at ${distPath}. Run \`pnpm -F ccaudit build\` before running this test.`, + ); + } +}); + +interface RestoreEnvelope { + meta: { command: string; exitCode: number }; + status: string; + selectionFilter: { mode: string; ids: string[] } | null; + skipped: unknown[]; +} + +describe.skipIf(process.platform === 'win32')( + 'Phase 08 Plan 06 — restore --all-matching --json envelope (D8-16/17)', + () => { + let tmpHome: string; + + beforeEach(async () => { + tmpHome = await makeTmpHome(); + await stageRestoreInteractiveFixture(tmpHome); + await buildFakePs(tmpHome); + }); + + afterEach(async () => { + await cleanupTmpHome(tmpHome); + }); + + it('envelope carries selectionFilter (subset, 2 ids) + skipped[] on success', async () => { + const r = await runCcauditCli(tmpHome, ['restore', '--all-matching', 'pencil', '--json'], { + env: { PATH: `${path.join(tmpHome, 'bin')}:${process.env.PATH ?? ''}` }, + }); + expect(r.exitCode, `stderr:\n${r.stderr}\nstdout:\n${r.stdout}`).toBe(0); + + // Parse the single JSON line from stdout. + const parsed = JSON.parse(r.stdout.trim()) as RestoreEnvelope; + + expect(parsed.meta.command).toBe('restore'); + expect(parsed.meta.exitCode).toBe(0); + expect(parsed.status).toBe('success'); + + // selectionFilter is the v1.5 additive field (D8-16). + expect(parsed.selectionFilter).not.toBeNull(); + expect(parsed.selectionFilter?.mode).toBe('subset'); + expect(parsed.selectionFilter?.ids).toHaveLength(2); + // ids reference the two pencil archive paths (agent:). + for (const id of parsed.selectionFilter!.ids) { + expect(id).toMatch(/^agent:.*pencil-(dev|review)\.md$/); + } + + // skipped[] present (D8-17) — empty on the happy path. + expect(Array.isArray(parsed.skipped)).toBe(true); + expect(parsed.skipped).toHaveLength(0); + }); + }, +); diff --git a/apps/ccaudit/src/__tests__/restore-list-skips-purge.test.ts b/apps/ccaudit/src/__tests__/restore-list-skips-purge.test.ts new file mode 100644 index 0000000..04b7dcf --- /dev/null +++ b/apps/ccaudit/src/__tests__/restore-list-skips-purge.test.ts @@ -0,0 +1,164 @@ +/** + * M9 — `restore --list` must not surface purge manifests as restore entries. + * + * Fixture: two manifest JSONL files in `/.claude/ccaudit/manifests/`: + * - BUST manifest (bust-*.jsonl) → one archive op for `live-agent` + * - PURGE manifest (purge-*.jsonl) → one archive_purge op referencing the bust op + * + * `restore --list --json` must return exactly one entry whose `path` ends with + * the bust manifest filename. The purge manifest must be silently skipped and + * the archive op referenced by `archive_purge.original_op_id` must not appear + * in the entry's items array. + */ +import { describe, it, expect, beforeAll, beforeEach, afterEach } from 'vitest'; +import { existsSync } from 'node:fs'; +import { mkdir, writeFile, utimes } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { makeTmpHome, cleanupTmpHome, buildFakePs, runCcauditCli } from './_test-helpers.ts'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const distPath = path.resolve(here, '..', '..', 'dist', 'index.js'); + +beforeAll(() => { + if (!existsSync(distPath)) { + throw new Error( + `dist binary not found at ${distPath}. Run \`pnpm -F ccaudit build\` before running this test.`, + ); + } +}); + +interface RestoreListEnvelope { + meta: { command: string; exitCode: number }; + status: string; + entries: Array<{ + path: string; + mtime: string; + is_partial: boolean; + op_count: number; + items: Array<{ category: string; name: string }>; + }>; + filteredStaleCount: number; +} + +describe.skipIf(process.platform === 'win32')('M9 — restore --list skips purge manifests', () => { + let tmpHome: string; + let bustManifestName: string; + let purgeManifestName: string; + + beforeEach(async () => { + tmpHome = await makeTmpHome(); + await buildFakePs(tmpHome); + + const manifestsDir = path.join(tmpHome, '.claude', 'ccaudit', 'manifests'); + const archivedAgentsDir = path.join(tmpHome, '.claude', 'ccaudit', 'archived', 'agents'); + await mkdir(manifestsDir, { recursive: true }); + await mkdir(archivedAgentsDir, { recursive: true }); + + const archivePath = path.join(archivedAgentsDir, 'live-agent.md'); + const sourcePath = path.join(tmpHome, '.claude', 'agents', 'live-agent.md'); + const bustMtime = new Date('2026-04-20T10:00:00.000Z'); + const purgeMtime = new Date('2026-04-21T10:00:00.000Z'); + + bustManifestName = 'bust-2026-04-20T10-00-00-000Z-m9tt.jsonl'; + purgeManifestName = 'purge-2026-04-21T10-00-00-000Z-m9tt.jsonl'; + + // Bust manifest: one archive op for live-agent (archive present on disk) + const bustHeader = { + record_type: 'header', + manifest_version: 1, + ccaudit_version: '1.5.0-test', + checkpoint_ghost_hash: 'deadbeef-m9', + checkpoint_timestamp: '2026-04-20T10:00:00.000Z', + since_window: '30d', + os: 'darwin', + node_version: 'v20.0.0', + planned_ops: { archive: 1, disable: 0, flag: 0 }, + selection_filter: { mode: 'full' }, + }; + const bustOp = { + op_id: 'op-live-agent-m9', + op_type: 'archive', + timestamp: bustMtime.toISOString(), + status: 'completed', + category: 'agent', + scope: 'global', + source_path: sourcePath, + archive_path: archivePath, + content_sha256: '0'.repeat(64), + }; + const bustFooter = { + record_type: 'footer', + status: 'completed', + actual_ops: { + archive: { completed: 1, failed: 0 }, + disable: { completed: 0, failed: 0 }, + flag: { completed: 0, failed: 0, refreshed: 0, skipped: 0 }, + }, + duration_ms: 10, + exit_code: 0, + }; + const bustBody = + [JSON.stringify(bustHeader), JSON.stringify(bustOp), JSON.stringify(bustFooter)].join('\n') + + '\n'; + const bustPath = path.join(manifestsDir, bustManifestName); + await writeFile(bustPath, bustBody, 'utf8'); + await utimes(bustPath, bustMtime, bustMtime); + + // Seed the archive file so restore doesn't filter it as stale + await writeFile(archivePath, '# live-agent (archived)\n', 'utf8'); + + // Purge manifest: one archive_purge op referencing the bust op + const purgeHeader = { + record_type: 'header', + manifest_version: 1, + ccaudit_version: '1.5.0-test', + purge_timestamp: purgeMtime.toISOString(), + }; + const purgeOp = { + op_id: 'purge-op-m9', + op_type: 'archive_purge', + timestamp: purgeMtime.toISOString(), + status: 'completed', + original_op_id: 'op-live-agent-m9', + purged: true, + reason: 'reclaimed', + }; + const purgeBody = [JSON.stringify(purgeHeader), JSON.stringify(purgeOp)].join('\n') + '\n'; + const purgePath = path.join(manifestsDir, purgeManifestName); + await writeFile(purgePath, purgeBody, 'utf8'); + await utimes(purgePath, purgeMtime, purgeMtime); + }); + + afterEach(async () => { + await cleanupTmpHome(tmpHome); + }); + + it('restore --list --json returns the bust manifest but suppresses purged items', async () => { + const r = await runCcauditCli(tmpHome, ['restore', '--list', '--json'], { + env: { PATH: `${path.join(tmpHome, 'bin')}:${process.env.PATH ?? ''}` }, + }); + expect(r.exitCode, `stderr:\n${r.stderr}\nstdout:\n${r.stdout}`).toBe(0); + + const parsed = JSON.parse(r.stdout.trim()) as RestoreListEnvelope; + expect(parsed.meta.command).toBe('restore'); + expect(parsed.meta.exitCode).toBe(0); + expect(parsed.status).toBe('list'); + + // Exactly one entry — only the bust manifest + expect( + parsed.entries, + `expected 1 entry (bust only), got ${parsed.entries.length}: ${parsed.entries.map((e) => path.basename(e.path)).join(', ')}`, + ).toHaveLength(1); + + // The entry must point to the bust manifest, not the purge manifest + expect(path.basename(parsed.entries[0]!.path)).toBe(bustManifestName); + expect(path.basename(parsed.entries[0]!.path).startsWith('bust-')).toBe(true); + + expect(parsed.entries[0]!.items).toEqual([]); + + // Defensive: purge manifest must not appear anywhere + const allPaths = parsed.entries.map((e) => path.basename(e.path)); + expect(allPaths.every((p) => !p.startsWith('purge-'))).toBe(true); + }); +}); diff --git a/apps/ccaudit/src/__tests__/restore-name-ambiguity.test.ts b/apps/ccaudit/src/__tests__/restore-name-ambiguity.test.ts new file mode 100644 index 0000000..e67f392 --- /dev/null +++ b/apps/ccaudit/src/__tests__/restore-name-ambiguity.test.ts @@ -0,0 +1,83 @@ +/** + * Phase 08 Plan 06 — `ccaudit restore --name ` ambiguity contract + * (D8-09, RESTORE-02). + * + * With two pencil-* items in the deduped archive inventory, + * `--name pencil` matches both and MUST exit 1 with the verbatim D8-09 + * candidate block on stderr. Stdout MUST be empty (ambiguity short-circuits + * before dispatch — no JSON envelope is produced). No items are restored; + * the archive directory remains intact. + * + * The em-dash `\u2014` is load-bearing — the CLI contract pins the string + * exactly so downstream tooling can parse deterministically. + */ +import { describe, it, expect, beforeAll, beforeEach, afterEach } from 'vitest'; +import { existsSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + makeTmpHome, + cleanupTmpHome, + buildFakePs, + stageRestoreInteractiveFixture, + runCcauditCli, +} from './_test-helpers.ts'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const distPath = path.resolve(here, '..', '..', 'dist', 'index.js'); + +beforeAll(() => { + if (!existsSync(distPath)) { + throw new Error( + `dist binary not found at ${distPath}. Run \`pnpm -F ccaudit build\` before running this test.`, + ); + } +}); + +describe.skipIf(process.platform === 'win32')( + 'Phase 08 Plan 06 — restore --name ambiguity emits D8-09 block and exits 1 (RESTORE-02)', + () => { + let tmpHome: string; + + beforeEach(async () => { + tmpHome = await makeTmpHome(); + await stageRestoreInteractiveFixture(tmpHome); + await buildFakePs(tmpHome); + }); + + afterEach(async () => { + await cleanupTmpHome(tmpHome); + }); + + it('--name pencil with 2 candidates → exit 1, verbatim block on stderr, empty stdout', async () => { + const r = await runCcauditCli(tmpHome, ['restore', '--name', 'pencil'], { + env: { PATH: `${path.join(tmpHome, 'bin')}:${process.env.PATH ?? ''}` }, + }); + + expect(r.exitCode).toBe(1); + + // D8-09: verbatim header with em-dash (U+2014) — grep-visible. + // "pencil" is ambiguous — candidates: + expect(r.stderr).toContain('"pencil" is ambiguous \u2014 candidates:'); + // Both candidate canonical_ids appear, each indented by 2 spaces. + expect(r.stderr).toMatch(/\n {2}agent:.*pencil-dev\.md\n/); + expect(r.stderr).toMatch(/\n {2}agent:.*pencil-review\.md\n/); + // Suggestion line at the end. + expect(r.stderr).toContain('Use --all-matching to restore every candidate.'); + + // stdout is empty — ambiguity short-circuits before any dispatch + // (D8-09: no JSON envelope is produced). + expect(r.stdout).toBe(''); + + // No items were restored — archive directory intact. + const archivedDir = path.join(tmpHome, '.claude', 'ccaudit', 'archived', 'agents'); + expect(existsSync(path.join(archivedDir, 'pencil-dev.md'))).toBe(true); + expect(existsSync(path.join(archivedDir, 'pencil-review.md'))).toBe(true); + expect(existsSync(path.join(archivedDir, 'code-reviewer.md'))).toBe(true); + + // Source paths remain empty. + expect(existsSync(path.join(tmpHome, '.claude', 'agents', 'pencil-dev.md'))).toBe(false); + expect(existsSync(path.join(tmpHome, '.claude', 'agents', 'pencil-review.md'))).toBe(false); + }); + }, +); diff --git a/apps/ccaudit/src/__tests__/restore-preflight-json.test.ts b/apps/ccaudit/src/__tests__/restore-preflight-json.test.ts new file mode 100644 index 0000000..b3f1257 --- /dev/null +++ b/apps/ccaudit/src/__tests__/restore-preflight-json.test.ts @@ -0,0 +1,198 @@ +/** + * Pre-dispatch preflight errors must emit a JSON envelope on stdout (not raw + * stderr) when `--json` is active. + * + * Covers the five hard-fail sites replaced by RestorePreflightError: + * 1. Mutual-exclusion (--interactive --name) → exit 1 + * 2. CCAUDIT_NO_INTERACTIVE refusal → exit 2 + * 3. TTY guard (no TTY, no CCAUDIT_FORCE_TTY) → exit 1 + * 4. --name no-match → exit 1 + * 5. --all-matching no-match → exit 1 + * + * The envelope shape from buildJsonEnvelope is `{ meta: {...}, ...data }` — + * the data fields are spread at the top level, so `error` lives at + * `envelope.error`, not `envelope.data.error`. + * + * For each case the stdout must be a valid JSON object with + * `meta.command === 'restore'` and `meta.exitCode` matching the expected code, + * and `error` containing the expected message fragment. + * stderr must be empty (no leakage of raw text alongside JSON). + */ +import { describe, it, expect, beforeAll, beforeEach, afterEach } from 'vitest'; +import { existsSync } from 'node:fs'; +import { mkdir, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { makeTmpHome, cleanupTmpHome, buildFakePs, runCcauditCli } from './_test-helpers.ts'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const distPath = path.resolve(here, '..', '..', 'dist', 'index.js'); + +beforeAll(() => { + if (!existsSync(distPath)) { + throw new Error( + `dist binary not found at ${distPath}. Run \`pnpm -F ccaudit build\` before running this test.`, + ); + } +}); + +// buildJsonEnvelope spreads data at the top level: { meta: {...}, ...data } +interface Envelope { + meta: { command: string; exitCode: number }; + error?: string; + [key: string]: unknown; +} + +function parseEnvelope(stdout: string): Envelope { + return JSON.parse(stdout.trim()) as Envelope; +} + +describe.skipIf(process.platform === 'win32')( + 'restore preflight errors emit JSON envelope when --json is active', + () => { + let tmpHome: string; + + beforeEach(async () => { + tmpHome = await makeTmpHome(); + await mkdir(path.join(tmpHome, '.claude'), { recursive: true }); + await writeFile(path.join(tmpHome, '.claude.json'), '{}', 'utf8'); + await buildFakePs(tmpHome); + }); + + afterEach(async () => { + await cleanupTmpHome(tmpHome); + }); + + it('mutual-exclusion (--interactive --name) --json → JSON envelope on stdout, exit 1', async () => { + const r = await runCcauditCli( + tmpHome, + ['restore', '--interactive', '--name', 'foo', '--json'], + { env: { PATH: `${path.join(tmpHome, 'bin')}:${process.env.PATH ?? ''}` } }, + ); + expect(r.exitCode).toBe(1); + const env = parseEnvelope(r.stdout); + expect(env.meta.command).toBe('restore'); + expect(env.meta.exitCode).toBe(1); + expect(env.error).toContain('mutually exclusive'); + // No raw text on stderr alongside JSON + expect(r.stderr).toBe(''); + }); + + it('CCAUDIT_NO_INTERACTIVE=1 --interactive --json → JSON envelope on stdout, exit 2', async () => { + const r = await runCcauditCli(tmpHome, ['restore', '--interactive', '--json'], { + env: { + CCAUDIT_NO_INTERACTIVE: '1', + CCAUDIT_FORCE_TTY: '1', + PATH: `${path.join(tmpHome, 'bin')}:${process.env.PATH ?? ''}`, + }, + }); + expect(r.exitCode).toBe(2); + const env = parseEnvelope(r.stdout); + expect(env.meta.command).toBe('restore'); + expect(env.meta.exitCode).toBe(2); + expect(env.error).toContain('CCAUDIT_NO_INTERACTIVE'); + expect(r.stderr).toBe(''); + }); + + it('TTY guard (no TTY) --interactive --json → JSON envelope on stdout, exit 1', async () => { + // No CCAUDIT_FORCE_TTY — subprocess has no TTY by default in CI/subprocess context. + const r = await runCcauditCli(tmpHome, ['restore', '--interactive', '--json'], { + env: { + PATH: `${path.join(tmpHome, 'bin')}:${process.env.PATH ?? ''}`, + }, + }); + expect(r.exitCode).toBe(1); + const env = parseEnvelope(r.stdout); + expect(env.meta.command).toBe('restore'); + expect(env.meta.exitCode).toBe(1); + expect(env.error).toContain('--interactive requires a TTY'); + expect(r.stderr).toBe(''); + }); + + it('--name no-match --json → JSON envelope on stdout, exit 1', async () => { + const r = await runCcauditCli( + tmpHome, + ['restore', '--name', 'nonexistent-ghost-xyz', '--json'], + { env: { PATH: `${path.join(tmpHome, 'bin')}:${process.env.PATH ?? ''}` } }, + ); + expect(r.exitCode).toBe(1); + const env = parseEnvelope(r.stdout); + expect(env.meta.command).toBe('restore'); + expect(env.meta.exitCode).toBe(1); + expect(env.error).toContain('nonexistent-ghost-xyz'); + expect(r.stderr).toBe(''); + }); + + it('--all-matching no-match --json → JSON envelope on stdout, exit 1', async () => { + const r = await runCcauditCli( + tmpHome, + ['restore', '--all-matching', 'nonexistent-ghost-xyz', '--json'], + { env: { PATH: `${path.join(tmpHome, 'bin')}:${process.env.PATH ?? ''}` } }, + ); + expect(r.exitCode).toBe(1); + const env = parseEnvelope(r.stdout); + expect(env.meta.command).toBe('restore'); + expect(env.meta.exitCode).toBe(1); + expect(env.error).toContain('nonexistent-ghost-xyz'); + expect(r.stderr).toBe(''); + }); + }, +); + +describe.skipIf(process.platform === 'win32')( + 'restore preflight errors emit plain stderr (not JSON) without --json', + () => { + let tmpHome: string; + + beforeEach(async () => { + tmpHome = await makeTmpHome(); + await mkdir(path.join(tmpHome, '.claude'), { recursive: true }); + await writeFile(path.join(tmpHome, '.claude.json'), '{}', 'utf8'); + await buildFakePs(tmpHome); + }); + + afterEach(async () => { + await cleanupTmpHome(tmpHome); + }); + + it('mutual-exclusion without --json → plain stderr, no JSON on stdout', async () => { + const r = await runCcauditCli(tmpHome, ['restore', '--interactive', '--name', 'foo'], { + env: { PATH: `${path.join(tmpHome, 'bin')}:${process.env.PATH ?? ''}` }, + }); + expect(r.exitCode).toBe(1); + expect(r.stderr).toContain('mutually exclusive'); + expect(r.stdout).toBe(''); + }); + }, +); + +describe.skipIf(process.platform === 'win32')( + 'restore preflight empty-archive --interactive --json → JSON envelope exit 0', + () => { + it('empty archive under --interactive --json → JSON envelope on stdout, exit 0', async () => { + // Use a fresh home with no manifests: findManifestsForRestore returns [] + // → collectRestoreableItems returns [] → RestorePreflightError(0, ...). + const emptyHome = await makeTmpHome(); + try { + await mkdir(path.join(emptyHome, '.claude'), { recursive: true }); + await writeFile(path.join(emptyHome, '.claude.json'), '{}', 'utf8'); + await buildFakePs(emptyHome); + + const r = await runCcauditCli(emptyHome, ['restore', '--interactive', '--json'], { + env: { + CCAUDIT_FORCE_TTY: '1', + PATH: `${path.join(emptyHome, 'bin')}:${process.env.PATH ?? ''}`, + }, + }); + expect(r.exitCode).toBe(0); + const env = parseEnvelope(r.stdout); + expect(env.meta.command).toBe('restore'); + expect(env.meta.exitCode).toBe(0); + expect(env.error).toContain('archive is empty'); + expect(r.stderr).toBe(''); + } finally { + await cleanupTmpHome(emptyHome); + } + }); + }, +); diff --git a/apps/ccaudit/src/__tests__/restore-stale-filter.test.ts b/apps/ccaudit/src/__tests__/restore-stale-filter.test.ts new file mode 100644 index 0000000..182968e --- /dev/null +++ b/apps/ccaudit/src/__tests__/restore-stale-filter.test.ts @@ -0,0 +1,188 @@ +/** + * Phase 8.2 — `restore --list --json` drops stale archive ops. + * + * Fixture: two manifest JSONL files in `/.claude/ccaudit/manifests/`: + * - STALE manifest → archives `stale-agent` but archive_path is missing + * on disk AND source_path DOES exist on disk → already-restored / + * test-residue. Must be suppressed from `restore --list`. + * - LIVE manifest → archives `live-agent`, archive_path present on disk, + * source_path absent → genuinely restoreable. Must remain listed. + * + * Asserts: + * - `restore --list --json` exits 0 and lists only `live-agent` + * - envelope.filteredStaleCount === 1 + * - INV-S3 is not regressed: fixture mirrors the mixed-manifest shape + * that restore-interactive-roundtrip.test.ts guards + */ +import { describe, it, expect, beforeAll, beforeEach, afterEach } from 'vitest'; +import { existsSync } from 'node:fs'; +import { mkdir, writeFile, utimes } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { makeTmpHome, cleanupTmpHome, buildFakePs, runCcauditCli } from './_test-helpers.ts'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const distPath = path.resolve(here, '..', '..', 'dist', 'index.js'); + +beforeAll(() => { + if (!existsSync(distPath)) { + throw new Error( + `dist binary not found at ${distPath}. Run \`pnpm -F ccaudit build\` before running this test.`, + ); + } +}); + +interface RestoreListEnvelope { + meta: { command: string; exitCode: number }; + status: string; + entries: Array<{ + path: string; + mtime: string; + is_partial: boolean; + op_count: number; + items: Array<{ + category: string; + name: string; + source_path?: string; + archive_path?: string; + }>; + }>; + filteredStaleCount: number; +} + +/** + * Build a single JSONL manifest with one archive op. `archiveOnDisk` controls + * whether the archive_path file is actually present; `sourceOnDisk` controls + * whether source_path exists (simulating either an already-restored item or + * a genuinely-restoreable archive). + */ +async function buildManifestFixture( + tmpHome: string, + opts: { + manifestName: string; + agentName: string; + archiveOnDisk: boolean; + sourceOnDisk: boolean; + mtime: Date; + }, +): Promise { + const manifestsDir = path.join(tmpHome, '.claude', 'ccaudit', 'manifests'); + const archivedAgentsDir = path.join(tmpHome, '.claude', 'ccaudit', 'archived', 'agents'); + const agentsDir = path.join(tmpHome, '.claude', 'agents'); + await mkdir(manifestsDir, { recursive: true }); + await mkdir(archivedAgentsDir, { recursive: true }); + await mkdir(agentsDir, { recursive: true }); + + const archivePath = path.join(archivedAgentsDir, `${opts.agentName}.md`); + const sourcePath = path.join(agentsDir, `${opts.agentName}.md`); + + const header = { + record_type: 'header', + manifest_version: 1, + ccaudit_version: '1.5.0-test', + checkpoint_ghost_hash: `deadbeef-${opts.agentName}`, + checkpoint_timestamp: opts.mtime.toISOString(), + since_window: '30d', + os: 'darwin', + node_version: 'v20.0.0', + planned_ops: { archive: 1, disable: 0, flag: 0 }, + selection_filter: { mode: 'full' }, + }; + const op = { + op_id: `op-${opts.agentName}`, + op_type: 'archive', + timestamp: opts.mtime.toISOString(), + status: 'completed', + category: 'agent', + scope: 'global', + source_path: sourcePath, + archive_path: archivePath, + content_sha256: '0000000000000000000000000000000000000000000000000000000000000001', + }; + const footer = { + record_type: 'footer', + status: 'completed', + actual_ops: { + archive: { completed: 1, failed: 0 }, + disable: { completed: 0, failed: 0 }, + flag: { completed: 0, failed: 0, refreshed: 0, skipped: 0 }, + }, + duration_ms: 42, + exit_code: 0, + }; + const body = + [JSON.stringify(header), JSON.stringify(op), JSON.stringify(footer)].join('\n') + '\n'; + const manifestPath = path.join(manifestsDir, opts.manifestName); + await writeFile(manifestPath, body, 'utf8'); + await utimes(manifestPath, opts.mtime, opts.mtime); + + if (opts.archiveOnDisk) { + await writeFile(archivePath, `# ${opts.agentName} (archived)\n`, 'utf8'); + } + if (opts.sourceOnDisk) { + await writeFile(sourcePath, `# ${opts.agentName} (at source)\n`, 'utf8'); + } +} + +describe.skipIf(process.platform === 'win32')( + 'Phase 8.2 — restore --list drops stale archive ops (archive_missing + source_exists)', + () => { + let tmpHome: string; + + beforeEach(async () => { + tmpHome = await makeTmpHome(); + await buildFakePs(tmpHome); + + // STALE: archive_path missing, source_path present → should be filtered. + await buildManifestFixture(tmpHome, { + manifestName: 'bust-2026-04-20T10-00-00-000Z-stale.jsonl', + agentName: 'stale-agent', + archiveOnDisk: false, + sourceOnDisk: true, + mtime: new Date('2026-04-20T10:00:00.000Z'), + }); + + // LIVE: archive_path present, source_path missing → should remain listed. + await buildManifestFixture(tmpHome, { + manifestName: 'bust-2026-04-21T10-00-00-000Z-live.jsonl', + agentName: 'live-agent', + archiveOnDisk: true, + sourceOnDisk: false, + mtime: new Date('2026-04-21T10:00:00.000Z'), + }); + }); + + afterEach(async () => { + await cleanupTmpHome(tmpHome); + }); + + it('restore --list --json lists only the live item and reports filteredStaleCount=1', async () => { + const r = await runCcauditCli(tmpHome, ['restore', '--list', '--json'], { + env: { PATH: `${path.join(tmpHome, 'bin')}:${process.env.PATH ?? ''}` }, + }); + expect(r.exitCode, `stderr:\n${r.stderr}\nstdout:\n${r.stdout}`).toBe(0); + + const parsed = JSON.parse(r.stdout.trim()) as RestoreListEnvelope; + expect(parsed.meta.command).toBe('restore'); + expect(parsed.meta.exitCode).toBe(0); + expect(parsed.status).toBe('list'); + + // filteredStaleCount reflects the suppressed stale archive op. + expect(parsed.filteredStaleCount).toBe(1); + + // Aggregate archive-op items across all entries: exactly one (live-agent). + const allItems = parsed.entries.flatMap((e) => e.items); + const archiveItems = allItems.filter((i) => i.category === 'agent'); + expect(archiveItems).toHaveLength(1); + expect(archiveItems[0]!.name).toBe('live-agent'); + + // Defensive: the stale item must NOT appear anywhere in the listing. + const names = archiveItems.map((i) => i.name); + expect(names).not.toContain('stale-agent'); + }); + + it.todo( + 'filter propagates to subset restore via interactive CLI path — covered by Task 1 in-source tests', + ); + }, +); diff --git a/apps/ccaudit/src/__tests__/safety-invariants-framework.test.ts b/apps/ccaudit/src/__tests__/safety-invariants-framework.test.ts new file mode 100644 index 0000000..ea83d8d --- /dev/null +++ b/apps/ccaudit/src/__tests__/safety-invariants-framework.test.ts @@ -0,0 +1,290 @@ +/** + * Safety-invariant integration tests for INV-S6: framework-as-unit bust protection. + * + * Spawns the built binary (apps/ccaudit/dist/index.js) with HOME overridden to a + * tmpdir fixture. Uses the Phase 3 helpers from _test-helpers.ts: + * - createFrameworkFixture: partial-use GSD framework (1 used + 2 ghost agents) + * - buildFakePs: fake `ps` shim so preflight passes inside a Claude Code session + * - runCcauditGhost: subprocess runner returning live child + done promise + * - agentItemId: canonical ID for a global agent file + * + * Coverage: + * INV-S6 Test A: framework protection is enforced by default — ghost member of a + * partially-used framework is NOT archived; source file survives; + * dry-run JSON envelope carries changePlan.protected[]; bust JSON + * envelope carries bust.protectionWarnings[]. + * INV-S6 Test B: --force-partial unlocks protection — ghost member IS archived; + * manifest planned_ops.archive === 1. + * INV-S6 Test C: TUI picker locking is a deferred placeholder (Phase 6 TUI-05). + * + * Local-vs-CI note + * ───────────────── + * Same fake-ps shim as bust-command.test.ts: each test writes a FAKE `ps` script + * into `/bin/ps` so the bust preflight finds no running Claude Code + * process and proceeds. Without it, tests run from inside a Claude Code session + * would fail with exit 3 every time. + */ +import { describe, it, expect, beforeAll, beforeEach, afterEach } from 'vitest'; +import { existsSync } from 'node:fs'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + buildFakePs, + createFrameworkFixture, + createMultiFrameworkFixture, + runCcauditGhost, + agentItemId, +} from './_test-helpers.ts'; + +// ── Resolve dist path ────────────────────────────────────────────────────── + +const here = path.dirname(fileURLToPath(import.meta.url)); +const distPath = path.resolve(here, '..', '..', 'dist', 'index.js'); + +// ── Guard: dist must exist before any test runs ──────────────────────────── + +beforeAll(() => { + if (!existsSync(distPath)) { + throw new Error( + `dist binary not found at ${distPath}. Run \`pnpm -F ccaudit build\` before running this test.`, + ); + } +}); + +// ── INV-S6 framework protection tests ───────────────────────────────────── + +// Windows: fake `ps` shell scripts require /bin/sh — skip on win32. +describe.skipIf(process.platform === 'win32')('INV-S6: framework-as-unit bust protection', () => { + let tmpHome: string; + + beforeEach(async () => { + tmpHome = await mkdtemp(path.join(tmpdir(), 'ccaudit-inv-s6-')); + // Install fake-ps shim so the bust preflight passes when run inside + // a Claude Code session (same pattern as bust-command.test.ts). + await buildFakePs(tmpHome); + // Build the partial-use GSD framework fixture: + // gsd-planner.md → used (recent mtime + Task invocation in session JSONL) + // gsd-researcher.md → ghost (60-day-old mtime) + // gsd-verifier.md → ghost (60-day-old mtime) + await createFrameworkFixture(tmpHome); + }); + + afterEach(async () => { + await rm(tmpHome, { recursive: true, force: true }); + }); + + // ── Test A: protection enforced by default ───────────────────────────── + + it('Test A — INV-S6: protected ghost is excluded by default; source file survives', async () => { + // Compute the canonical ID for the ghost we want to select. + const ghostId = agentItemId(tmpHome, 'gsd-researcher.md'); + + // Step 1: dry-run to create the checkpoint. + // Protected items appear in changePlan.protected[]; archive count is 0. + const dryResult = await runCcauditGhost(tmpHome, ['--dry-run', '--json'], {}).done; + expect(dryResult.exitCode, `dry-run stderr: ${dryResult.stderr}`).toBe(0); + + const dryEnvelope = JSON.parse(dryResult.stdout) as { + dryRun: boolean; + changePlan: { + archive: unknown[]; + counts: { agents: number }; + protected?: Array<{ name: string; tier: string }>; + protectionWarnings?: Array<{ frameworkId: string; status: string }>; + }; + }; + + // The dry-run archive list is empty — the ghosts are protected. + expect(dryEnvelope.dryRun).toBe(true); + expect(dryEnvelope.changePlan.counts.agents).toBe(0); + expect(dryEnvelope.changePlan.archive).toHaveLength(0); + + // changePlan.protected[] is populated with the two framework-protected ghosts. + expect(dryEnvelope.changePlan.protected).toBeDefined(); + expect(dryEnvelope.changePlan.protected!.length).toBeGreaterThanOrEqual(1); + const protectedNames = dryEnvelope.changePlan.protected!.map((p) => p.name); + expect(protectedNames).toContain('gsd-researcher'); + + // protectionWarnings[] carries the framework audit trail. + expect(dryEnvelope.changePlan.protectionWarnings).toBeDefined(); + expect(dryEnvelope.changePlan.protectionWarnings!).toHaveLength(1); + expect(dryEnvelope.changePlan.protectionWarnings![0]!.frameworkId).toBe('gsd'); + expect(dryEnvelope.changePlan.protectionWarnings![0]!.status).toBe('partially-used'); + + // Step 2: bust — select the protected ghost via CCAUDIT_SELECT_IDS. + // Without --force-partial the item is removed from the eligible set before + // reaching runBust, so it is never archived. + const bustResult = await runCcauditGhost( + tmpHome, + ['--dangerously-bust-ghosts', '--yes-proceed-busting', '--json'], + { env: { CCAUDIT_SELECT_IDS: ghostId } }, + ).done; + expect(bustResult.exitCode, `bust stderr: ${bustResult.stderr}`).toBe(0); + + const bustEnvelope = JSON.parse(bustResult.stdout) as { + bust: { + status: string; + counts: { archive: { agents: number } }; + protectionWarnings?: Array<{ frameworkId: string; status: string }>; + }; + }; + expect(bustEnvelope.bust.status).toBe('success'); + + // No agents were archived — protection held. + expect(bustEnvelope.bust.counts.archive.agents).toBe(0); + + // The bust JSON envelope emits protectionWarnings for the audit trail. + expect(bustEnvelope.bust.protectionWarnings).toBeDefined(); + expect(bustEnvelope.bust.protectionWarnings!).toHaveLength(1); + expect(bustEnvelope.bust.protectionWarnings![0]!.frameworkId).toBe('gsd'); + + // Source file is still on disk — nothing was moved. + expect( + existsSync(path.join(tmpHome, '.claude', 'agents', 'gsd-researcher.md')), + 'gsd-researcher.md must still exist after protection-blocked bust', + ).toBe(true); + }); + + // ── Test B: --force-partial unlocks protection ───────────────────────── + + it('Test B — INV-S6: --force-partial unlocks protected ghost; file is archived', async () => { + // Compute the canonical ID for the ghost we want to select. + const ghostId = agentItemId(tmpHome, 'gsd-researcher.md'); + + // Step 1: dry-run WITH --force-partial so the checkpoint hash covers the + // expanded eligible set (protection bypassed at dry-run time too). + const dryResult = await runCcauditGhost(tmpHome, ['--dry-run', '--force-partial', '--json'], {}) + .done; + expect(dryResult.exitCode, `dry-run stderr: ${dryResult.stderr}`).toBe(0); + + const dryEnvelope = JSON.parse(dryResult.stdout) as { + dryRun: boolean; + changePlan: { counts: { agents: number } }; + }; + // With --force-partial both ghost agents are eligible → counts > 0. + expect(dryEnvelope.dryRun).toBe(true); + expect(dryEnvelope.changePlan.counts.agents).toBeGreaterThanOrEqual(1); + + // Step 2: bust WITH --force-partial and CCAUDIT_SELECT_IDS targeting the researcher. + const bustResult = await runCcauditGhost( + tmpHome, + ['--dangerously-bust-ghosts', '--force-partial', '--yes-proceed-busting', '--json'], + { env: { CCAUDIT_SELECT_IDS: ghostId } }, + ).done; + expect(bustResult.exitCode, `bust stderr: ${bustResult.stderr}`).toBe(0); + + const bustEnvelope = JSON.parse(bustResult.stdout) as { + bust: { + status: string; + counts: { archive: { agents: number } }; + manifestPath: string; + }; + }; + expect(bustEnvelope.bust.status).toBe('success'); + + // Exactly 1 agent was archived — the force-partial override worked. + expect(bustEnvelope.bust.counts.archive.agents).toBe(1); + + // Source file moved to archive; no longer at original path. + expect( + existsSync(path.join(tmpHome, '.claude', 'agents', 'gsd-researcher.md')), + 'gsd-researcher.md must NOT exist at original path after --force-partial bust', + ).toBe(false); + + // Archive destination exists. + expect( + existsSync( + path.join(tmpHome, '.claude', 'ccaudit', 'archived', 'agents', 'gsd-researcher.md'), + ), + 'gsd-researcher.md must exist in the archive directory after --force-partial bust', + ).toBe(true); + }); + + // ── Test C: INV-S6 with a multi-framework fixture (Phase 6 strengthening) ── + // + // Converts the former `it.todo` Phase 6 pointer into a real test. Uses + // `createMultiFrameworkFixture` to stand up TWO curated frameworks, each + // in partially-used state, and asserts that: + // - Without `--force-partial`, selecting a ghost member via + // `CCAUDIT_SELECT_IDS` is filtered by framework protection: no archive, + // source files survive on disk, `protectionWarnings[]` emitted for + // BOTH frameworks in the JSON envelope. + // - With `--force-partial`, selection succeeds and the manifest reflects + // exactly the selected subset (1 item archived, not the whole group). + // + // This is the canonical Phase 6 INV-S6 regression: multi-framework trust + // boundary (2 independent partially-used groups) plus the interactive + // selection gate (selection Set ≠ filtered list). + + it('Test C — INV-S6 multi-framework: both frameworks blocked; --force-partial archives only the selected subset', async () => { + // Clean up the tmpHome created by beforeEach before replacing it with the + // multi-framework fixture, so the original temp directory is not leaked. + await rm(tmpHome, { recursive: true, force: true }); + tmpHome = await (async () => { + const p = await mkdtemp(path.join(tmpdir(), 'ccaudit-inv-s6-multi-')); + await buildFakePs(p); + await createMultiFrameworkFixture(p, [ + { prefix: 'gsd', usedMembers: ['planner'], ghostMembers: ['researcher', 'verifier'] }, + { prefix: 'sc', usedMembers: ['analyze'], ghostMembers: ['audit', 'improve'] }, + ]); + return p; + })(); + + // ── Without --force-partial: dry-run sees BOTH frameworks protected. ── + const dryResult = await runCcauditGhost(tmpHome, ['--dry-run', '--json'], {}).done; + expect(dryResult.exitCode, `dry-run stderr: ${dryResult.stderr}`).toBe(0); + const dry = JSON.parse(dryResult.stdout) as { + changePlan: { + archive: unknown[]; + protected?: Array<{ name: string }>; + protectionWarnings?: Array<{ frameworkId: string; status: string }>; + }; + }; + expect(dry.changePlan.archive).toHaveLength(0); + const warnings = dry.changePlan.protectionWarnings ?? []; + const warnIds = warnings.map((w) => w.frameworkId).sort(); + expect(warnIds).toContain('gsd'); + expect(warnIds).toContain('superclaude'); + // protected[] carries all 4 ghost members across both frameworks. + expect(dry.changePlan.protected!.length).toBeGreaterThanOrEqual(4); + + // ── Selecting one ghost without --force-partial is blocked. ── + const ghostId = agentItemId(tmpHome, 'sc-audit.md'); + const blockedBust = await runCcauditGhost( + tmpHome, + ['--dangerously-bust-ghosts', '--yes-proceed-busting', '--json'], + { env: { CCAUDIT_SELECT_IDS: ghostId } }, + ).done; + expect(blockedBust.exitCode, `stderr: ${blockedBust.stderr}`).toBe(0); + const blockedEnv = JSON.parse(blockedBust.stdout) as { + bust: { counts: { archive: { agents: number } } }; + }; + expect(blockedEnv.bust.counts.archive.agents).toBe(0); + // Source still on disk. + expect(existsSync(path.join(tmpHome, '.claude', 'agents', 'sc-audit.md'))).toBe(true); + + // ── With --force-partial: dry-run + bust one specific ghost. ── + const dryForce = await runCcauditGhost(tmpHome, ['--dry-run', '--force-partial', '--json'], {}) + .done; + expect(dryForce.exitCode).toBe(0); + + const busted = await runCcauditGhost( + tmpHome, + ['--dangerously-bust-ghosts', '--force-partial', '--yes-proceed-busting', '--json'], + { env: { CCAUDIT_SELECT_IDS: ghostId } }, + ).done; + expect(busted.exitCode, `stderr: ${busted.stderr}`).toBe(0); + const bustedEnv = JSON.parse(busted.stdout) as { + bust: { counts: { archive: { agents: number } } }; + }; + // Exactly 1 archived — the selected subset, NOT the whole framework group. + expect(bustedEnv.bust.counts.archive.agents).toBe(1); + expect(existsSync(path.join(tmpHome, '.claude', 'agents', 'sc-audit.md'))).toBe(false); + // The other framework's ghosts + the sibling sc-improve stay untouched. + expect(existsSync(path.join(tmpHome, '.claude', 'agents', 'sc-improve.md'))).toBe(true); + expect(existsSync(path.join(tmpHome, '.claude', 'agents', 'gsd-researcher.md'))).toBe(true); + expect(existsSync(path.join(tmpHome, '.claude', 'agents', 'gsd-verifier.md'))).toBe(true); + }); +}); diff --git a/apps/ccaudit/src/__tests__/safety-invariants-mcp.test.ts b/apps/ccaudit/src/__tests__/safety-invariants-mcp.test.ts new file mode 100644 index 0000000..6e0fa97 --- /dev/null +++ b/apps/ccaudit/src/__tests__/safety-invariants-mcp.test.ts @@ -0,0 +1,450 @@ +/** + * Phase 3 — Safety-invariant integration tests for MCP byte-preservation + * (INV-S1) and the cross-path equivalence smoke for INV-S4 + INV-S5 + * (CONTEXT D-13). + * + * INV-S1 (SAFETY-01): unselected MCP server keys in shared ~/.claude.json + * are byte-preserved across a subset bust. The test selects ONLY serverA + * via CCAUDIT_SELECT_IDS, runs --dangerously-bust-ghosts, then asserts + * that serverB's key + value + surrounding formatting bytes are + * IDENTICAL to a hand-crafted slice of the original fixture file. + * + * Why bytes (not JSON-equivalent): a naive JSON.parse + JSON.stringify + * round-trip would produce JSON-equivalent but byte-DIFFERENT output + * (key reordering, indent changes, trailing newline loss). The fixture + * deliberately uses 2-space indent + serverA-before-serverB key order + * + a trailing newline so any naive rewrite would fail this test. + * + * INV-S4/S5 cross-path equivalence: ONE combined test that runs the + * SAME subset bust as INV-S1 and asserts the manifest's selection_filter + * shape + freedTokens-vs-totalPlannedTokens semantics match what the + * Phase 1 unit tests already pin for the ghost-select-ids env path. + * This is intentionally light coverage (CONTEXT D-13). + * + * Pattern mirrors bust-command.test.ts and ghost-select-ids.test.ts. + * Helpers come from _test-helpers.ts (Phase 3 Plan 01). + */ +import { describe, it, expect, beforeAll, beforeEach, afterEach } from 'vitest'; +import { existsSync } from 'node:fs'; +import { writeFile, mkdir } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { readManifest } from '@ccaudit/internal'; +import { + makeTmpHome, + cleanupTmpHome, + buildFakePs, + runCcauditGhost, + createMcpFixture, + readMcpConfigBytes, + mcpItemId, +} from './_test-helpers.ts'; + +// ── Guard: dist must exist before any test runs ──────────────── + +const here = path.dirname(fileURLToPath(import.meta.url)); +const distPath = path.resolve(here, '..', '..', 'dist', 'index.js'); +beforeAll(() => { + if (!existsSync(distPath)) { + throw new Error( + `dist binary not found at ${distPath}. Run \`pnpm -F ccaudit build\` before running this test.`, + ); + } +}); + +// ───────────────────────────────────────────────────────────────────────────── +// INV-S1: MCP byte-preservation (SAFETY-01) +// ───────────────────────────────────────────────────────────────────────────── + +describe.skipIf(process.platform === 'win32')( + 'Phase 3 — INV-S1: MCP byte-preservation (SAFETY-01)', + () => { + let tmpHome: string; + let preBustBytes: Buffer; + + beforeEach(async () => { + tmpHome = await makeTmpHome(); + // .claude/ directory tree the scanners walk + await mkdir(path.join(tmpHome, '.claude', 'agents'), { recursive: true }); + await mkdir(path.join(tmpHome, '.claude', 'skills'), { recursive: true }); + await mkdir(path.join(tmpHome, '.config', 'claude'), { recursive: true }); + // Minimal session JSONL so discoverSessionFiles returns ≥1 file + const sessionDir = path.join(tmpHome, '.claude', 'projects', 'fake-project'); + await mkdir(sessionDir, { recursive: true }); + const sessionLine = JSON.stringify({ + type: 'system', + subtype: 'init', + cwd: '/fake/project', + timestamp: new Date(Date.now() - 60 * 60 * 1000).toISOString(), + sessionId: 'inv-s1-session', + }); + await writeFile(path.join(sessionDir, 'session-1.jsonl'), sessionLine + '\n', 'utf8'); + + // MCP fixture: 2 servers (A, B) with deliberate formatting quirks + await createMcpFixture(tmpHome); + preBustBytes = await readMcpConfigBytes(tmpHome); + + // Fake ps shim (Claude-process preflight passes) + await buildFakePs(tmpHome); + }); + + afterEach(async () => { + await cleanupTmpHome(tmpHome); + }); + + // ── Test 1: INV-S1 — serverB bytes byte-identical post-subset-bust ── + it('serverB key + value + surrounding bytes are byte-identical after subset-bust(serverA)', async () => { + // Step 1: dry-run to write checkpoint (gate 1). + const dry = runCcauditGhost(tmpHome, ['--dry-run', '--yes-proceed-busting', '--json']); + dry.child.stdin?.end(); + const dryResult = await dry.done; + expect(dryResult.exitCode, `dry-run stderr: ${dryResult.stderr}`).toBe(0); + + // Step 2: subset-bust serverA only via CCAUDIT_SELECT_IDS. + const aId = mcpItemId(tmpHome, 'serverA'); + const bust = runCcauditGhost( + tmpHome, + ['--dangerously-bust-ghosts', '--yes-proceed-busting', '--json'], + { env: { CCAUDIT_SELECT_IDS: aId } }, + ); + bust.child.stdin?.end(); + const bustResult = await bust.done; + expect(bustResult.exitCode, `bust stderr: ${bustResult.stderr}`).toBe(0); + + // Step 3: read post-bust bytes. + const postBustBytes = await readMcpConfigBytes(tmpHome); + const postBustText = postBustBytes.toString('utf8'); + + // Step 4: locate serverB's exact byte slice in pre AND post buffers. + // Slice spans from the literal '"serverB":' opening through the closing '}' of its value object. + // Implementation: find the byte index of '"serverB":' in both buffers, then walk forward + // to the matching '}' that closes the value object (track brace depth from 0 at the colon). + const findServerBSlice = (text: string): { start: number; end: number } | null => { + const needle = '"serverB":'; + const start = text.indexOf(needle); + if (start === -1) return null; + // Walk forward from after the colon, finding the value's opening '{' and matching '}'. + let i = start + needle.length; + // Skip any whitespace + while (i < text.length && (text[i] === ' ' || text[i] === '\n' || text[i] === '\t')) i++; + if (text[i] !== '{') return null; // not an object value + // Walk to the matching closing `}`, respecting JSON string boundaries. + let depth = 0; + let inString = false; + for (; i < text.length; i++) { + const ch = text[i]; + if (inString) { + if (ch === '\\') { + i++; + continue; + } // skip escaped char + if (ch === '"') inString = false; + continue; + } + if (ch === '"') { + inString = true; + continue; + } + if (ch === '{') depth++; + else if (ch === '}') { + depth--; + if (depth === 0) return { start, end: i + 1 }; + } + } + return null; + }; + const preText = preBustBytes.toString('utf8'); + const preSlice = findServerBSlice(preText); + const postSlice = findServerBSlice(postBustText); + + expect(preSlice, 'serverB block must be present in pre-bust fixture').not.toBeNull(); + expect( + postSlice, + `serverB block missing post-bust; full post-bust file:\n${postBustText}`, + ).not.toBeNull(); + + // Compare the EXACT byte ranges. Strings (not Buffers) are sufficient because + // utf8 round-trip is byte-stable for ASCII fixtures, and the assertion message + // is far more legible on string mismatch. + const preServerBChunk = preText.slice(preSlice!.start, preSlice!.end); + const postServerBChunk = postBustText.slice(postSlice!.start, postSlice!.end); + expect(postServerBChunk).toBe(preServerBChunk); + }); + + // ── Test 2: INV-S1 supplement — serverA key renamed to ccaudit-disabled:serverA ── + it('serverA key is renamed to ccaudit-disabled:serverA after subset-bust(serverA)', async () => { + // dry-run + subset-bust as above + const dry = runCcauditGhost(tmpHome, ['--dry-run', '--yes-proceed-busting', '--json']); + dry.child.stdin?.end(); + await dry.done; + + const aId = mcpItemId(tmpHome, 'serverA'); + const bust = runCcauditGhost( + tmpHome, + ['--dangerously-bust-ghosts', '--yes-proceed-busting', '--json'], + { env: { CCAUDIT_SELECT_IDS: aId } }, + ); + bust.child.stdin?.end(); + const bustResult = await bust.done; + expect(bustResult.exitCode, `bust stderr: ${bustResult.stderr}`).toBe(0); + + // Parse post-bust JSON to verify the rename. + const postBustParsed = JSON.parse( + (await readMcpConfigBytes(tmpHome)).toString('utf8'), + ) as Record; + + // ccaudit-disabled:serverA appears at the ROOT level (current v1.4.0 disable behavior), + // and the original mcpServers.serverA key is gone. + expect(postBustParsed['ccaudit-disabled:serverA']).toBeDefined(); + expect(postBustParsed['ccaudit-disabled:serverA']).toMatchObject({ + command: 'npx', + args: ['server-a'], + }); + const mcpServers = postBustParsed.mcpServers as Record | undefined; + expect(mcpServers?.serverA).toBeUndefined(); + // serverB is still under mcpServers (not renamed because it was not selected). + expect(mcpServers?.serverB).toBeDefined(); + }); + }, +); + +// ───────────────────────────────────────────────────────────────── +// INV-S4/S5 cross-path equivalence (CONTEXT D-13) — light coverage +// ───────────────────────────────────────────────────────────────── + +describe.skipIf(process.platform === 'win32')( + 'Phase 3 — INV-S4 + INV-S5 cross-path equivalence (light)', + () => { + let tmpHome: string; + + beforeEach(async () => { + tmpHome = await makeTmpHome(); + await mkdir(path.join(tmpHome, '.claude', 'agents'), { recursive: true }); + await mkdir(path.join(tmpHome, '.claude', 'skills'), { recursive: true }); + await mkdir(path.join(tmpHome, '.config', 'claude'), { recursive: true }); + const sessionDir = path.join(tmpHome, '.claude', 'projects', 'fake-project'); + await mkdir(sessionDir, { recursive: true }); + await writeFile( + path.join(sessionDir, 'session-1.jsonl'), + JSON.stringify({ + type: 'system', + subtype: 'init', + cwd: '/fake/project', + timestamp: new Date(Date.now() - 60 * 60 * 1000).toISOString(), + sessionId: 'cross-path', + }) + '\n', + 'utf8', + ); + await createMcpFixture(tmpHome); + await buildFakePs(tmpHome); + }); + + afterEach(async () => { + await cleanupTmpHome(tmpHome); + }); + + it('subset bust on serverA produces a manifest with subset selection_filter and subset-accurate freedTokens', async () => { + const dry = runCcauditGhost(tmpHome, ['--dry-run', '--yes-proceed-busting', '--json']); + dry.child.stdin?.end(); + const dryResult = await dry.done; + expect(dryResult.exitCode, `dry-run stderr: ${dryResult.stderr}`).toBe(0); + + const aId = mcpItemId(tmpHome, 'serverA'); + const bust = runCcauditGhost( + tmpHome, + ['--dangerously-bust-ghosts', '--yes-proceed-busting', '--json'], + { env: { CCAUDIT_SELECT_IDS: aId } }, + ); + bust.child.stdin?.end(); + const bustResult = await bust.done; + expect(bustResult.exitCode, `bust stderr: ${bustResult.stderr}`).toBe(0); + + const parsed = JSON.parse(bustResult.stdout) as { + bust: { + status: string; + manifestPath: string; + summary: { freedTokens: number; totalPlannedTokens: number }; + }; + }; + expect(parsed.bust.status).toBe('success'); + + // INV-S4 cross-path: manifest selection_filter is subset, planned_ops.disable=1 + const manifest = await readManifest(parsed.bust.manifestPath); + expect(manifest.header).not.toBeNull(); + expect(manifest.header!.selection_filter).toBeDefined(); + expect(manifest.header!.selection_filter!.mode).toBe('subset'); + const sf = manifest.header!.selection_filter as { mode: 'subset'; ids: string[] }; + expect(sf.ids).toEqual([aId]); + expect(manifest.header!.planned_ops.disable).toBe(1); + expect(manifest.header!.planned_ops.archive).toBe(0); + expect(manifest.header!.planned_ops.flag).toBe(0); + + // INV-S5 cross-path: freedTokens > 0, freedTokens < totalPlannedTokens + // (Only 1 of 2 MCP servers archived → strict less-than. We tolerate equal + // if the unselected server's token estimate is 0, but with the canonical + // npx-style fixture both estimate to a positive value.) + expect(parsed.bust.summary.freedTokens).toBeGreaterThan(0); + expect(parsed.bust.summary.freedTokens).toBeLessThanOrEqual( + parsed.bust.summary.totalPlannedTokens, + ); + }); + }, +); + +// ───────────────────────────────────────────────────────────────────────────── +// C2: INV-S1 with project-scoped + global-scoped same-name servers +// ───────────────────────────────────────────────────────────────────────────── + +describe.skipIf(process.platform === 'win32')( + 'Phase 3 — C2: INV-S1 scoped rename with project+global same-name servers', + () => { + let tmpHome: string; + + beforeEach(async () => { + tmpHome = await makeTmpHome(); + await mkdir(path.join(tmpHome, '.claude', 'agents'), { recursive: true }); + await mkdir(path.join(tmpHome, '.claude', 'skills'), { recursive: true }); + await mkdir(path.join(tmpHome, '.config', 'claude'), { recursive: true }); + const sessionDir = path.join(tmpHome, '.claude', 'projects', 'fake-project'); + await mkdir(sessionDir, { recursive: true }); + await writeFile( + path.join(sessionDir, 'session-1.jsonl'), + JSON.stringify({ + type: 'system', + subtype: 'init', + cwd: '/fake/project', + timestamp: new Date(Date.now() - 60 * 60 * 1000).toISOString(), + sessionId: 'c2-inv-s1-session', + }) + '\n', + 'utf8', + ); + + // Fixture: serverA appears in BOTH a project-level block AND the global + // mcpServers block. The project entry appears first in the document so a + // naive text.indexOf(`"serverA":`) would match it instead of the global one. + const body = + '{\n' + + ' "projects": {\n' + + ' "/some/project": {\n' + + ' "mcpServers": {\n' + + ' "serverA": {\n' + + ' "command": "project-scoped"\n' + + ' }\n' + + ' }\n' + + ' }\n' + + ' },\n' + + ' "mcpServers": {\n' + + ' "serverA": {\n' + + ' "command": "npx",\n' + + ' "args": ["server-a"]\n' + + ' },\n' + + ' "serverB": {\n' + + ' "command": "npx",\n' + + ' "args": ["server-b"]\n' + + ' }\n' + + ' }\n' + + '}\n'; + await writeFile(path.join(tmpHome, '.claude.json'), body, 'utf8'); + + await buildFakePs(tmpHome); + }); + + afterEach(async () => { + await cleanupTmpHome(tmpHome); + }); + + it('C2-INV-S1: subset bust of global serverA renames global key, leaves project block byte-identical', async () => { + // Step 1: dry-run checkpoint + const dry = runCcauditGhost(tmpHome, ['--dry-run', '--yes-proceed-busting', '--json']); + dry.child.stdin?.end(); + const dryResult = await dry.done; + expect(dryResult.exitCode, `dry-run stderr: ${dryResult.stderr}`).toBe(0); + + // Capture original project block bytes for comparison + const preBustText = (await readMcpConfigBytes(tmpHome)).toString('utf8'); + + // Step 2: subset-bust global serverA only + const aId = mcpItemId(tmpHome, 'serverA'); + const bust = runCcauditGhost( + tmpHome, + ['--dangerously-bust-ghosts', '--yes-proceed-busting', '--json'], + { env: { CCAUDIT_SELECT_IDS: aId } }, + ); + bust.child.stdin?.end(); + const bustResult = await bust.done; + expect(bustResult.exitCode, `bust stderr: ${bustResult.stderr}`).toBe(0); + + const postBustText = (await readMcpConfigBytes(tmpHome)).toString('utf8'); + const postBustParsed = JSON.parse(postBustText) as Record; + + // Global mcpServers.serverA must be gone + const globalMcp = postBustParsed.mcpServers as Record | undefined; + expect(globalMcp?.serverA).toBeUndefined(); + + // A ccaudit-disabled:serverA key must appear at root with the global value + const disabledKey = Object.keys(postBustParsed).find((k) => + k.startsWith('ccaudit-disabled:serverA'), + ); + expect(disabledKey).toBeDefined(); + expect(postBustParsed[disabledKey!]).toMatchObject({ command: 'npx', args: ['server-a'] }); + + // Project-scoped serverA must be untouched + const projects = postBustParsed.projects as Record< + string, + { mcpServers?: Record } + >; + expect(projects['/some/project']?.mcpServers?.serverA).toEqual({ + command: 'project-scoped', + }); + + // Verify the project block bytes are identical (C2 byte-preservation check) + const findProjectBlock = (text: string): string | null => { + const needle = '"projects":'; + const start = text.indexOf(needle); + if (start === -1) return null; + let i = start + needle.length; + while (i < text.length && /\s/.test(text[i]!)) i++; + if (text[i] !== '{') return null; + let depth = 0; + let inS = false; + for (let k = i; k < text.length; k++) { + const c = text[k]; + if (inS) { + if (c === '\\') { + k++; + continue; + } + if (c === '"') inS = false; + continue; + } + if (c === '"') { + inS = true; + continue; + } + if (c === '{') depth++; + else if (c === '}') { + depth--; + if (depth === 0) return text.slice(i, k + 1); + } + } + return null; + }; + const preProjectBlock = findProjectBlock(preBustText); + const postProjectBlock = findProjectBlock(postBustText); + expect(preProjectBlock).not.toBeNull(); + expect(postProjectBlock).toBe(preProjectBlock); + + // Manifest records original_key === "mcpServers.serverA" and scope === "global" + const envelope = JSON.parse(bustResult.stdout) as { + bust: { manifestPath: string }; + }; + const manifest = await readManifest(envelope.bust.manifestPath); + const disableOp = (manifest.ops ?? []).find( + (o: { op_type?: string }) => o.op_type === 'disable', + ) as { original_key?: string; scope?: string } | undefined; + expect(disableOp).toBeDefined(); + expect(disableOp!.original_key).toBe('mcpServers.serverA'); + expect(disableOp!.scope).toBe('global'); + }); + }, +); diff --git a/apps/ccaudit/src/__tests__/safety-invariants-restore-roundtrip.test.ts b/apps/ccaudit/src/__tests__/safety-invariants-restore-roundtrip.test.ts new file mode 100644 index 0000000..4a596fd --- /dev/null +++ b/apps/ccaudit/src/__tests__/safety-invariants-restore-roundtrip.test.ts @@ -0,0 +1,368 @@ +/** + * Phase 3 — INV-S3 (SAFETY-03): subset manifests + full manifests round-trip + * cleanly through `ccaudit restore`. + * + * Strategy (CONTEXT D-10, D-11): + * 1. Build a fixture with 3 ghost agents: alpha, beta, gamma. + * 2. Subset-bust {alpha, beta} via CCAUDIT_SELECT_IDS=alphaId,betaId. + * → first manifest with planned_ops.archive=2, selection_filter.mode='subset'. + * 3. Re-run dry-run (the inventory has changed: alpha+beta gone), then + * full-bust {gamma} (no CCAUDIT_SELECT_IDS). + * → second manifest with planned_ops.archive=1, selection_filter.mode='full'. + * 4. Run `ccaudit restore --json`. + * → assert status='success', counts.unarchived.moved === 3. + * → assert all 3 source files exist at their original paths. + * → assert archived/agents/ does not contain any of {alpha,beta,gamma}. + * + * Per CONTEXT D-12 the dedup-newer-wins stress (re-bust same item, restore + * dedups) is OUT of scope for Phase 3 — Phase 8 owns the restore TUI work + * and will add the dedup test there. Phase 3 covers the happy path of two + * manifests with disjoint contents. + */ +import { describe, it, expect, beforeAll, beforeEach, afterEach } from 'vitest'; +import { existsSync } from 'node:fs'; +import { mkdir, writeFile, readFile, readdir, utimes } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + makeTmpHome, + cleanupTmpHome, + buildFakePs, + runCcauditGhost, + runCcauditCli, + agentItemId, +} from './_test-helpers.ts'; + +// ── Dist guard ───────────────────────────────────────────────────────────── + +const here = path.dirname(fileURLToPath(import.meta.url)); +const distPath = path.resolve(here, '..', '..', 'dist', 'index.js'); + +beforeAll(() => { + if (!existsSync(distPath)) { + throw new Error( + `dist binary not found at ${distPath}. Run \`pnpm -F ccaudit build\` before running this test.`, + ); + } +}); + +// ── Tests ────────────────────────────────────────────────────────────────── + +// Windows: fake `ps` shell scripts require /bin/sh; skip on win32. +describe.skipIf(process.platform === 'win32')( + 'Phase 3 — INV-S3: subset + full manifests round-trip via `ccaudit restore` (SAFETY-03)', + () => { + let tmpHome: string; + + beforeEach(async () => { + tmpHome = await makeTmpHome(); + // Base scaffold + await mkdir(path.join(tmpHome, '.claude', 'agents'), { recursive: true }); + await mkdir(path.join(tmpHome, '.claude', 'skills'), { recursive: true }); + await mkdir(path.join(tmpHome, '.config', 'claude'), { recursive: true }); + const sessionDir = path.join(tmpHome, '.claude', 'projects', 'fake-project'); + await mkdir(sessionDir, { recursive: true }); + await writeFile( + path.join(sessionDir, 'session-1.jsonl'), + JSON.stringify({ + type: 'system', + subtype: 'init', + cwd: '/fake/project', + timestamp: new Date(Date.now() - 60 * 60 * 1000).toISOString(), + sessionId: 'inv-s3-session', + }) + '\n', + 'utf8', + ); + await writeFile(path.join(tmpHome, '.claude.json'), '{}', 'utf8'); + + // 3 ghost agents + await writeFile( + path.join(tmpHome, '.claude', 'agents', 'alpha.md'), + '# alpha\nNever invoked.\n', + 'utf8', + ); + await writeFile( + path.join(tmpHome, '.claude', 'agents', 'beta.md'), + '# beta\nNever invoked.\n', + 'utf8', + ); + await writeFile( + path.join(tmpHome, '.claude', 'agents', 'gamma.md'), + '# gamma\nNever invoked.\n', + 'utf8', + ); + + await buildFakePs(tmpHome); + }); + + afterEach(async () => { + await cleanupTmpHome(tmpHome); + }); + + // Helper: run a sequence of CLI invocations against the fixture. + // Each invocation uses runCcauditGhost (which exposes the ChildProcess). + // We end stdin immediately and await `done` for non-interactive flows. + async function runGhost( + args: string[], + env?: Record, + ): Promise<{ stdout: string; stderr: string; exitCode: number | null }> { + const spawned = runCcauditGhost(tmpHome, args, env ? { env } : {}); + spawned.child.stdin?.end(); + const r = await spawned.done; + return { stdout: r.stdout, stderr: r.stderr, exitCode: r.exitCode }; + } + + it('subset bust {alpha, beta} + full bust {gamma} → restore restores all three', async () => { + // Step 1: dry-run (writes checkpoint with all 3 agents in scope). + const dry1 = await runGhost(['--dry-run', '--yes-proceed-busting', '--json']); + expect(dry1.exitCode, `dry-run 1 stderr: ${dry1.stderr}`).toBe(0); + + // Step 2: subset-bust {alpha, beta}. + const alphaId = agentItemId(tmpHome, 'alpha.md'); + const betaId = agentItemId(tmpHome, 'beta.md'); + const subsetBust = await runGhost( + ['--dangerously-bust-ghosts', '--yes-proceed-busting', '--json'], + { CCAUDIT_SELECT_IDS: `${alphaId},${betaId}` }, + ); + expect(subsetBust.exitCode, `subset bust stderr: ${subsetBust.stderr}`).toBe(0); + + // Sanity: alpha + beta archived, gamma still at source. + expect( + existsSync(path.join(tmpHome, '.claude', 'ccaudit', 'archived', 'agents', 'alpha.md')), + ).toBe(true); + expect( + existsSync(path.join(tmpHome, '.claude', 'ccaudit', 'archived', 'agents', 'beta.md')), + ).toBe(true); + expect(existsSync(path.join(tmpHome, '.claude', 'agents', 'alpha.md'))).toBe(false); + expect(existsSync(path.join(tmpHome, '.claude', 'agents', 'beta.md'))).toBe(false); + expect(existsSync(path.join(tmpHome, '.claude', 'agents', 'gamma.md'))).toBe(true); + + // Step 3: (sleep removed) — resolveManifestPath now uses millisecond precision + // plus a 4-char random suffix, so same-second busts no longer collide on the + // manifest filename. The 1.1s sleep that used to work around the second-granularity + // bug (WR-03) is no longer needed. See fix(03-review): WR-03 commit. + + // Re-run dry-run so the next bust's checkpoint matches the + // current inventory (only gamma remains). + const dry2 = await runGhost(['--dry-run', '--yes-proceed-busting', '--json']); + expect(dry2.exitCode, `dry-run 2 stderr: ${dry2.stderr}`).toBe(0); + + // Step 4: full-bust {gamma}, no CCAUDIT_SELECT_IDS. + const fullBust = await runGhost([ + '--dangerously-bust-ghosts', + '--yes-proceed-busting', + '--json', + ]); + expect(fullBust.exitCode, `full bust stderr: ${fullBust.stderr}`).toBe(0); + + // Sanity: gamma is now archived too. + expect( + existsSync(path.join(tmpHome, '.claude', 'ccaudit', 'archived', 'agents', 'gamma.md')), + ).toBe(true); + expect(existsSync(path.join(tmpHome, '.claude', 'agents', 'gamma.md'))).toBe(false); + + // Step 5: assert exactly 2 manifests exist (one subset, one full). + const manifestsDir = path.join(tmpHome, '.claude', 'ccaudit', 'manifests'); + const manifests = await readdir(manifestsDir); + expect( + manifests.filter((m) => m.endsWith('.jsonl')), + `expected 2 manifests, got ${manifests.length}: ${manifests.join(', ')}`, + ).toHaveLength(2); + + // Step 6: run `ccaudit restore --json`. + // restore is its own gunshi subcommand — invoke via runCcauditCli with + // ['restore', '--json'] (NOT runCcauditGhost which prepends 'ghost'). + // The PATH override ensures the fake-ps shim is on PATH for the restore + // preflight (which also runs the running-Claude check). + const restore = await runCcauditCli(tmpHome, ['restore', '--json'], { + env: { PATH: `${path.join(tmpHome, 'bin')}:${process.env.PATH ?? ''}` }, + }); + expect(restore.exitCode, `restore stderr: ${restore.stderr}`).toBe(0); + + const parsed = JSON.parse(restore.stdout) as { + status: string; + counts?: { unarchived: { moved: number; alreadyAtSource: number; failed: number } }; + }; + expect(parsed.status).toBe('success'); + expect(parsed.counts?.unarchived.moved).toBe(3); + expect(parsed.counts?.unarchived.failed).toBe(0); + + // Step 7: assert all 3 source files are back at their original paths. + expect(existsSync(path.join(tmpHome, '.claude', 'agents', 'alpha.md'))).toBe(true); + expect(existsSync(path.join(tmpHome, '.claude', 'agents', 'beta.md'))).toBe(true); + expect(existsSync(path.join(tmpHome, '.claude', 'agents', 'gamma.md'))).toBe(true); + + // Step 8: assert archived/agents/ no longer contains any of the three. + const archivedAgentsDir = path.join(tmpHome, '.claude', 'ccaudit', 'archived', 'agents'); + const archivedFiles = (await readdir(archivedAgentsDir).catch(() => [] as string[])).filter( + (f) => ['alpha.md', 'beta.md', 'gamma.md'].includes(f), + ); + expect(archivedFiles, `archived dir still contains: ${archivedFiles.join(', ')}`).toEqual([]); + }); + }, +); + +// ── M8 — INV-S3: memory file flagged in bust1 + refreshed in bust2 → restore removes all ccaudit frontmatter ── + +describe.skipIf(process.platform === 'win32')( + 'M8 — INV-S3: memory file flagged in bust1 then refreshed in bust2 → restore round-trip (SAFETY-M8)', + () => { + let tmpHome: string; + + beforeEach(async () => { + tmpHome = await makeTmpHome(); + await mkdir(path.join(tmpHome, '.claude', 'agents'), { recursive: true }); + await mkdir(path.join(tmpHome, '.config', 'claude'), { recursive: true }); + const manifestsDir = path.join(tmpHome, '.claude', 'ccaudit', 'manifests'); + await mkdir(manifestsDir, { recursive: true }); + + // Session JSONL so the running-Claude preflight passes. + const sessionDir = path.join(tmpHome, '.claude', 'projects', 'fake-project'); + await mkdir(sessionDir, { recursive: true }); + await writeFile( + path.join(sessionDir, 'session-1.jsonl'), + JSON.stringify({ + type: 'system', + subtype: 'init', + cwd: '/fake/project', + timestamp: new Date(Date.now() - 60 * 60 * 1000).toISOString(), + sessionId: 'm8-session', + }) + '\n', + 'utf8', + ); + await writeFile(path.join(tmpHome, '.claude.json'), '{}', 'utf8'); + + await buildFakePs(tmpHome); + + // Write the memory file with BOTH ccaudit frontmatter keys so restore + // has something to strip — simulating a file that was flagged and later + // refreshed across two separate busts. + const memoryPath = path.join(tmpHome, '.claude', 'CLAUDE.md'); + const originalContent = '# Memory file\nSome important content.\n'; + await writeFile( + memoryPath, + `---\nccaudit-stale: "2026-04-17T10:00:00Z"\nccaudit-flagged: "2026-04-18T10:00:00Z"\n---\n${originalContent}`, + 'utf8', + ); + + // Bust 1 (older): flag op on the memory file. + const bust1Mtime = new Date('2026-04-17T10:00:00.000Z'); + const bust1Header = { + record_type: 'header', + manifest_version: 1, + ccaudit_version: '1.5.0-test', + checkpoint_ghost_hash: 'deadbeef-m8-1', + checkpoint_timestamp: '2026-04-17T09:59:59.000Z', + since_window: '30d', + os: 'darwin', + node_version: 'v20.0.0', + planned_ops: { archive: 0, disable: 0, flag: 1 }, + selection_filter: { mode: 'full' }, + }; + const bust1FlagOp = { + op_id: 'op-m8-flag-bust1', + op_type: 'flag', + timestamp: bust1Mtime.toISOString(), + status: 'completed', + file_path: memoryPath, + scope: 'global', + had_frontmatter: false, + had_ccaudit_stale: false, + patched_keys: ['ccaudit-stale', 'ccaudit-flagged'], + original_content_sha256: '0'.repeat(64), + }; + const bust1Footer = { + record_type: 'footer', + status: 'completed', + actual_ops: { + archive: { completed: 0, failed: 0 }, + disable: { completed: 0, failed: 0 }, + flag: { completed: 1, failed: 0, refreshed: 0, skipped: 0 }, + }, + duration_ms: 10, + exit_code: 0, + }; + const bust1Body = + [ + JSON.stringify(bust1Header), + JSON.stringify(bust1FlagOp), + JSON.stringify(bust1Footer), + ].join('\n') + '\n'; + const bust1Path = path.join(manifestsDir, 'bust-2026-04-17T10-00-00-000Z-m8t1.jsonl'); + await writeFile(bust1Path, bust1Body, 'utf8'); + await utimes(bust1Path, bust1Mtime, bust1Mtime); + + // Bust 2 (newer): refresh op on the SAME memory file. + const bust2Mtime = new Date('2026-04-18T10:00:00.000Z'); + const bust2Header = { + record_type: 'header', + manifest_version: 1, + ccaudit_version: '1.5.0-test', + checkpoint_ghost_hash: 'deadbeef-m8-2', + checkpoint_timestamp: '2026-04-18T09:59:59.000Z', + since_window: '30d', + os: 'darwin', + node_version: 'v20.0.0', + planned_ops: { archive: 0, disable: 0, flag: 1 }, + selection_filter: { mode: 'full' }, + }; + const bust2RefreshOp = { + op_id: 'op-m8-refresh-bust2', + op_type: 'refresh', + timestamp: bust2Mtime.toISOString(), + status: 'completed', + file_path: memoryPath, + scope: 'global', + previous_flagged_at: bust1Mtime.toISOString(), + }; + const bust2Footer = { + record_type: 'footer', + status: 'completed', + actual_ops: { + archive: { completed: 0, failed: 0 }, + disable: { completed: 0, failed: 0 }, + flag: { completed: 0, failed: 0, refreshed: 1, skipped: 0 }, + }, + duration_ms: 10, + exit_code: 0, + }; + const bust2Body = + [ + JSON.stringify(bust2Header), + JSON.stringify(bust2RefreshOp), + JSON.stringify(bust2Footer), + ].join('\n') + '\n'; + const bust2Path = path.join(manifestsDir, 'bust-2026-04-18T10-00-00-000Z-m8t2.jsonl'); + await writeFile(bust2Path, bust2Body, 'utf8'); + await utimes(bust2Path, bust2Mtime, bust2Mtime); + }); + + afterEach(async () => { + await cleanupTmpHome(tmpHome); + }); + + it('M8: restore --json removes all ccaudit frontmatter from memory file flagged in bust1 and refreshed in bust2', async () => { + const memoryPath = path.join(tmpHome, '.claude', 'CLAUDE.md'); + + const restore = await runCcauditCli(tmpHome, ['restore', '--json'], { + env: { PATH: `${path.join(tmpHome, 'bin')}:${process.env.PATH ?? ''}` }, + }); + expect(restore.exitCode, `restore stderr: ${restore.stderr}`).toBe(0); + + const parsed = JSON.parse(restore.stdout) as { + status: string; + counts?: { stripped: { completed: number; failed: number } }; + }; + expect(parsed.status).toBe('success'); + // Both the flag op and the refresh op should result in 2 stripped operations. + expect(parsed.counts?.stripped.completed).toBe(2); + expect(parsed.counts?.stripped.failed).toBe(0); + + // Memory file must have no residual ccaudit frontmatter from either op. + const afterContent = await readFile(memoryPath, 'utf8'); + expect(afterContent).not.toContain('ccaudit-stale'); + expect(afterContent).not.toContain('ccaudit-flagged'); + expect(afterContent).toContain('Some important content.'); + }); + }, +); diff --git a/apps/ccaudit/src/__tests__/safety-invariants-tui-abort.test.ts b/apps/ccaudit/src/__tests__/safety-invariants-tui-abort.test.ts new file mode 100644 index 0000000..77606eb --- /dev/null +++ b/apps/ccaudit/src/__tests__/safety-invariants-tui-abort.test.ts @@ -0,0 +1,310 @@ +/** + * Phase 3 — INV-S2 (SAFETY-02): Ctrl+C / SIGINT during the interactive TUI + * produces ZERO disk writes that mutate user data. + * + * Strategy (CONTEXT D-09 / Path B): + * 1. Build a fixture with at least one ghost agent + one stale memory file + * so a successful interactive bust would write a manifest, archive + * the agent file, and inject frontmatter into the memory file. + * 2. Spawn `ccaudit ghost --interactive` with env CCAUDIT_FORCE_TTY=1. + * The Plan 01 hook makes the runInteractiveGhostFlow branch run + * from this non-pty subprocess. + * 3. Wait for the subprocess to write the dry-run checkpoint (which + * runInteractiveGhostFlow writes BEFORE opening the picker — this + * is fine; checkpoint is NOT a manifest and is allowed). We detect + * this transition by polling for a small marker on stderr OR by + * time-bounded sleep + child-still-alive check. + * 4. Send SIGINT to the child. + * 5. Await `done`; assert exit code is 0, 130, or null (SIGINT ladder + * is implementation-defined; what matters is the process exited). + * 6. Assert NO new manifest file exists in ~/.claude/ccaudit/manifests/ + * relative to the pre-spawn baseline. + * 7. Assert the source agent file is still at its source path (NOT + * moved to the archive directory). + * 8. Assert the stale memory file does NOT contain the ccaudit-flagged + * or ccaudit-stale frontmatter keys. + * + * The .last-dry-run checkpoint MAY exist post-abort — that is allowed by + * the invariant and explicitly noted in CONTEXT D-08. Manifests + mutations + * are what we forbid. + * + * Pattern mirrors interactive-smoke.test.ts (Phase 2) but exercises the + * picker code path itself, not just the guard layer. + */ +import { describe, it, expect, beforeAll, beforeEach, afterEach } from 'vitest'; +import { existsSync } from 'node:fs'; +import { mkdir, writeFile, readFile, utimes } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + makeTmpHome, + cleanupTmpHome, + buildFakePs, + runCcauditGhost, + listManifestsDir, + sendKeys, + waitForPicker, +} from './_test-helpers.ts'; + +// ── Dist guard ───────────────────────────────────────────────────────────── + +const here = path.dirname(fileURLToPath(import.meta.url)); +const distPath = path.resolve(here, '..', '..', 'dist', 'index.js'); + +beforeAll(() => { + if (!existsSync(distPath)) { + throw new Error( + `dist binary not found at ${distPath}. Run \`pnpm -F ccaudit build\` before running this test.`, + ); + } +}); + +// ── Tests ────────────────────────────────────────────────────────────────── + +// Windows: fake `ps` shell scripts require /bin/sh; skip on win32. +describe.skipIf(process.platform === 'win32')( + 'Phase 3 — INV-S2: SIGINT during interactive TUI produces zero disk writes (SAFETY-02)', + () => { + let tmpHome: string; + + beforeEach(async () => { + tmpHome = await makeTmpHome(); + + // Base scaffold + await mkdir(path.join(tmpHome, '.claude', 'agents'), { recursive: true }); + await mkdir(path.join(tmpHome, '.claude', 'skills'), { recursive: true }); + await mkdir(path.join(tmpHome, '.config', 'claude'), { recursive: true }); + + // Session JSONL so the scanner finds ≥1 file + const sessionDir = path.join(tmpHome, '.claude', 'projects', 'fake-project'); + await mkdir(sessionDir, { recursive: true }); + await writeFile( + path.join(sessionDir, 'session-1.jsonl'), + JSON.stringify({ + type: 'system', + subtype: 'init', + cwd: '/fake/project', + timestamp: new Date(Date.now() - 60 * 60 * 1000).toISOString(), + sessionId: 'inv-s2-session', + }) + '\n', + 'utf8', + ); + + // Empty .claude.json + await writeFile(path.join(tmpHome, '.claude.json'), '{}', 'utf8'); + + // Ghost agent (would be archived if bust ran) + await writeFile( + path.join(tmpHome, '.claude', 'agents', 'inv-s2-agent.md'), + '# inv-s2-agent\n\nNever invoked. Should NOT be archived after SIGINT.\n', + 'utf8', + ); + + // Stale memory file (would be flagged if bust ran) + const memPath = path.join(tmpHome, '.claude', 'CLAUDE.md'); + await writeFile(memPath, '# stale memory\n\nplain content, no frontmatter.\n', 'utf8'); + const oldTime = new Date(Date.now() - 40 * 24 * 60 * 60 * 1000); // 40 days ago + await utimes(memPath, oldTime, oldTime); + + await buildFakePs(tmpHome); + }); + + afterEach(async () => { + await cleanupTmpHome(tmpHome); + }); + + it('SIGINT mid-flight: no new manifest, source files untouched, no frontmatter written', async () => { + // Baseline: capture manifest dir state before spawn (likely [], but be defensive). + const baselineManifests = await listManifestsDir(tmpHome); + + // Spawn `ccaudit ghost --interactive` with CCAUDIT_FORCE_TTY=1 so + // the runInteractiveGhostFlow branch runs from this non-pty subprocess. + const spawned = runCcauditGhost(tmpHome, ['--interactive'], { + env: { CCAUDIT_FORCE_TTY: '1' }, + timeout: 10_000, + }); + + // Wait for the subprocess to reach the picker (blocking on stdin) before + // sending SIGINT. Uses centralized waitForPicker with exponential backoff. + await waitForPicker(spawned.child); + + // Confirm the child is still alive (otherwise the test is meaningless — + // the subprocess crashed before we got a chance to abort it). + // Note: child.exitCode is null while running. + expect( + spawned.child.exitCode, + 'subprocess exited before SIGINT — investigate stderr/stdout from done', + ).toBeNull(); + + // Send SIGINT. + spawned.child.kill('SIGINT'); + + // Await child exit. We accept any of: + // - exitCode === 0 (graceful cancel via @clack/prompts) + // - exitCode === 130 (POSIX SIGINT exit code 128 + 2) + // - exitCode === null (process killed by signal, no exit code recorded) + // The invariant is about disk side-effects, not the exact exit code. + const result = await spawned.done; + expect( + [0, 130, null].includes(result.exitCode), + `unexpected exitCode ${result.exitCode}\nstderr:\n${result.stderr.slice(-500)}\nstdout:\n${result.stdout.slice(-500)}`, + ).toBe(true); + + // Assertion 1: no new manifest written. + const postManifests = await listManifestsDir(tmpHome); + const newManifests = postManifests.filter((m) => !baselineManifests.includes(m)); + expect( + newManifests, + `INV-S2 violation: new manifest(s) appeared after SIGINT: ${newManifests.join(', ')}\n` + + `stderr:\n${result.stderr.slice(-500)}`, + ).toEqual([]); + + // Assertion 2: source agent file still exists at its source path. + expect( + existsSync(path.join(tmpHome, '.claude', 'agents', 'inv-s2-agent.md')), + 'INV-S2 violation: source agent file was archived/moved despite SIGINT', + ).toBe(true); + + // Assertion 3: archived directory does NOT contain the agent. + // (Archive dir may not exist at all if no bust ran — both conditions are fine.) + const archivedAgentPath = path.join( + tmpHome, + '.claude', + 'ccaudit', + 'archived', + 'agents', + 'inv-s2-agent.md', + ); + expect( + existsSync(archivedAgentPath), + 'INV-S2 violation: agent file leaked into archived/ despite SIGINT', + ).toBe(false); + + // Assertion 4: stale memory file has NO ccaudit-flagged or ccaudit-stale frontmatter. + const memContent = await readFile(path.join(tmpHome, '.claude', 'CLAUDE.md'), 'utf8'); + expect(memContent).not.toMatch(/ccaudit-flagged:/); + expect(memContent).not.toMatch(/ccaudit-stale:/); + // Original content is intact (defensive — also catches partial-write corruption). + expect(memContent).toContain('plain content, no frontmatter.'); + }); + }, +); + +// Phase 5 Plan 05-05 — INV-S2 re-run under the new filter-input mode +// (threat T-05-02 — SIGINT during filter-mode-active must still produce zero +// manifest writes even though additional key-handler state is live). +describe.skipIf(process.platform === 'win32')( + 'Phase 5 — INV-S2 under filter mode: SIGINT while typing into `/` filter still produces zero disk writes', + () => { + let tmpHome: string; + + beforeEach(async () => { + tmpHome = await makeTmpHome(); + + await mkdir(path.join(tmpHome, '.claude', 'agents'), { recursive: true }); + await mkdir(path.join(tmpHome, '.claude', 'skills'), { recursive: true }); + await mkdir(path.join(tmpHome, '.config', 'claude'), { recursive: true }); + + const sessionDir = path.join(tmpHome, '.claude', 'projects', 'inv-s2-filter'); + await mkdir(sessionDir, { recursive: true }); + await writeFile( + path.join(sessionDir, 'session-1.jsonl'), + JSON.stringify({ + type: 'system', + subtype: 'init', + cwd: '/fake/inv-s2-filter', + timestamp: new Date(Date.now() - 60 * 60 * 1000).toISOString(), + sessionId: 'inv-s2-filter-session', + }) + '\n', + 'utf8', + ); + + await writeFile(path.join(tmpHome, '.claude.json'), '{}', 'utf8'); + + // Two ghost agents — both archivable if a bust were to run. + for (const name of ['foo-ghost', 'foo-other']) { + await writeFile( + path.join(tmpHome, '.claude', 'agents', `${name}.md`), + `# ${name}\n\nNever invoked.\n`, + 'utf8', + ); + } + + // Stale memory file — also archivable if a bust were to run. + const memPath = path.join(tmpHome, '.claude', 'CLAUDE.md'); + await writeFile(memPath, '# stale memory\n\nplain content, no frontmatter.\n', 'utf8'); + const oldTime = new Date(Date.now() - 40 * 24 * 60 * 60 * 1000); + await utimes(memPath, oldTime, oldTime); + + await buildFakePs(tmpHome); + }); + + afterEach(async () => { + await cleanupTmpHome(tmpHome); + }); + + it('SIGINT during `/` filter-input mode writes zero manifests (T-05-02)', async () => { + const baselineManifests = await listManifestsDir(tmpHome); + + const spawned = runCcauditGhost(tmpHome, ['--interactive'], { + env: { + CCAUDIT_FORCE_TTY: '1', + CCAUDIT_TEST_STDOUT_ROWS: '30', + LINES: '30', + COLUMNS: '100', + NO_COLOR: '1', + }, + timeout: 10_000, + }); + + // Wait for the picker to be live before starting to drive it. + await waitForPicker(spawned.child); + + // Enter filter-input mode and start typing a partial query. + await sendKeys(spawned.child, ['/', 'f', 'o', 'o']); + // Small grace so the filter-mode state flag is live in the subprocess. + await new Promise((r) => setTimeout(r, 300)); + + expect( + spawned.child.exitCode, + 'subprocess exited before SIGINT — investigate stderr/stdout from done', + ).toBeNull(); + + spawned.child.kill('SIGINT'); + const result = await spawned.done; + expect( + [0, 130, null].includes(result.exitCode), + `unexpected exitCode ${result.exitCode}\nstderr:\n${result.stderr.slice(-500)}\nstdout:\n${result.stdout.slice(-500)}`, + ).toBe(true); + + // Assertion 1: no new manifest written. + const postManifests = await listManifestsDir(tmpHome); + const newManifests = postManifests.filter((m) => !baselineManifests.includes(m)); + expect( + newManifests, + `INV-S2 violation under filter mode: new manifest(s) appeared after SIGINT: ${newManifests.join(', ')}\n` + + `stderr:\n${result.stderr.slice(-500)}`, + ).toEqual([]); + + // Assertion 2: both source agent files still at their source paths. + expect( + existsSync(path.join(tmpHome, '.claude', 'agents', 'foo-ghost.md')), + 'INV-S2 violation: foo-ghost.md was archived despite SIGINT under filter mode', + ).toBe(true); + expect( + existsSync(path.join(tmpHome, '.claude', 'agents', 'foo-other.md')), + 'INV-S2 violation: foo-other.md was archived despite SIGINT under filter mode', + ).toBe(true); + + // Assertion 3: stale memory file has NO ccaudit frontmatter flags. + const memContent = await readFile(path.join(tmpHome, '.claude', 'CLAUDE.md'), 'utf8'); + expect(memContent).not.toMatch(/ccaudit-flagged:/); + expect(memContent).not.toMatch(/ccaudit-stale:/); + expect(memContent).toContain('plain content, no frontmatter.'); + }); + }, +); + +// TODO(Phase 9): Exercise SIGINT at additional interrupt points (during the +// pre-picker scan, immediately after checkpoint write, during the confirmation +// prompt). Phase 3 covers the highest-risk point — inside the picker itself. diff --git a/apps/ccaudit/src/__tests__/scanner-multi-config-mcp.test.ts b/apps/ccaudit/src/__tests__/scanner-multi-config-mcp.test.ts new file mode 100644 index 0000000..db44cf3 --- /dev/null +++ b/apps/ccaudit/src/__tests__/scanner-multi-config-mcp.test.ts @@ -0,0 +1,97 @@ +/** + * Phase 6 (06-05) — Scanner multi-config MCP aggregation test. + * + * Validates that `scanMcpServers` produces `configRefs.length >= 2` when the + * same MCP server key appears in two or more config files, in deterministic + * bucket+stable order (project-local first, then user scope, then system), + * and that single-config MCPs still carry `configRefs.length === 1`. + * + * Runs in-process (no subprocess). Uses `createMultiConfigMcpFixture` from + * `_test-helpers.ts`. + */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { scanMcpServers, compareConfigRef } from '@ccaudit/internal'; +import { makeTmpHome, cleanupTmpHome, createMultiConfigMcpFixture } from './_test-helpers.ts'; + +describe.skipIf(process.platform === 'win32')( + 'Phase 6 SC3 scanner — multi-config MCP aggregation', + () => { + let tmpHome: string; + let origHome: string | undefined; + + beforeEach(async () => { + tmpHome = await makeTmpHome(); + // presentPath() in scanner reads os.homedir() which is driven by $HOME + // on POSIX. Override to tmpHome so `~/.claude.json` compression fires. + origHome = process.env['HOME']; + process.env['HOME'] = tmpHome; + }); + + afterEach(async () => { + if (origHome === undefined) delete process.env['HOME']; + else process.env['HOME'] = origHome; + await cleanupTmpHome(tmpHome); + }); + + it('identical MCP key in 2 configs produces configRefs.length === 2 in deterministic order', async () => { + const fixture = await createMultiConfigMcpFixture({ + home: tmpHome, + sharedKey: 'pencil', + alsoInProjectLocal: true, + alsoInUser: true, + }); + + const items = await scanMcpServers(fixture.userConfigPath, fixture.projectPaths); + + const pencilItems = items.filter((i) => i.name === 'pencil'); + expect(pencilItems.length).toBeGreaterThanOrEqual(1); + + // Every emitted `pencil` item must reference BOTH configs. + for (const it of pencilItems) { + expect(it.configRefs, 'configRefs must be populated').toBeDefined(); + expect(it.configRefs!.length).toBe(2); + // D6-19 bucket order: project-local (relative) first, then user (~/...). + expect(it.configRefs![0]).toMatch(/\.mcp\.json$/); + expect(it.configRefs![1]).toMatch(/^~\//); + expect(it.configRefs![1]).toContain('.claude.json'); + } + }); + + it('single-config MCP still carries configRefs.length === 1', async () => { + // Only user-scope; no project-local. + const fixture = await createMultiConfigMcpFixture({ + home: tmpHome, + sharedKey: 'solo', + alsoInProjectLocal: false, + alsoInUser: true, + }); + + const items = await scanMcpServers(fixture.userConfigPath, fixture.projectPaths); + const solo = items.find((i) => i.name === 'solo'); + expect(solo, 'solo item must exist').toBeDefined(); + expect(solo!.configRefs).toBeDefined(); + expect(solo!.configRefs!.length).toBe(1); + }); + + it('aggregation across many configs is deterministic and dedup-safe', async () => { + const fixture = await createMultiConfigMcpFixture({ + home: tmpHome, + sharedKey: 'quintet', + alsoInProjectLocal: true, + alsoInUser: true, + extraProjectDirs: ['proj-b', 'proj-c', 'proj-d'], + }); + + const items = await scanMcpServers(fixture.userConfigPath, fixture.projectPaths); + const quintetItems = items.filter((i) => i.name === 'quintet'); + expect(quintetItems.length).toBeGreaterThanOrEqual(1); + for (const it of quintetItems) { + // At minimum: project-local + user scopes both present. + expect(it.configRefs!.length).toBeGreaterThanOrEqual(2); + // configRefs must be in deterministic bucket order (project-local → user → system). + const sorted = [...it.configRefs!].sort(compareConfigRef); + expect(it.configRefs).toEqual(sorted); + } + }); + }, +); diff --git a/apps/ccaudit/src/__tests__/tabbed-picker-50-item-fixture.test.ts b/apps/ccaudit/src/__tests__/tabbed-picker-50-item-fixture.test.ts new file mode 100644 index 0000000..54985e5 --- /dev/null +++ b/apps/ccaudit/src/__tests__/tabbed-picker-50-item-fixture.test.ts @@ -0,0 +1,195 @@ +/** + * Phase 5 SC5 — 50-item fixture smoke test (pty harness). + * + * Drives ~50 ghosts across 3 tabs (20 agents, 20 skills, 10 MCP) through the + * full Phase 5 keyboard model in sequence and asserts: + * - No crash, exit code ∈ {0, 130, null}. + * - Transcript is free of Node-thrown `TypeError` / `RangeError` / `undefined is not` + * error signatures. + */ +import { describe, it, expect, beforeAll, beforeEach, afterEach } from 'vitest'; +import { existsSync } from 'node:fs'; +import { mkdir, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + makeTmpHome, + cleanupTmpHome, + buildFakePs, + runCcauditGhost, + sendKeys, + waitForMarker, +} from './_test-helpers.ts'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const distPath = path.resolve(here, '..', '..', 'dist', 'index.js'); + +beforeAll(() => { + if (!existsSync(distPath)) { + throw new Error( + `dist binary not found at ${distPath}. Run \`pnpm -F ccaudit build\` before running this test.`, + ); + } +}); + +async function buildBigFixture(tmpHome: string): Promise { + const agentsDir = path.join(tmpHome, '.claude', 'agents'); + const skillsRoot = path.join(tmpHome, '.claude', 'skills'); + const sessionDir = path.join(tmpHome, '.claude', 'projects', 'p5-big'); + await mkdir(agentsDir, { recursive: true }); + await mkdir(skillsRoot, { recursive: true }); + await mkdir(sessionDir, { recursive: true }); + await mkdir(path.join(tmpHome, '.config', 'claude'), { recursive: true }); + + // 20 agents + for (let i = 1; i <= 20; i++) { + const name = `agent-${String(i).padStart(2, '0')}`; + await writeFile( + path.join(agentsDir, `${name}.md`), + `# ${name}\n\n` + 'content '.repeat(20), + 'utf8', + ); + } + + // 20 skills + for (let i = 1; i <= 20; i++) { + const name = `skill-${String(i).padStart(2, '0')}`; + const dir = path.join(skillsRoot, name); + await mkdir(dir, { recursive: true }); + await writeFile( + path.join(dir, 'SKILL.md'), + `---\nname: ${name}\n---\n# ${name}\n\n` + 'body '.repeat(20), + 'utf8', + ); + } + + // 10 MCP servers in .claude.json + const mcpServers: Record = {}; + for (let i = 1; i <= 10; i++) { + const name = `mcp-${String(i).padStart(2, '0')}`; + mcpServers[name] = { command: 'npx', args: [`srv-${i}`] }; + } + await writeFile( + path.join(tmpHome, '.claude.json'), + JSON.stringify({ mcpServers }, null, 2) + '\n', + 'utf8', + ); + + const recentTs = new Date(Date.now() - 3600_000).toISOString(); + await writeFile( + path.join(sessionDir, 's.jsonl'), + JSON.stringify({ + type: 'system', + subtype: 'init', + cwd: '/fake/p5-big', + timestamp: recentTs, + sessionId: 'p5-big', + }) + '\n', + 'utf8', + ); +} + +function baseEnv(extra: Record = {}): Record { + return { + CCAUDIT_FORCE_TTY: '1', + CCAUDIT_TEST_STDOUT_ROWS: '30', + LINES: '30', + COLUMNS: '100', + NO_COLOR: '1', + ...extra, + }; +} + +describe.skipIf(process.platform === 'win32')( + 'Phase 5 SC5 — 50-item fixture smoke (all new bindings, no crash)', + () => { + let tmpHome: string; + + beforeEach(async () => { + tmpHome = await makeTmpHome(); + await buildFakePs(tmpHome); + await buildBigFixture(tmpHome); + }); + + afterEach(async () => { + await cleanupTmpHome(tmpHome); + }); + + it('exercises ?, /, s, a across tabs on a 50-item fixture without crashing', async () => { + const spawned = runCcauditGhost(tmpHome, ['--interactive'], { + env: baseEnv(), + timeout: 30_000, + }); + + let stdoutBuf = ''; + let stderrBuf = ''; + spawned.child.stdout!.on('data', (c: Buffer) => { + stdoutBuf += c.toString(); + }); + spawned.child.stderr!.on('data', (c: Buffer) => { + stderrBuf += c.toString(); + }); + + // Anchor on any `of NN selected` marker; total count depends on whether + // all 50 items are recognized as ghosts, so match loosely. + await waitForMarker( + () => stdoutBuf, + () => spawned.child.exitCode !== null, + 'selected across all tabs', + ); + + // Sanity: child must still be alive before we start driving it. If the + // picker crashed during startup we surface stderr early rather than + // letting `sendKeys` fail with a cryptic 'stdin destroyed'. + if (spawned.child.exitCode !== null) { + throw new Error( + `[SC5] picker exited before first keystroke (code=${spawned.child.exitCode})\n` + + `stdout tail:\n${stdoutBuf.slice(-1500)}\nstderr tail:\n${stderrBuf.slice(-1500)}`, + ); + } + + // Sequence exercises all four new bindings without triggering the + // @clack/core Esc→cancel alias bug (see KNOWN-GAP note in + // tabbed-picker-filter.test.ts). We close the help overlay via the + // `?` toggle and exit filter-input mode via Enter rather than Esc. + await sendKeys(spawned.child, ['?']); + await new Promise((r) => setTimeout(r, 250)); + await sendKeys(spawned.child, ['?']); // close help via toggle + await new Promise((r) => setTimeout(r, 250)); + + await sendKeys(spawned.child, ['/', 'a', 'g', 'e', 'n', 't']); // filter + await new Promise((r) => setTimeout(r, 300)); + await sendKeys(spawned.child, ['\r']); // Enter: exit filter-input, keep query + await new Promise((r) => setTimeout(r, 200)); + + await sendKeys(spawned.child, ['s']); + await new Promise((r) => setTimeout(r, 200)); + await sendKeys(spawned.child, ['s']); + await new Promise((r) => setTimeout(r, 200)); + + await sendKeys(spawned.child, ['\t']); // next tab + await new Promise((r) => setTimeout(r, 300)); + await sendKeys(spawned.child, ['s']); + await new Promise((r) => setTimeout(r, 200)); + await sendKeys(spawned.child, ['a']); // select all in tab + await new Promise((r) => setTimeout(r, 300)); + + // Cancel cleanly. + await sendKeys(spawned.child, ['\x03']); + spawned.child.stdin!.end(); + const result = await spawned.done; + + // Exit contract. + expect( + [0, 130, null].includes(result.exitCode), + `unexpected exit code ${result.exitCode}\nstderr tail:\n${stderrBuf.slice(-500)}\nstdout tail:\n${stdoutBuf.slice(-500)}`, + ).toBe(true); + + // No Node runtime errors surfaced. + const full = stdoutBuf + '\n' + stderrBuf; + expect(full).not.toMatch(/\bTypeError\b/); + expect(full).not.toMatch(/\bRangeError\b/); + expect(full).not.toMatch(/\bundefined is not\b/); + }, 45_000); + }, +); diff --git a/apps/ccaudit/src/__tests__/tabbed-picker-bust-parity.test.ts b/apps/ccaudit/src/__tests__/tabbed-picker-bust-parity.test.ts new file mode 100644 index 0000000..c27149d --- /dev/null +++ b/apps/ccaudit/src/__tests__/tabbed-picker-bust-parity.test.ts @@ -0,0 +1,190 @@ +/** + * Phase 4 — MH-04 parity: the picker footer token total at the moment of Enter + * must match the post-bust "Freed:" line within ≤1k rounding tolerance. + * + * Rationale (from the Phase 4 block): "Counter MUST sum from the + * same field the scanner+report use. Drift between the picker counter and the + * post-bust 'freed ≈ Xk tokens' summary is a trust break." + * + * Flow: + * 1. Scaffold 3 ghost agents. + * 2. Spawn `ccaudit ghost --interactive` under CCAUDIT_FORCE_TTY=1 + NO_COLOR=1. + * 3. Select agents 0 and 1 via Space + ArrowDown + Space. + * 4. Parse the picker footer at the moment of Enter — capture either + * `~ Xk tokens saved` (≥1000 sum) or `N tokens saved` (<1000 sum). + * 5. Enter → confirmation prompt appears → send 'y' + Enter. + * 6. Await exit; regex-extract the `Freed: ~Xk` / `Freed: ~N` line from the + * shareable-block output. + * 7. Compare: |pickerTokensK − freedTokensK| ≤ 1 (MH-04 tolerance). + * 8. If the fixture produces 0 archivable tokens, the test skips with a + * console warning rather than failing — this prevents flakiness if the + * agent token estimator changes in a future phase. + */ +import { describe, it, expect, beforeAll, beforeEach, afterEach } from 'vitest'; +import { existsSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + makeTmpHome, + cleanupTmpHome, + buildFakePs, + runCcauditGhost, + sendKeys, + buildManyGhostsFixture, + waitForMarker, +} from './_test-helpers.ts'; + +// ── Dist guard ───────────────────────────────────────────────────────────── + +const here = path.dirname(fileURLToPath(import.meta.url)); +const distPath = path.resolve(here, '..', '..', 'dist', 'index.js'); + +beforeAll(() => { + if (!existsSync(distPath)) { + throw new Error( + `dist binary not found at ${distPath}. Run \`pnpm -F ccaudit build\` before running this test.`, + ); + } +}); + +// ── ANSI stripper ───────────────────────────────────────────────────────── +/* eslint-disable no-control-regex -- ANSI stripping requires literal \x1b */ +function stripAnsi(s: string): string { + return s + .replace(/\x1b\[\?25[lh]/g, '') + .replace(/\x1b\[[0-9;]*[mGKHFABCD]/g, '') + .replace(/\x1b\[\d*[JST]/g, ''); +} +/* eslint-enable no-control-regex */ + +/** + * Parse the picker footer's "tokens saved" suffix and return the value in + * thousands (k), rounded to an integer. Returns null if no match is found + * (the fixture produced 0 archivable tokens — caller should skip). + * + * "~ Xk tokens saved" → X + * "≈ Xk tokens saved" → X + * "N tokens saved" → N / 1000 (raw → k) + */ +function parsePickerTokensK(frame: string): number | null { + // Take the LAST occurrence — `frame` is the cumulative stdout buffer and + // contains earlier renders (e.g. the initial "0 of N selected" footer with + // `0 tokens saved`) that would otherwise win under non-global match. + const kMatches = [...frame.matchAll(/(?:~|≈)\s+(\d+)k\s+tokens saved/g)]; + if (kMatches.length > 0) return parseInt(kMatches.at(-1)![1]!, 10); + // Raw form: `N tokens saved` where N is a bare integer. + const rawMatches = [...frame.matchAll(/\b(\d+)\s+tokens saved/g)]; + if (rawMatches.length > 0) return parseInt(rawMatches.at(-1)![1]!, 10) / 1000; + return null; +} + +/** + * Parse the post-bust shareable-block "Freed:" line. fmtK produces: + * tokens ≥ 10000 → "~Xk" + * tokens ≥ 1000 → "~X.Xk" + * tokens < 1000 → "~X" (raw count, not divided) + */ +function parseFreedTokensK(stdout: string): number | null { + // ~Xk OR ~X.Xk + const kMatch = stdout.match(/Freed:\s+~(\d+(?:\.\d+)?)k\s+tokens/); + if (kMatch !== null) return parseFloat(kMatch[1]!); + // Raw form: `Freed: ~N tokens` where N < 1000. + const rawMatch = stdout.match(/Freed:\s+~(\d+)\s+tokens/); + if (rawMatch !== null) return parseInt(rawMatch[1]!, 10) / 1000; + return null; +} + +// ── Test ────────────────────────────────────────────────────────────────── +// Windows: fake `ps` shell scripts require /bin/sh; skip on win32. +describe.skipIf(process.platform === 'win32')( + 'Phase 4 — MH-04 picker-footer / post-bust-Freed parity', + () => { + let tmpHome: string; + + beforeEach(async () => { + tmpHome = await makeTmpHome(); + await buildManyGhostsFixture(tmpHome, 3); + await buildFakePs(tmpHome); + }); + + afterEach(async () => { + await cleanupTmpHome(tmpHome); + }); + + it('picker footer total at Enter matches post-bust Freed: line within ≤1k rounding tolerance (MH-04)', async () => { + const spawned = runCcauditGhost(tmpHome, ['--interactive'], { + env: { + CCAUDIT_FORCE_TTY: '1', + CCAUDIT_TEST_STDOUT_ROWS: '24', + LINES: '24', + COLUMNS: '80', + NO_COLOR: '1', + }, + timeout: 25_000, + }); + + let stdoutBuf = ''; + spawned.child.stdout!.on('data', (c: Buffer) => { + stdoutBuf += c.toString(); + }); + + await waitForMarker( + () => stdoutBuf, + () => spawned.child.exitCode !== null, + '0 of 3 selected across all tabs', + ); + + // Select agents 0 + 1: Space, ArrowDown, Space. + await sendKeys(spawned.child, [' ', '\x1b[B', ' ']); + await new Promise((r) => setTimeout(r, 400)); + + // Parse the picker footer BEFORE pressing Enter. We scan the stripped + // stream for the most recent "2 of 3 selected" line and the tokens saved + // suffix near it. Using the full captured stream is fine — the renderer + // has already emitted the updated counter for the 2-selection state. + const preEnterFrame = stripAnsi(stdoutBuf); + expect(preEnterFrame).toContain('2 of 3 selected across all tabs'); + const pickerTokensK = parsePickerTokensK(preEnterFrame); + if (pickerTokensK === null || pickerTokensK === 0) { + console.warn('[phase-4 parity test] fixture produced 0 tokens; skipping'); + await sendKeys(spawned.child, ['\x03']); + spawned.child.stdin!.end(); + await spawned.done; + return; + } + + // Press Enter → confirmation prompt. + await sendKeys(spawned.child, ['\r']); + await waitForMarker( + () => stdoutBuf, + () => spawned.child.exitCode !== null, + 'Proceed with archive?', + ); + + // Confirm the bust. + await sendKeys(spawned.child, ['y', '\r'], 120); + spawned.child.stdin!.end(); + + const result = await spawned.done; + expect( + result.exitCode, + `bust subprocess exited ${result.exitCode}\nstdout tail:\n${result.stdout.slice(-2000)}\nstderr tail:\n${result.stderr.slice(-1000)}`, + ).toBe(0); + + const finalStdout = stripAnsi(result.stdout); + const freedTokensK = parseFreedTokensK(finalStdout); + expect( + freedTokensK, + `no Freed: line parsed from post-bust output; tail:\n${finalStdout.slice(-1500)}`, + ).not.toBeNull(); + + // MH-04: |pickerKey - freedKey| ≤ 1 (1k rounding tolerance). + const pickerKey = Math.round(pickerTokensK); + const freedKey = Math.round(freedTokensK ?? 0); + expect( + Math.abs(pickerKey - freedKey) <= 1, + `MH-04 parity drift: picker shown ${pickerTokensK}k; bust freed ${freedTokensK ?? 'null'}k (diff ${Math.abs(pickerKey - freedKey)}k > 1k tolerance)`, + ).toBe(true); + }, 35_000); + }, +); diff --git a/apps/ccaudit/src/__tests__/tabbed-picker-filter.test.ts b/apps/ccaudit/src/__tests__/tabbed-picker-filter.test.ts new file mode 100644 index 0000000..0d68ac5 --- /dev/null +++ b/apps/ccaudit/src/__tests__/tabbed-picker-filter.test.ts @@ -0,0 +1,345 @@ +/** + * Phase 5 SC1 — filter (`/`) integration test (pty harness). + * + * Drives `ccaudit ghost --interactive` via the existing subprocess harness and + * asserts that: + * - `/` opens filter input mode, footer shows `Filter: {q}_`. + * - typing narrows the active tab (footer: `Filtered: M of N visible | X selected`). + * - Space toggles a visible row; selection count reflects it. + * - `Esc` clears the filter AND exits filter mode (D5-05); selection preserved (D5-06). + * - Tab switch clears the filter (D5-03); SKILLS tab then shows all rows. + * + * ASCII mode (`NO_COLOR=1`) is assumed per the helper env; the separator between + * `visible` and `selected` is `|` (not `·`) per D5-22 fallback. + */ +import { describe, it, expect, beforeAll, beforeEach, afterEach } from 'vitest'; +import { existsSync } from 'node:fs'; +import { mkdir, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + makeTmpHome, + cleanupTmpHome, + buildFakePs, + runCcauditGhost, + sendKeys, + waitForMarker, +} from './_test-helpers.ts'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const distPath = path.resolve(here, '..', '..', 'dist', 'index.js'); + +beforeAll(() => { + if (!existsSync(distPath)) { + throw new Error( + `dist binary not found at ${distPath}. Run \`pnpm -F ccaudit build\` before running this test.`, + ); + } +}); + +/* eslint-disable no-control-regex -- ANSI stripping requires literal \x1b */ +function stripAnsi(s: string): string { + return s + .replace(/\x1b\[\?25[lh]/g, '') + .replace(/\x1b\[[0-9;]*[mGKHFABCD]/g, '') + .replace(/\x1b\[\d*[JST]/g, ''); +} +/* eslint-enable no-control-regex */ + +async function buildPencilFixture(tmpHome: string): Promise { + const agentsDir = path.join(tmpHome, '.claude', 'agents'); + const skillsRoot = path.join(tmpHome, '.claude', 'skills'); + const sessionDir = path.join(tmpHome, '.claude', 'projects', 'p5-filter'); + await mkdir(agentsDir, { recursive: true }); + await mkdir(skillsRoot, { recursive: true }); + await mkdir(sessionDir, { recursive: true }); + await mkdir(path.join(tmpHome, '.config', 'claude'), { recursive: true }); + + // 3 agents — 2 with `pencil` in name, 1 with `compass`. + for (const name of ['pencil-dev', 'pencil-prod', 'compass-dev']) { + await writeFile( + path.join(agentsDir, `${name}.md`), + `# ${name}\n\n` + 'content content content. '.repeat(8), + 'utf8', + ); + } + + // 3 skills — 2 without `pencil`, 1 with. + for (const sk of ['foo', 'bar', 'pencil-note']) { + const dir = path.join(skillsRoot, sk); + await mkdir(dir, { recursive: true }); + await writeFile( + path.join(dir, 'SKILL.md'), + `---\nname: ${sk}\n---\n# ${sk}\n\n` + 'body body body. '.repeat(8), + 'utf8', + ); + } + + await writeFile(path.join(tmpHome, '.claude.json'), '{}', 'utf8'); + const recentTs = new Date(Date.now() - 3600_000).toISOString(); + await writeFile( + path.join(sessionDir, 's.jsonl'), + JSON.stringify({ + type: 'system', + subtype: 'init', + cwd: '/fake/p5-filter', + timestamp: recentTs, + sessionId: 'p5-filter', + }) + '\n', + 'utf8', + ); +} + +function baseEnv(extra: Record = {}): Record { + return { + CCAUDIT_FORCE_TTY: '1', + CCAUDIT_TEST_STDOUT_ROWS: '30', + LINES: '30', + COLUMNS: '100', + NO_COLOR: '1', + ...extra, + }; +} + +describe.skipIf(process.platform === 'win32')('Phase 5 SC1 — filter integration', () => { + let tmpHome: string; + + beforeEach(async () => { + tmpHome = await makeTmpHome(); + await buildFakePs(tmpHome); + await buildPencilFixture(tmpHome); + }); + + afterEach(async () => { + await cleanupTmpHome(tmpHome); + }); + + it('narrows visible rows when `/` + query typed; footer shows "Filter:" + "Filtered: M of N visible" (D5-01, D5-02)', async () => { + const spawned = runCcauditGhost(tmpHome, ['--interactive'], { + env: baseEnv(), + timeout: 20_000, + }); + + let stdoutBuf = ''; + spawned.child.stdout!.on('data', (c: Buffer) => { + stdoutBuf += c.toString(); + }); + + // Initial render: total is 6 ghosts across 2 tabs. + await waitForMarker( + () => stdoutBuf, + () => spawned.child.exitCode !== null, + '0 of 6 selected across all tabs', + ); + + // Open filter + type "pencil". Assert on the cumulative transcript because + // the terminal diff renderer suppresses unchanged lines between keystrokes; + // the final `Filter: pen_` frame lives in the full stream. + await sendKeys(spawned.child, ['/', 'p', 'e', 'n']); + await waitForMarker( + () => stripAnsi(stdoutBuf), + () => spawned.child.exitCode !== null, + 'Filter: pen_', + ); + + const afterFilter = stripAnsi(stdoutBuf); + expect( + afterFilter.includes('Filter: pen_'), + `expected filter prompt 'Filter: pen_'; got tail:\n${afterFilter.slice(-2000)}`, + ).toBe(true); + // AGENTS tab: 2 of 3 visible (pencil-dev, pencil-prod). + expect( + /Filtered:\s*2\s+of\s+3\s+visible/.test(afterFilter), + `expected 'Filtered: 2 of 3 visible'; got tail:\n${afterFilter.slice(-2000)}`, + ).toBe(true); + + // Tab key in filter-input mode exits filter-mode + switches tab AND clears + // the departing tab's filter state per D5-03 — and unlike Enter/Esc it is + // not aliased to submit/cancel by @clack/core's base dispatcher, so it is + // safe to drive through a pty here. + const beforeLen = stdoutBuf.length; + await sendKeys(spawned.child, ['\t']); + await new Promise((r) => setTimeout(r, 400)); + const afterTab = stripAnsi(stdoutBuf.slice(beforeLen)); + // SKILLS tab — 3 rows total. No `Filtered:` suffix (SKILLS is a fresh tab). + expect(afterTab).not.toContain('Filtered:'); + expect( + afterTab.includes('0 of 6 selected across all tabs'), + `expected '0 of 6 selected …' on SKILLS tab; got:\n${afterTab}`, + ).toBe(true); + + // Cleanup via Ctrl+C (Esc is aliased to cancel by @clack/core — see the + // D5-05 / D5-13 KNOWN-GAP note below; Ctrl+C is the spec'd cancel key). + await sendKeys(spawned.child, ['\x03']); + spawned.child.stdin!.end(); + const result = await spawned.done; + expect([0, 130, null].includes(result.exitCode)).toBe(true); + }, 30_000); + + // Phase 5 gap closure (D5-05, D5-06, D5-13): the three live tests below + // replaced earlier `it.todo` markers once `TabbedGhostPicker.onKeypress` + // was overridden to intercept escape/return while `filterMode` or + // `helpOpen` is active, preventing `@clack/core`'s base dispatcher from + // unconditionally flipping `state` to `'cancel'` / `'submit'`. The fix + // is surgical: Ctrl+C still cancels (INV-S2), and escape/return outside + // filter/help mode still cancel/submit per Phase 3.1 behavior. + + it('D5-05: Esc in filter mode clears the query and exits filter mode without canceling the picker', async () => { + const spawned = runCcauditGhost(tmpHome, ['--interactive'], { + env: baseEnv(), + timeout: 20_000, + }); + + let stdoutBuf = ''; + spawned.child.stdout!.on('data', (c: Buffer) => { + stdoutBuf += c.toString(); + }); + + await waitForMarker( + () => stdoutBuf, + () => spawned.child.exitCode !== null, + '0 of 6 selected across all tabs', + ); + + await sendKeys(spawned.child, ['/', 'p', 'e', 'n']); + await waitForMarker( + () => stripAnsi(stdoutBuf), + () => spawned.child.exitCode !== null, + 'Filter: pen_', + ); + const beforeEsc = stripAnsi(stdoutBuf); + expect(beforeEsc).toContain('Filter: pen_'); + expect(/Filtered:\s*2\s+of\s+3\s+visible/.test(beforeEsc)).toBe(true); + + // Esc in filter mode: per D5-05 should clear the query AND exit filter + // mode in one stroke. Critically, the picker must NOT cancel. + const beforeLen = stdoutBuf.length; + await sendKeys(spawned.child, ['\x1b']); + await new Promise((r) => setTimeout(r, 500)); + expect(spawned.child.exitCode).toBeNull(); + + const afterEsc = stripAnsi(stdoutBuf.slice(beforeLen)); + expect(afterEsc).not.toContain('No changes made'); + // Filter footer cleared → the full-inventory counter returns. + expect(afterEsc.includes('0 of 6 selected across all tabs')).toBe(true); + // And the `Filter: pen_` prompt is not reissued afterwards — the picker + // is back in its normal (non-filter) state. + expect(afterEsc).not.toContain('Filter: pen_'); + + // Cleanup via Ctrl+C. + await sendKeys(spawned.child, ['\x03']); + spawned.child.stdin!.end(); + const result = await spawned.done; + expect([0, 130, null].includes(result.exitCode)).toBe(true); + }, 30_000); + + it('D5-05: Enter in filter mode exits filter-input mode but keeps the narrowed view (query stays active)', async () => { + const spawned = runCcauditGhost(tmpHome, ['--interactive'], { + env: baseEnv(), + timeout: 20_000, + }); + + let stdoutBuf = ''; + spawned.child.stdout!.on('data', (c: Buffer) => { + stdoutBuf += c.toString(); + }); + + await waitForMarker( + () => stdoutBuf, + () => spawned.child.exitCode !== null, + '0 of 6 selected across all tabs', + ); + + await sendKeys(spawned.child, ['/', 'p', 'e', 'n']); + await waitForMarker( + () => stripAnsi(stdoutBuf), + () => spawned.child.exitCode !== null, + 'Filter: pen_', + ); + + // Enter: exit filter-input mode, but the narrowed view (2 of 3) persists. + // Picker must NOT submit. + await sendKeys(spawned.child, ['\r']); + await new Promise((r) => setTimeout(r, 500)); + expect(spawned.child.exitCode).toBeNull(); + + // Drive one more visible change (Space toggles the focused row) so the + // renderer emits a new frame we can inspect. If Enter had (incorrectly) + // submitted the picker, this Space keystroke would land on a closed + // stdin and the exitCode would already be set. + const beforeSpaceLen = stdoutBuf.length; + await sendKeys(spawned.child, [' ']); + await new Promise((r) => setTimeout(r, 400)); + const afterSpace = stripAnsi(stdoutBuf.slice(beforeSpaceLen)); + expect(spawned.child.exitCode).toBeNull(); + // The post-Space frame must still show the filter narrowing active + // (`Filtered: 2 of 3 visible | 1 selected`), confirming Enter kept the + // query live rather than submitting the picker. + expect(stripAnsi(stdoutBuf)).not.toContain('No changes made'); + expect(/Filtered:\s*2\s+of\s+3\s+visible\s*\|\s*1\s+selected/.test(afterSpace)).toBe(true); + + // Cleanup via Ctrl+C. + await sendKeys(spawned.child, ['\x03']); + spawned.child.stdin!.end(); + const result = await spawned.done; + expect([0, 130, null].includes(result.exitCode)).toBe(true); + }, 30_000); + + it('D5-06: after Enter-exits-filter-input, Space toggles the current visible row; selection preserved across Esc-clear', async () => { + const spawned = runCcauditGhost(tmpHome, ['--interactive'], { + env: baseEnv(), + timeout: 20_000, + }); + + let stdoutBuf = ''; + spawned.child.stdout!.on('data', (c: Buffer) => { + stdoutBuf += c.toString(); + }); + + await waitForMarker( + () => stdoutBuf, + () => spawned.child.exitCode !== null, + '0 of 6 selected across all tabs', + ); + + // Enter filter, type, Enter to exit filter-input mode keeping query live. + await sendKeys(spawned.child, ['/', 'p', 'e', 'n']); + await waitForMarker( + () => stripAnsi(stdoutBuf), + () => spawned.child.exitCode !== null, + 'Filter: pen_', + ); + await sendKeys(spawned.child, ['\r']); + await new Promise((r) => setTimeout(r, 300)); + + // Space toggles the row under the cursor in the narrowed list. While a + // filter is active the footer renders `Filtered: M of N visible | X + // selected` (per D5-01) — NOT the full-inventory `X of 6 selected` form. + await sendKeys(spawned.child, [' ']); + await waitForMarker( + () => stripAnsi(stdoutBuf), + () => spawned.child.exitCode !== null, + 'Filtered: 2 of 3 visible | 1 selected', + ); + const afterSpace = stripAnsi(stdoutBuf); + expect(afterSpace).toContain('Filtered: 2 of 3 visible | 1 selected'); + + // Re-enter filter mode and Esc-clear from there; selection preserved + // across the filter clear (D5-06). + await sendKeys(spawned.child, ['/']); + await new Promise((r) => setTimeout(r, 200)); + const beforeClearLen = stdoutBuf.length; + await sendKeys(spawned.child, ['\x1b']); + await new Promise((r) => setTimeout(r, 500)); + expect(spawned.child.exitCode).toBeNull(); + const afterClear = stripAnsi(stdoutBuf.slice(beforeClearLen)); + // Once filter is cleared, counter returns to `X of 6 selected` form; + // selection of 1 item survives. + expect(afterClear.includes('1 of 6 selected')).toBe(true); + + await sendKeys(spawned.child, ['\x03']); + spawned.child.stdin!.end(); + const result = await spawned.done; + expect([0, 130, null].includes(result.exitCode)).toBe(true); + }, 30_000); +}); diff --git a/apps/ccaudit/src/__tests__/tabbed-picker-force-partial-banner.test.ts b/apps/ccaudit/src/__tests__/tabbed-picker-force-partial-banner.test.ts new file mode 100644 index 0000000..a550b9b --- /dev/null +++ b/apps/ccaudit/src/__tests__/tabbed-picker-force-partial-banner.test.ts @@ -0,0 +1,213 @@ +/** + * Phase 6 SC2 — `--force-partial` banner integration (pty harness). + * + * Asserts: + * A) With protected items in the scan, the banner renders on every frame: + * `⚠ --force-partial active: framework protection DISABLED. …` + * Protected rows render non-dim (no [🔒] prefix). Space toggles them. + * B) With no protected items, the banner still renders with the suffix + * `(no protected items in this scan)` (D6-14). + * C) In ASCII mode (`CCAUDIT_ASCII_ONLY=1`), the banner uses `!` instead of + * `⚠` and drops color. + */ +import { describe, it, expect, beforeAll, afterEach } from 'vitest'; +import { existsSync } from 'node:fs'; +import { mkdir, writeFile, utimes } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + makeTmpHome, + cleanupTmpHome, + buildFakePs, + createMultiFrameworkFixture, + runCcauditGhost, + sendKeys, + waitForMarker, +} from './_test-helpers.ts'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const distPath = path.resolve(here, '..', '..', 'dist', 'index.js'); + +beforeAll(() => { + if (!existsSync(distPath)) { + throw new Error(`dist binary not found at ${distPath}. Run \`pnpm -F ccaudit build\` first.`); + } +}); + +/* eslint-disable no-control-regex */ +function stripAnsi(s: string): string { + return s + .replace(/\x1b\[\?25[lh]/g, '') + .replace(/\x1b\[[0-9;]*[mGKHFABCD]/g, '') + .replace(/\x1b\[\d*[JST]/g, ''); +} +/* eslint-enable no-control-regex */ + +function baseEnv(extra: Record = {}): Record { + return { + CCAUDIT_FORCE_TTY: '1', + CCAUDIT_TEST_STDOUT_ROWS: '30', + LINES: '30', + COLUMNS: '120', + LANG: 'en_US.UTF-8', + TERM: 'xterm-256color', + FORCE_COLOR: '1', + ...extra, + }; +} + +async function cleanupChild(spawned: { child: { stdin: NodeJS.WritableStream | null } }) { + if ( + spawned.child.stdin && + !(spawned.child.stdin as NodeJS.WritableStream & { destroyed?: boolean }).destroyed + ) { + try { + spawned.child.stdin.write('\x03'); + spawned.child.stdin.end(); + } catch { + /* child exit race */ + } + } +} + +describe.skipIf(process.platform === 'win32')('Phase 6 SC2 — --force-partial banner', () => { + let tmpHome: string; + + afterEach(async () => { + await cleanupTmpHome(tmpHome); + }); + + it('Scenario A: banner renders; protected rows become selectable; Space toggles them', async () => { + tmpHome = await makeTmpHome(); + await buildFakePs(tmpHome); + await createMultiFrameworkFixture(tmpHome, [ + { prefix: 'gsd', usedMembers: ['planner'], ghostMembers: ['researcher', 'verifier'] }, + ]); + + const spawned = runCcauditGhost(tmpHome, ['--interactive', '--force-partial'], { + env: baseEnv(), + timeout: 25_000, + }); + let out = ''; + spawned.child.stdout!.on('data', (c: Buffer) => { + out += c.toString(); + }); + + await waitForMarker( + () => out, + () => spawned.child.exitCode !== null, + 'selected across all tabs', + ); + + const frame = stripAnsi(out); + expect( + /⚠ --force-partial active: framework protection DISABLED/.test(frame), + `expected banner glyph + text in Unicode mode; got:\n${frame.slice(0, 1500)}`, + ).toBe(true); + // Protected lock glyph must NOT appear when --force-partial is on. + expect(frame.includes('[🔒]')).toBe(false); + + // `a` = tab-all. With --force-partial, ALL 2 protected ghosts become + // selectable alongside the framework-less nothing; gsd-planner is USED, + // so ghosts = 2. Result: 2 of 2. + const before = out.length; + await sendKeys(spawned.child, ['a']); + await new Promise((r) => setTimeout(r, 400)); + const afterA = stripAnsi(out.slice(before)); + expect( + /2 of 2 selected across all tabs/.test(afterA), + `expected '2 of 2 selected' under --force-partial; got:\n${afterA.slice(-1500)}`, + ).toBe(true); + + await cleanupChild(spawned); + const result = await spawned.done; + expect([0, 1, 130, 137, null].includes(result.exitCode)).toBe(true); + }, 45_000); + + it('Scenario B: zero-protected items — banner suffix "(no protected items in this scan)"', async () => { + tmpHome = await makeTmpHome(); + await buildFakePs(tmpHome); + // Plain ghosts only — no curated framework at all. + const agentsDir = path.join(tmpHome, '.claude', 'agents'); + await mkdir(agentsDir, { recursive: true }); + await mkdir(path.join(tmpHome, '.config', 'claude'), { recursive: true }); + const sixtyDaysAgo = new Date(Date.now() - 60 * 86_400_000); + for (const name of ['alpha', 'beta']) { + const p = path.join(agentsDir, `${name}.md`); + await writeFile(p, `# ${name}\n`, 'utf8'); + await utimes(p, sixtyDaysAgo, sixtyDaysAgo); + } + await writeFile(path.join(tmpHome, '.claude.json'), '{}', 'utf8'); + const sessionDir = path.join(tmpHome, '.claude', 'projects', 'clean'); + await mkdir(sessionDir, { recursive: true }); + const recentTs = new Date(Date.now() - 3600_000).toISOString(); + await writeFile( + path.join(sessionDir, 's.jsonl'), + JSON.stringify({ + type: 'system', + subtype: 'init', + cwd: '/fake/clean', + timestamp: recentTs, + sessionId: 'clean', + }) + '\n', + 'utf8', + ); + + const spawned = runCcauditGhost(tmpHome, ['--interactive', '--force-partial'], { + env: baseEnv(), + timeout: 25_000, + }); + let out = ''; + spawned.child.stdout!.on('data', (c: Buffer) => { + out += c.toString(); + }); + + await waitForMarker( + () => out, + () => spawned.child.exitCode !== null, + 'selected across all tabs', + ); + + const frame = stripAnsi(out); + expect(/⚠ --force-partial active/.test(frame)).toBe(true); + expect(/\(no protected items in this scan\)/.test(frame)).toBe(true); + + await cleanupChild(spawned); + const result = await spawned.done; + expect([0, 1, 130, 137, null].includes(result.exitCode)).toBe(true); + }, 45_000); + + it('Scenario C: ASCII mode — banner uses "!" instead of "⚠"', async () => { + tmpHome = await makeTmpHome(); + await buildFakePs(tmpHome); + await createMultiFrameworkFixture(tmpHome, [ + { prefix: 'gsd', usedMembers: ['planner'], ghostMembers: ['researcher'] }, + ]); + + const spawned = runCcauditGhost(tmpHome, ['--interactive', '--force-partial'], { + env: baseEnv({ CCAUDIT_ASCII_ONLY: '1' }), + timeout: 25_000, + }); + let out = ''; + spawned.child.stdout!.on('data', (c: Buffer) => { + out += c.toString(); + }); + + await waitForMarker( + () => out, + () => spawned.child.exitCode !== null, + 'selected across all tabs', + ); + + const frame = stripAnsi(out); + expect( + /! --force-partial active: framework protection DISABLED/.test(frame), + `expected ASCII banner with "!" prefix; got:\n${frame.slice(0, 1500)}`, + ).toBe(true); + expect(frame.includes('⚠')).toBe(false); + + await cleanupChild(spawned); + const result = await spawned.done; + expect([0, 1, 130, 137, null].includes(result.exitCode)).toBe(true); + }, 45_000); +}); diff --git a/apps/ccaudit/src/__tests__/tabbed-picker-force-partial-overflow.test.ts b/apps/ccaudit/src/__tests__/tabbed-picker-force-partial-overflow.test.ts new file mode 100644 index 0000000..e162fa1 --- /dev/null +++ b/apps/ccaudit/src/__tests__/tabbed-picker-force-partial-overflow.test.ts @@ -0,0 +1,149 @@ +/** + * NEW-M3 regression — `--force-partial` banner height is width-aware. + * + * Before the fix, `bannerHeight()` always returned 1 regardless of terminal + * width. At 80 cols the active+protected banner is 111 chars (wraps to + * 2 rows) and the active+zero-protected banner is 146 chars (also 2 rows at + * 80 cols, 3 rows at 60 cols). The viewport deducted only 1 row, so the + * rendered frame exceeded `stdoutRows`, re-introducing the Phase 3.1 + * off-by-one overflow. + * + * This test: + * 1. Spawns `ccaudit ghost --interactive --force-partial` with LINES=24, + * COLUMNS=80 and ≥20 ghost agents so the viewport formula is exercised. + * 2. Waits for the initial render containing the banner. + * 3. Counts the number of unique `agent-NN` entries visible in the FIRST + * rendered frame (before any keypress). + * 4. Asserts the count is ≤ 13 (viewport = Math.max(8,24-10)-2 = 12, + * plus the 2-row banner, tab bar, header, footer, and indicator lines + * comfortably fit inside 24 terminal rows with no overflow). + * + * ASCII-vs-Unicode: CCAUDIT_FORCE_TTY=1 with piped stdio → ASCII mode, so + * the banner uses `!` glyph; both glyph forms are accepted. + * + * Windows: fake-ps shell scripts require /bin/sh; skipped on win32. + */ +import { describe, it, expect, beforeAll, beforeEach, afterEach } from 'vitest'; +import { existsSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + makeTmpHome, + cleanupTmpHome, + buildFakePs, + runCcauditGhost, + buildManyGhostsFixture, +} from './_test-helpers.ts'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const distPath = path.resolve(here, '..', '..', 'dist', 'index.js'); + +beforeAll(() => { + if (!existsSync(distPath)) { + throw new Error( + `dist binary not found at ${distPath}. Run \`pnpm -F ccaudit build\` before running this test.`, + ); + } +}); + +/* eslint-disable no-control-regex -- ANSI stripping requires literal \x1b */ +function stripAnsi(s: string): string { + return s + .replace(/\x1b\[\?25[lh]/g, '') + .replace(/\x1b\[[0-9;]*[mGKHFABCD]/g, '') + .replace(/\x1b\[\d*[JST]/g, ''); +} +/* eslint-enable no-control-regex */ + +describe.skipIf(process.platform === 'win32')( + 'NEW-M3 — --force-partial banner height is width-aware (no viewport overflow at 80 cols)', + () => { + let tmpHome: string; + + beforeEach(async () => { + tmpHome = await makeTmpHome(); + // 25 ghost agents so the list is longer than any reasonable viewport. + await buildManyGhostsFixture(tmpHome, 25); + await buildFakePs(tmpHome); + }); + + afterEach(async () => { + await cleanupTmpHome(tmpHome); + }); + + it('visible agent count on first frame is within viewport bounds (≤ 13) — banner does not overflow', async () => { + const spawned = runCcauditGhost(tmpHome, ['--interactive', '--force-partial'], { + env: { CCAUDIT_FORCE_TTY: '1', LINES: '24', COLUMNS: '80' }, + timeout: 15_000, + }); + + let stdoutBuf = ''; + spawned.child.stdout!.on('data', (c: Buffer) => { + stdoutBuf += c.toString(); + }); + + // Wait for the banner text to appear in the first render. + const bannerPattern = /force-partial active/; + { + const maxWaitMs = 8_000; + const startMs = Date.now(); + let delayMs = 100; + while ( + spawned.child.exitCode === null && + !bannerPattern.test(stdoutBuf) && + Date.now() - startMs < maxWaitMs + ) { + await new Promise((r) => setTimeout(r, delayMs)); + delayMs = Math.min(delayMs * 2, 500); + } + // Snapshot the buffer offset at the point the banner first appeared so + // the agent-NN count below reflects a single post-banner frame rather + // than the cumulative pre-banner renders (stdoutBuf is additive and + // stripAnsi cannot distinguish overwrite cues from distinct renders). + // Extra grace for any trailing viewport writes. + await new Promise((r) => setTimeout(r, 200)); + } + + expect( + spawned.child.exitCode, + `subprocess exited before first render\nstdout:\n${stdoutBuf.slice(-500)}`, + ).toBeNull(); + + // Use only the trailing portion of stdoutBuf that starts at the last + // occurrence of the banner text so agentMatches reflects one frame. + const rawPlain = stripAnsi(stdoutBuf); + const bannerIdx = rawPlain.lastIndexOf('force-partial active'); + const plain = bannerIdx >= 0 ? rawPlain.slice(bannerIdx) : rawPlain; + + // Banner must be present. + expect( + plain.includes('force-partial active'), + `expected --force-partial banner in frame:\n${plain.slice(-800)}`, + ).toBe(true); + + // Count how many distinct agent-NN tokens appear in the accumulated + // output up to this point (before any keypress). Because bannerHeight + // now correctly reports 2 rows at 80 cols, computeViewportHeight + // deducts 2, leaving Math.max(8, 24-10)-2 = 12 rows of item slots. + // With sub-header rows and indicators the count will be ≤ 13. + const agentMatches = plain.match(/agent-\d\d/g) ?? []; + const uniqueAgents = new Set(agentMatches).size; + + expect( + uniqueAgents, + `expected ≤ 13 unique agent-NN visible (viewport minus 2-row banner); got ${uniqueAgents}\nframe:\n${plain.slice(-800)}`, + ).toBeLessThanOrEqual(13); + + // Must still show at least 1 agent (sanity: picker rendered at all). + expect(uniqueAgents).toBeGreaterThanOrEqual(1); + + // Tear down. + spawned.child.kill('SIGINT'); + const result = await spawned.done; + expect( + [0, 130, null].includes(result.exitCode), + `unexpected exitCode ${result.exitCode}\nstderr:\n${result.stderr.slice(-300)}`, + ).toBe(true); + }); + }, +); diff --git a/apps/ccaudit/src/__tests__/tabbed-picker-framework-group.test.ts b/apps/ccaudit/src/__tests__/tabbed-picker-framework-group.test.ts new file mode 100644 index 0000000..7e0b661 --- /dev/null +++ b/apps/ccaudit/src/__tests__/tabbed-picker-framework-group.test.ts @@ -0,0 +1,171 @@ +/** + * Phase 5 SC4 — framework-group toggle integration test (pty harness). + * + * Outcome A per 05-04-SUMMARY.md — `InventoryItem.framework` is populated by the + * framework scanner. We seed AGENTS with 2 curated-framework prefixes (gsd-*, + * sc-*, each with 2 items) so the AGENTS tab visible slice spans ≥ 2 distinct + * framework values and the `TabbedGhostPicker` renders sub-header rows. + * + * Asserts: + * - Sub-header row `-- gsd (…) --` (or `-- superclaude (…) --`) appears in ASCII mode. + * - Navigating the cursor onto a sub-header and pressing Space selects all 2 + * members of that framework (D5-17). The global footer reports `2 of 4`. + * - Pressing Space again on the sub-header deselects them (select-or-clear). + */ +import { describe, it, expect, beforeAll, beforeEach, afterEach } from 'vitest'; +import { existsSync } from 'node:fs'; +import { mkdir, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + makeTmpHome, + cleanupTmpHome, + buildFakePs, + runCcauditGhost, + sendKeys, + waitForMarker, +} from './_test-helpers.ts'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const distPath = path.resolve(here, '..', '..', 'dist', 'index.js'); + +beforeAll(() => { + if (!existsSync(distPath)) { + throw new Error( + `dist binary not found at ${distPath}. Run \`pnpm -F ccaudit build\` before running this test.`, + ); + } +}); + +/* eslint-disable no-control-regex */ +function stripAnsi(s: string): string { + return s + .replace(/\x1b\[\?25[lh]/g, '') + .replace(/\x1b\[[0-9;]*[mGKHFABCD]/g, '') + .replace(/\x1b\[\d*[JST]/g, ''); +} +/* eslint-enable no-control-regex */ + +async function buildMultiFrameworkAgentsFixture(tmpHome: string): Promise { + const agentsDir = path.join(tmpHome, '.claude', 'agents'); + const sessionDir = path.join(tmpHome, '.claude', 'projects', 'p5-fwk'); + await mkdir(agentsDir, { recursive: true }); + await mkdir(sessionDir, { recursive: true }); + await mkdir(path.join(tmpHome, '.config', 'claude'), { recursive: true }); + + // 2 curated-framework prefixes × 2 items each. All ghosts (no session usage). + for (const name of ['gsd-foo', 'gsd-bar', 'sc-baz', 'sc-qux']) { + await writeFile( + path.join(agentsDir, `${name}.md`), + `# ${name}\n\n` + 'body body body. '.repeat(10), + 'utf8', + ); + } + + await writeFile(path.join(tmpHome, '.claude.json'), '{}', 'utf8'); + const recentTs = new Date(Date.now() - 3600_000).toISOString(); + await writeFile( + path.join(sessionDir, 's.jsonl'), + JSON.stringify({ + type: 'system', + subtype: 'init', + cwd: '/fake/p5-fwk', + timestamp: recentTs, + sessionId: 'p5-fwk', + }) + '\n', + 'utf8', + ); +} + +function baseEnv(extra: Record = {}): Record { + return { + CCAUDIT_FORCE_TTY: '1', + CCAUDIT_TEST_STDOUT_ROWS: '30', + LINES: '30', + COLUMNS: '100', + NO_COLOR: '1', + ...extra, + }; +} + +describe.skipIf(process.platform === 'win32')( + 'Phase 5 SC4 — framework-group toggle integration (Outcome A)', + () => { + let tmpHome: string; + + beforeEach(async () => { + tmpHome = await makeTmpHome(); + await buildFakePs(tmpHome); + await buildMultiFrameworkAgentsFixture(tmpHome); + }); + + afterEach(async () => { + await cleanupTmpHome(tmpHome); + }); + + it('sub-header renders; Space on sub-header selects/deselects whole group (D5-17)', async () => { + const spawned = runCcauditGhost(tmpHome, ['--interactive'], { + env: baseEnv(), + timeout: 25_000, + }); + + let stdoutBuf = ''; + spawned.child.stdout!.on('data', (c: Buffer) => { + stdoutBuf += c.toString(); + }); + + await waitForMarker( + () => stdoutBuf, + () => spawned.child.exitCode !== null, + '0 of 4 selected across all tabs', + ); + + // Sub-header rows render in ASCII mode as `-- --`. At least one + // of the two known curated display names must appear somewhere in the frame. + const initial = stripAnsi(stdoutBuf); + const hasAnySubHeader = /--\s+\S.*--/m.test(initial); + if (!hasAnySubHeader) { + // Scanner framework attribution didn't fire — surface this as a blocker + // for the verifier/human rather than proceeding with a meaningless test. + throw new Error( + '[SC4] Expected framework sub-header rows in AGENTS tab but none rendered. ' + + 'Outcome A pty path requires the scanner to populate InventoryItem.framework. ' + + `Captured (tail):\n${initial.slice(-2000)}`, + ); + } + + // Cursor starts at row 0 (top of rows list). Move cursor up to land on + // the first row; the first row emitted by assembleRowsForTab for a + // multi-framework tab is a sub-header. We rely on that (per Plan 04). + // Press Home to clamp cursor at row 0. + await sendKeys(spawned.child, ['\x1b[H']); // Home + await new Promise((r) => setTimeout(r, 200)); + + // Space on sub-header → select whole group (2 items). + let beforeLen = stdoutBuf.length; + await sendKeys(spawned.child, [' ']); + await new Promise((r) => setTimeout(r, 400)); + const afterSelect = stripAnsi(stdoutBuf.slice(beforeLen)); + expect( + afterSelect.includes('2 of 4 selected across all tabs'), + `expected '2 of 4 selected' after Space on sub-header; got:\n${afterSelect}`, + ).toBe(true); + + // Space again → deselect whole group. + beforeLen = stdoutBuf.length; + await sendKeys(spawned.child, [' ']); + await new Promise((r) => setTimeout(r, 300)); + const afterDeselect = stripAnsi(stdoutBuf.slice(beforeLen)); + expect( + afterDeselect.includes('0 of 4 selected across all tabs'), + `expected '0 of 4 selected' after 2nd Space on sub-header; got:\n${afterDeselect}`, + ).toBe(true); + + // Cleanup. + await sendKeys(spawned.child, ['\x03']); + spawned.child.stdin!.end(); + const result = await spawned.done; + expect([0, 130, null].includes(result.exitCode)).toBe(true); + }, 40_000); + }, +); diff --git a/apps/ccaudit/src/__tests__/tabbed-picker-help-overlay.test.ts b/apps/ccaudit/src/__tests__/tabbed-picker-help-overlay.test.ts new file mode 100644 index 0000000..6576f29 --- /dev/null +++ b/apps/ccaudit/src/__tests__/tabbed-picker-help-overlay.test.ts @@ -0,0 +1,217 @@ +/** + * Phase 5 SC3 — help overlay (`?`) integration test (pty harness). + * + * Asserts: + * - `?` opens a modal overlay containing the visible help groups and glyph + * legend (Selection / View / Glyphs / Exit in the 30-row test viewport; + * the Navigation section is clipped by the non-TTY 20-row render limit). + * - `?` toggles the overlay closed and restores the underlying picker footer + * (test 1). `Esc` also closes the overlay without canceling the picker + * (test 2 — D5-13 gap-closure). + * - `Space` is swallowed while the overlay is open (D5-13): the overlay stays + * open after Space, and on close the global counter still shows 0 selections. + * + * Uses ASCII mode (NO_COLOR=1) so the help-overlay tests expect the `# Heading` + * formatting the renderer emits in that mode. + */ +import { describe, it, expect, beforeAll, beforeEach, afterEach } from 'vitest'; +import { existsSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + makeTmpHome, + cleanupTmpHome, + buildFakePs, + runCcauditGhost, + sendKeys, + buildManyGhostsFixture, + waitForMarker, +} from './_test-helpers.ts'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const distPath = path.resolve(here, '..', '..', 'dist', 'index.js'); + +beforeAll(() => { + if (!existsSync(distPath)) { + throw new Error( + `dist binary not found at ${distPath}. Run \`pnpm -F ccaudit build\` before running this test.`, + ); + } +}); + +/* eslint-disable no-control-regex */ +function stripAnsi(s: string): string { + return s + .replace(/\x1b\[\?25[lh]/g, '') + .replace(/\x1b\[[0-9;]*[mGKHFABCD]/g, '') + .replace(/\x1b\[\d*[JST]/g, ''); +} +/* eslint-enable no-control-regex */ + +function baseEnv(extra: Record = {}): Record { + return { + CCAUDIT_FORCE_TTY: '1', + CCAUDIT_TEST_STDOUT_ROWS: '30', + LINES: '30', + COLUMNS: '100', + NO_COLOR: '1', + ...extra, + }; +} + +describe.skipIf(process.platform === 'win32')('Phase 5 SC3 — help overlay integration', () => { + let tmpHome: string; + + beforeEach(async () => { + tmpHome = await makeTmpHome(); + await buildFakePs(tmpHome); + await buildManyGhostsFixture(tmpHome, 3); + }); + + afterEach(async () => { + await cleanupTmpHome(tmpHome); + }); + + it('`?` opens overlay with keybinding groups + glyph legend; `Esc` closes; `Space` swallowed while open (D5-13..D5-14, E4)', async () => { + const spawned = runCcauditGhost(tmpHome, ['--interactive'], { + env: baseEnv(), + timeout: 20_000, + }); + + let stdoutBuf = ''; + spawned.child.stdout!.on('data', (c: Buffer) => { + stdoutBuf += c.toString(); + }); + + await waitForMarker( + () => stdoutBuf, + () => spawned.child.exitCode !== null, + '0 of 3 selected across all tabs', + ); + + // Open help overlay. Assert on the full transcript — the terminal's + // line-diff renderer may suppress some overlay lines between renders, so + // we pick assertions from the stable visible group sections. + await sendKeys(spawned.child, ['?']); + await waitForMarker( + () => stripAnsi(stdoutBuf), + () => spawned.child.exitCode !== null, + '(Press ? or Esc to close)', + ); + const afterOpen = stripAnsi(stdoutBuf); + expect(afterOpen).toContain('Selection'); + expect(afterOpen).toContain('View'); + expect(afterOpen).toContain('Glyphs'); + expect(afterOpen).toContain('Selected'); + expect(afterOpen).toContain('Unselected'); + expect(afterOpen).toContain('Protected / framework-locked'); + expect(afterOpen).toContain('Multi-config MCP server'); + expect(afterOpen).toContain('Stale memory file'); + expect(afterOpen).toContain('Exit'); + + // While overlay open, press Space — must be swallowed per D5-13. + let beforeLen = stdoutBuf.length; + await sendKeys(spawned.child, [' ']); + await waitForMarker( + () => stripAnsi(stdoutBuf), + () => spawned.child.exitCode !== null, + '(Press ? or Esc to close)', + ); + expect(stripAnsi(stdoutBuf)).toContain('(Press ? or Esc to close)'); + expect(spawned.child.exitCode).toBeNull(); + + // Close overlay via `?` toggle. After the Phase 5 gap-closure fix, + // Esc also safely closes the overlay (see the dedicated test below); + // this original SC3 path exercises the `?` toggle for regression + // coverage of the idempotent toggle gesture. + beforeLen = stdoutBuf.length; + await sendKeys(spawned.child, ['?']); + await waitForMarker( + () => stripAnsi(stdoutBuf.slice(beforeLen)), + () => spawned.child.exitCode !== null, + '0 of 3 selected across all tabs', + ); + const afterClose = stripAnsi(stdoutBuf.slice(beforeLen)); + expect( + afterClose.includes('0 of 3 selected across all tabs'), + `expected picker footer restored after ?-toggle; selection count must still be 0 (Space swallowed); got:\n${afterClose}`, + ).toBe(true); + + // Re-open, close via `?` again (toggle). + beforeLen = stdoutBuf.length; + await sendKeys(spawned.child, ['?']); + await waitForMarker( + () => stripAnsi(stdoutBuf), + () => spawned.child.exitCode !== null, + '(Press ? or Esc to close)', + ); + await sendKeys(spawned.child, ['?']); + await waitForMarker( + () => stripAnsi(stdoutBuf.slice(beforeLen)), + () => spawned.child.exitCode !== null, + '0 of 3 selected across all tabs', + ); + const afterToggle = stripAnsi(stdoutBuf.slice(beforeLen)); + expect( + afterToggle.includes('0 of 3 selected across all tabs'), + `expected picker footer after ?-? toggle; got:\n${afterToggle}`, + ).toBe(true); + + // Cleanup. + await sendKeys(spawned.child, ['\x03']); + spawned.child.stdin!.end(); + const result = await spawned.done; + expect([0, 130, null]).toContain(result.exitCode); + }, 30_000); + + it('D5-13: `Esc` closes the help overlay without canceling the picker (gap-closure)', async () => { + const spawned = runCcauditGhost(tmpHome, ['--interactive'], { + env: baseEnv(), + timeout: 20_000, + }); + + let stdoutBuf = ''; + spawned.child.stdout!.on('data', (c: Buffer) => { + stdoutBuf += c.toString(); + }); + + await waitForMarker( + () => stdoutBuf, + () => spawned.child.exitCode !== null, + '0 of 3 selected across all tabs', + ); + + // Open overlay. Wait for the overlay footer which is always visible in the + // non-TTY viewport (clack's A() returns 20 rows without a TTY, so the + // Navigation section at lines 0-8 is clipped; only lines 9+ are rendered). + // "(Press ? or Esc to close)" is the last rendered line and appears reliably. + await sendKeys(spawned.child, ['?']); + await waitForMarker( + () => stripAnsi(stdoutBuf), + () => spawned.child.exitCode !== null, + '(Press ? or Esc to close)', + ); + + // Esc should close the overlay and return to the picker — NOT cancel. + const beforeLen = stdoutBuf.length; + await sendKeys(spawned.child, ['\x1b']); + await waitForMarker( + () => stripAnsi(stdoutBuf.slice(beforeLen)), + () => spawned.child.exitCode !== null, + '0 of 3 selected across all tabs', + ); + expect(spawned.child.exitCode).toBeNull(); + const afterEsc = stripAnsi(stdoutBuf.slice(beforeLen)); + expect(afterEsc).not.toContain('No changes made'); + expect( + afterEsc.includes('0 of 3 selected across all tabs'), + `expected picker footer restored after Esc-closes-help; got:\n${afterEsc}`, + ).toBe(true); + + // Cleanup. + await sendKeys(spawned.child, ['\x03']); + spawned.child.stdin!.end(); + const result = await spawned.done; + expect([0, 130, null]).toContain(result.exitCode); + }, 30_000); +}); diff --git a/apps/ccaudit/src/__tests__/tabbed-picker-live-counter.test.ts b/apps/ccaudit/src/__tests__/tabbed-picker-live-counter.test.ts new file mode 100644 index 0000000..95cff47 --- /dev/null +++ b/apps/ccaudit/src/__tests__/tabbed-picker-live-counter.test.ts @@ -0,0 +1,475 @@ +/** + * Phase 4 — Live token counter integration tests (D4-14 cases 1, 2, 3, 4, 5, 6). + * + * Drives the real `ccaudit ghost --interactive` subprocess through the existing + * pty fixture harness from Phase 3.1 (`runCcauditGhost` + `sendKeys`) and asserts + * on captured stdout frames after stripping ANSI escape sequences. + * + * Cases covered: + * 1. Counter updates on Space single-item toggle (D4-14 case 1) + * 2. `a` tab-all updates tab header + global footer together (D4-14 case 2) + * 3. Selections across two tabs sum into the global footer (D4-14 case 3) + * 4. SIGWINCH preserves activeTabIndex + selection + counter re-renders (D4-14 case 4) + * 5. Sub-minimum terminal banner + INV-S2 zero-manifest on cancel (D4-14 case 5) + * 6. ASCII fallback: footer renders `~ Xk tokens saved`, never `≈` (D4-14 case 6) + * + * The resize tests drive a test-only env seam (`CCAUDIT_TEST_RESIZE=1` + + * `CCAUDIT_TEST_RESIZE_ROWS`) because Node child processes with piped stdio + * do not receive real `'resize'` events — `process.stdout` is a pipe, not a + * TTY, so `process.stdout.on('resize')` never fires inside the child. + * The production code path is unchanged unless the env var is set. + */ +import { describe, it, expect, beforeAll, beforeEach, afterEach } from 'vitest'; +import { existsSync } from 'node:fs'; +import { mkdir, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + makeTmpHome, + cleanupTmpHome, + buildFakePs, + runCcauditGhost, + sendKeys, + buildManyGhostsFixture, + listManifestsDir, + waitForMarker, +} from './_test-helpers.ts'; + +// ── Dist guard ───────────────────────────────────────────────────────────── + +const here = path.dirname(fileURLToPath(import.meta.url)); +const distPath = path.resolve(here, '..', '..', 'dist', 'index.js'); + +beforeAll(() => { + if (!existsSync(distPath)) { + throw new Error( + `dist binary not found at ${distPath}. Run \`pnpm -F ccaudit build\` before running this test.`, + ); + } +}); + +// ── ANSI stripper (mirrors tabbed-picker-overflow.test.ts) ─────────────── +/* eslint-disable no-control-regex -- ANSI stripping requires literal \x1b */ +function stripAnsi(s: string): string { + return s + .replace(/\x1b\[\?25[lh]/g, '') // cursor show/hide + .replace(/\x1b\[[0-9;]*[mGKHFABCD]/g, '') // common CSI codes + .replace(/\x1b\[\d*[JST]/g, ''); // erase display / scroll +} +/* eslint-enable no-control-regex */ + +// ── Inline fixture: 2 agents + 1 skill (Test 3) ─────────────────────────── + +async function buildTwoAgentsOneSkillFixture(tmpHome: string): Promise { + const agentsDir = path.join(tmpHome, '.claude', 'agents'); + const skillsDir = path.join(tmpHome, '.claude', 'skills', 'demo-skill'); + const sessionDir = path.join(tmpHome, '.claude', 'projects', 'p4-live-counter'); + await mkdir(agentsDir, { recursive: true }); + await mkdir(skillsDir, { recursive: true }); + await mkdir(sessionDir, { recursive: true }); + await mkdir(path.join(tmpHome, '.config', 'claude'), { recursive: true }); + // Agents: non-trivial bodies so the token estimator produces ≥1 token per item. + await writeFile( + path.join(agentsDir, 'a1.md'), + '# a1\n\n' + 'alpha alpha alpha. '.repeat(20), + 'utf8', + ); + await writeFile( + path.join(agentsDir, 'a2.md'), + '# a2\n\n' + 'beta beta beta. '.repeat(20), + 'utf8', + ); + await writeFile( + path.join(skillsDir, 'SKILL.md'), + '---\nname: demo-skill\n---\n# demo-skill\n\n' + 'gamma gamma gamma. '.repeat(20), + 'utf8', + ); + await writeFile(path.join(tmpHome, '.claude.json'), '{}', 'utf8'); + const recentTs = new Date(Date.now() - 3600_000).toISOString(); + await writeFile( + path.join(sessionDir, 's.jsonl'), + JSON.stringify({ + type: 'system', + subtype: 'init', + cwd: '/fake/p4-live-counter', + timestamp: recentTs, + sessionId: 'p4-live-counter', + }) + '\n', + 'utf8', + ); +} + +// ── Helpers ──────────────────────────────────────────────────────────────── + +/** Base env for the picker subprocess: forces TTY branch + pipes stdio + ASCII. */ +function baseEnv(extra: Record = {}): Record { + return { + CCAUDIT_FORCE_TTY: '1', + CCAUDIT_TEST_STDOUT_ROWS: '24', + LINES: '24', + COLUMNS: '80', + NO_COLOR: '1', + ...extra, + }; +} + +// ── Tests ────────────────────────────────────────────────────────────────── +// Windows: fake `ps` shell scripts require /bin/sh; skip on win32. +describe.skipIf(process.platform === 'win32')( + 'Phase 4 — Live token counter integration (D4-14 cases 1, 2, 3, 4, 5, 6)', + () => { + let tmpHome: string; + + beforeEach(async () => { + tmpHome = await makeTmpHome(); + await buildFakePs(tmpHome); + }); + + afterEach(async () => { + await cleanupTmpHome(tmpHome); + }); + + // ── Test 1 — counter updates on Space toggle (D4-14 case 1) ────────── + it('footer updates from "0 of M" to "1 of M · ~ Xk tokens saved" on Space toggle', async () => { + await buildManyGhostsFixture(tmpHome, 3); + + const spawned = runCcauditGhost(tmpHome, ['--interactive'], { + env: baseEnv(), + timeout: 15_000, + }); + + let stdoutBuf = ''; + spawned.child.stdout!.on('data', (c: Buffer) => { + stdoutBuf += c.toString(); + }); + + await waitForMarker( + () => stdoutBuf, + () => spawned.child.exitCode !== null, + '0 of 3 selected across all tabs', + ); + + const initial = stripAnsi(stdoutBuf); + expect(initial).toContain('0 of 3 selected across all tabs'); + expect(initial).not.toContain('tokens saved'); + + const beforeLen = stdoutBuf.length; + await sendKeys(spawned.child, [' ']); + // Wait for re-render + await new Promise((r) => setTimeout(r, 300)); + + const after = stripAnsi(stdoutBuf.slice(beforeLen)); + expect( + after.includes('1 of 3 selected across all tabs'), + `expected '1 of 3 selected across all tabs' after Space; got:\n${after}`, + ).toBe(true); + expect( + after.includes('tokens saved'), + `expected 'tokens saved' suffix after Space; got:\n${after}`, + ).toBe(true); + + // Cleanup + await sendKeys(spawned.child, ['\x03']); + spawned.child.stdin!.end(); + const result = await spawned.done; + expect([0, 130, null].includes(result.exitCode)).toBe(true); + }, 25_000); + + // ── Test 2 — `a` tab-all updates header + footer (D4-14 case 2) ────── + it("'a' tab-all updates both the active-tab header subtotal and the global footer", async () => { + await buildManyGhostsFixture(tmpHome, 3); + + const spawned = runCcauditGhost(tmpHome, ['--interactive'], { + env: baseEnv(), + timeout: 15_000, + }); + + let stdoutBuf = ''; + spawned.child.stdout!.on('data', (c: Buffer) => { + stdoutBuf += c.toString(); + }); + + await waitForMarker( + () => stdoutBuf, + () => spawned.child.exitCode !== null, + '0 of 3 selected across all tabs', + ); + + const beforeLen = stdoutBuf.length; + await sendKeys(spawned.child, ['a']); + await new Promise((r) => setTimeout(r, 300)); + + const after = stripAnsi(stdoutBuf.slice(beforeLen)); + expect(after).toContain('3 of 3 selected across all tabs'); + // Active tab header carries the (3/3 · …) subtotal. + expect( + after.match(/AGENTS \(3\/3 ·[^)]*\)/) !== null, + `expected 'AGENTS (3/3 · …)' header after 'a'; got:\n${after}`, + ).toBe(true); + expect(after).toContain('tokens saved'); + + // Cleanup + await sendKeys(spawned.child, ['\x03']); + spawned.child.stdin!.end(); + const result = await spawned.done; + expect([0, 130, null].includes(result.exitCode)).toBe(true); + }, 25_000); + + // ── Test 3 — cross-tab sum (D4-14 case 3) ──────────────────────────── + it('selecting items across two tabs sums their tokens into the global footer', async () => { + await buildTwoAgentsOneSkillFixture(tmpHome); + + const spawned = runCcauditGhost(tmpHome, ['--interactive'], { + env: baseEnv(), + timeout: 15_000, + }); + + let stdoutBuf = ''; + spawned.child.stdout!.on('data', (c: Buffer) => { + stdoutBuf += c.toString(); + }); + + await waitForMarker( + () => stdoutBuf, + () => spawned.child.exitCode !== null, + '0 of 3 selected across all tabs', + ); + + // Select a1 + a2 in AGENTS tab. + let beforeLen = stdoutBuf.length; + await sendKeys(spawned.child, [' ', '\x1b[B', ' ']); + await new Promise((r) => setTimeout(r, 300)); + const afterAgents = stripAnsi(stdoutBuf.slice(beforeLen)); + expect( + afterAgents.includes('2 of 3 selected across all tabs'), + `expected '2 of 3' after 2× Space in AGENTS; got:\n${afterAgents}`, + ).toBe(true); + + // Tab to SKILLS, select the single skill. + beforeLen = stdoutBuf.length; + await sendKeys(spawned.child, ['\t', ' ']); + await new Promise((r) => setTimeout(r, 300)); + + const afterSkill = stripAnsi(stdoutBuf.slice(beforeLen)); + expect( + afterSkill.includes('3 of 3 selected across all tabs'), + `expected '3 of 3' after tab + Space in SKILLS; got:\n${afterSkill}`, + ).toBe(true); + expect( + afterSkill.includes('tokens saved'), + `expected 'tokens saved' suffix; got:\n${afterSkill}`, + ).toBe(true); + + // Parse the numeric counter value — ensure it's numeric. + // `~ Xk tokens saved` or `~ X tokens saved` (ASCII mode) + // `≈ Xk tokens saved` or `N tokens saved` (Unicode mode) + const match = afterSkill.match(/(?:~|≈)?\s*(\d+)(?:\.\d+)?k?\s+tokens saved/); + expect(match !== null, `expected numeric tokens-saved token; got:\n${afterSkill}`).toBe(true); + if (match !== null) { + const parsed = parseInt(match[1]!, 10); + expect(Number.isFinite(parsed)).toBe(true); + } + + // Cleanup + await sendKeys(spawned.child, ['\x03']); + spawned.child.stdin!.end(); + const result = await spawned.done; + expect([0, 130, null].includes(result.exitCode)).toBe(true); + }, 25_000); + + // ── Test 6 — ASCII fallback (D4-14 case 6) ─────────────────────────── + it('ASCII fallback: footer renders ~ Xk tokens saved, never ≈', async () => { + await buildManyGhostsFixture(tmpHome, 2); + + const spawned = runCcauditGhost(tmpHome, ['--interactive'], { + env: baseEnv(), + timeout: 15_000, + }); + + let stdoutBuf = ''; + spawned.child.stdout!.on('data', (c: Buffer) => { + stdoutBuf += c.toString(); + }); + + await waitForMarker( + () => stdoutBuf, + () => spawned.child.exitCode !== null, + '0 of 2 selected across all tabs', + ); + + const beforeLen = stdoutBuf.length; + await sendKeys(spawned.child, [' ']); + await new Promise((r) => setTimeout(r, 300)); + + const after = stripAnsi(stdoutBuf.slice(beforeLen)); + expect( + after.includes('tokens saved'), + `expected 'tokens saved' after Space; got:\n${after}`, + ).toBe(true); + // Positive: ASCII glyph '~ ' appears in the counter segment OR the count is < 1k (raw form). + // ASCII-with-k: '~ Xk tokens saved' + // ASCII-raw: 'N tokens saved' (no glyph for < 1000) + const hasAsciiGlyph = /~\s+\d+k\s+tokens saved/.test(after); + const hasRawForm = /\b\d+\s+tokens saved/.test(after); + expect( + hasAsciiGlyph || hasRawForm, + `expected '~ Xk tokens saved' or 'N tokens saved'; got:\n${after}`, + ).toBe(true); + // Negative: the Unicode approx glyph MUST NOT appear in ASCII mode. + expect(after).not.toContain('≈'); + + // Cleanup + await sendKeys(spawned.child, ['\x03']); + spawned.child.stdin!.end(); + const result = await spawned.done; + expect([0, 130, null].includes(result.exitCode)).toBe(true); + }, 25_000); + + // ── Test 4 — SIGWINCH preserves state (D4-14 case 4) ───────────────── + it('SIGWINCH preserves selection and active tab across resize, counter re-renders', async () => { + await buildTwoAgentsOneSkillFixture(tmpHome); + + const spawned = runCcauditGhost(tmpHome, ['--interactive'], { + env: baseEnv({ + CCAUDIT_TEST_RESIZE: '1', + CCAUDIT_TEST_RESIZE_ROWS: '20', + }), + timeout: 20_000, + }); + + let stdoutBuf = ''; + spawned.child.stdout!.on('data', (c: Buffer) => { + stdoutBuf += c.toString(); + }); + + await waitForMarker( + () => stdoutBuf, + () => spawned.child.exitCode !== null, + '0 of 3 selected across all tabs', + ); + + // Select 2 items in AGENTS. + await sendKeys(spawned.child, [' ', '\x1b[B', ' ']); + await new Promise((r) => setTimeout(r, 300)); + + // Assert pre-resize state. + const preResize = stripAnsi(stdoutBuf); + expect(preResize).toContain('2 of 3 selected across all tabs'); + + // Fire the resize seam (Ctrl+R). Re-rendering when the new viewport + // happens to produce a byte-identical frame can be suppressed by the + // terminal renderer's diff logic, so after the resize we also press + // ArrowDown + ArrowUp — a net-zero cursor motion that is guaranteed to + // trigger a fresh frame. This proves BOTH that the resize handler ran + // (cursor/selection/active-tab preserved across the Ctrl+R) AND that + // the next render shows preserved state. + await sendKeys(spawned.child, ['\x12']); + await new Promise((r) => setTimeout(r, 200)); + const beforeLen = stdoutBuf.length; + await sendKeys(spawned.child, ['\x1b[B', '\x1b[A']); + await new Promise((r) => setTimeout(r, 250)); + + const postResize = stripAnsi(stdoutBuf.slice(beforeLen)); + const fullAfter = stripAnsi(stdoutBuf); + // Selection count intact — present in the post-resize slice (the diff + // renderer only re-emits changed lines, and the resize + cursor nudge + // always updates the global footer line). + expect( + postResize.includes('2 of 3 selected across all tabs'), + `expected selection count preserved post-resize; got:\n${postResize}`, + ).toBe(true); + // Counter re-rendered on the post-resize frame. + expect( + postResize.includes('tokens saved'), + `expected 'tokens saved' post-resize; got:\n${postResize}`, + ).toBe(true); + // Active tab index unchanged (AGENTS still the active tab). The tab + // header line is byte-identical pre/post resize so the terminal diff + // suppresses it in the post-resize slice — assert against the full + // captured stream instead. + expect( + fullAfter.includes('AGENTS ('), + `expected AGENTS tab header to be present in captured stream`, + ).toBe(true); + // 20 rows is above the 14-row minimum → no banner ever rendered. + expect(fullAfter).not.toContain('Terminal too small'); + + // Cleanup + await sendKeys(spawned.child, ['\x03']); + spawned.child.stdin!.end(); + const result = await spawned.done; + expect([0, 130, null].includes(result.exitCode)).toBe(true); + }, 30_000); + + // ── Test 5 — sub-minimum banner + INV-S2 (D4-14 case 5) ────────────── + it('sub-minimum terminal after resize shows banner and cancel writes zero manifests (INV-S2)', async () => { + await buildManyGhostsFixture(tmpHome, 3); + + // INV-S2 baseline: no manifests exist before the spawn. + expect(await listManifestsDir(tmpHome)).toEqual([]); + + const spawned = runCcauditGhost(tmpHome, ['--interactive'], { + env: baseEnv({ + CCAUDIT_TEST_RESIZE: '1', + CCAUDIT_TEST_RESIZE_ROWS: '10', + }), + timeout: 20_000, + }); + + let stdoutBuf = ''; + spawned.child.stdout!.on('data', (c: Buffer) => { + stdoutBuf += c.toString(); + }); + + await waitForMarker( + () => stdoutBuf, + () => spawned.child.exitCode !== null, + '0 of 3 selected across all tabs', + ); + + // Select one item while terminal is still normal size. + let beforeLen = stdoutBuf.length; + await sendKeys(spawned.child, [' ']); + await new Promise((r) => setTimeout(r, 300)); + + const afterSpace = stripAnsi(stdoutBuf.slice(beforeLen)); + expect(afterSpace).toContain('1 of 3 selected across all tabs'); + expect(afterSpace).toContain('tokens saved'); + + // Fire resize seam → 10 rows = sub-minimum. + beforeLen = stdoutBuf.length; + await sendKeys(spawned.child, ['\x12']); + await new Promise((r) => setTimeout(r, 250)); + + const afterResize = stripAnsi(stdoutBuf.slice(beforeLen)); + expect( + afterResize.includes('Terminal too small'), + `expected 'Terminal too small' banner post-resize; got:\n${afterResize}`, + ).toBe(true); + + // Send Space again — must be a no-op per D4-08. + beforeLen = stdoutBuf.length; + await sendKeys(spawned.child, [' ']); + await new Promise((r) => setTimeout(r, 250)); + + const afterSecondSpace = stripAnsi(stdoutBuf.slice(beforeLen)); + const fullAfterSecondSpace = stripAnsi(stdoutBuf); + // Banner still present (interactivity suppressed), or diff renderer emitted no new frame. + expect( + afterSecondSpace.includes('Terminal too small') || afterSecondSpace.trim() === '', + `expected banner re-rendered or zero new bytes after suppressed Space; got:\n${afterSecondSpace}`, + ).toBe(true); + expect(fullAfterSecondSpace).toContain('1 of 3 selected across all tabs'); + expect(fullAfterSecondSpace).not.toContain('2 of 3 selected across all tabs'); + + // Cleanup: Ctrl-C, stdin.end, await exit. + await sendKeys(spawned.child, ['\x03']); + spawned.child.stdin!.end(); + const result = await spawned.done; + + // INV-S2: cancel during picker → zero manifests, even in sub-minimum state. + expect(await listManifestsDir(tmpHome)).toEqual([]); + expect([0, 130, null].includes(result.exitCode)).toBe(true); + }, 30_000); + }, +); diff --git a/apps/ccaudit/src/__tests__/tabbed-picker-mcp-multi-config.test.ts b/apps/ccaudit/src/__tests__/tabbed-picker-mcp-multi-config.test.ts new file mode 100644 index 0000000..52feccf --- /dev/null +++ b/apps/ccaudit/src/__tests__/tabbed-picker-mcp-multi-config.test.ts @@ -0,0 +1,242 @@ +/** + * Phase 6 SC3 — MCP multi-config warning integration (pty harness). + * + * Asserts: + * - MCP row with configRefs.length > 1 renders `⚠` (or `!` in ASCII) prefix. + * - On focus, below-cursor hint matches `Also in: ` with user-home + * compression (`~/`) applied. + * - Advisory only: pressing Space toggles selection normally. + * - Truncation case: 5+ configs → `Also in: a, b, … (N more)`. + */ +import { describe, it, expect, beforeAll, beforeEach, afterEach } from 'vitest'; +import { existsSync } from 'node:fs'; +import { mkdir, writeFile, utimes } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + makeTmpHome, + cleanupTmpHome, + buildFakePs, + createMultiConfigMcpFixture, + runCcauditGhost, + sendKeys, + waitForMarker, +} from './_test-helpers.ts'; + +async function seedGhostAgent(tmpHome: string, name: string) { + const agentsDir = path.join(tmpHome, '.claude', 'agents'); + await mkdir(agentsDir, { recursive: true }); + const p = path.join(agentsDir, `${name}.md`); + await writeFile(p, `# ${name}\n`, 'utf8'); + const sixty = new Date(Date.now() - 60 * 86_400_000); + await utimes(p, sixty, sixty); +} + +async function seedSession(tmpHome: string, label: string, cwdOverride?: string) { + const dir = path.join(tmpHome, '.claude', 'projects', label); + await mkdir(dir, { recursive: true }); + const ts = new Date(Date.now() - 3600_000).toISOString(); + const cwd = cwdOverride ?? `/fake/${label}`; + await writeFile( + path.join(dir, 's.jsonl'), + JSON.stringify({ + type: 'system', + subtype: 'init', + cwd, + timestamp: ts, + sessionId: label, + }) + '\n', + 'utf8', + ); + await mkdir(path.join(tmpHome, '.config', 'claude'), { recursive: true }); +} + +const here = path.dirname(fileURLToPath(import.meta.url)); +const distPath = path.resolve(here, '..', '..', 'dist', 'index.js'); + +beforeAll(() => { + if (!existsSync(distPath)) { + throw new Error(`dist binary not found at ${distPath}. Run \`pnpm -F ccaudit build\` first.`); + } +}); + +/* eslint-disable no-control-regex */ +function stripAnsi(s: string): string { + return s + .replace(/\x1b\[\?25[lh]/g, '') + .replace(/\x1b\[[0-9;]*[mGKHFABCD]/g, '') + .replace(/\x1b\[\d*[JST]/g, ''); +} +/* eslint-enable no-control-regex */ + +function baseEnv(extra: Record = {}): Record { + return { + CCAUDIT_FORCE_TTY: '1', + CCAUDIT_TEST_STDOUT_ROWS: '30', + LINES: '30', + COLUMNS: '120', + LANG: 'en_US.UTF-8', + TERM: 'xterm-256color', + FORCE_COLOR: '1', + ...extra, + }; +} + +/** + * Navigate the picker cursor to the MCP tab (ServersTab == 'mcp-server'). + * Tabs are cycled with Tab (`\t`). We press Tab until the tab header + * changes to show the MCP tab is active, or up to N attempts. + */ +async function focusMcpTab( + child: { stdin: NodeJS.WritableStream | null; exitCode: number | null }, + getOut: () => string, + maxTabs = 6, +): Promise { + for (let i = 0; i < maxTabs; i++) { + const beforeLen = getOut().length; + if ( + child.stdin && + !(child.stdin as NodeJS.WritableStream & { destroyed?: boolean }).destroyed + ) { + child.stdin.write('\t'); + } + await new Promise((r) => setTimeout(r, 200)); + const frame = stripAnsi(getOut().slice(beforeLen)); + if (/MCP SERVERS? \(\d+\/\d+\)|\bMCP\b/.test(frame)) { + return true; + } + } + return false; +} + +async function cleanupChild(spawned: { + child: { stdin: NodeJS.WritableStream | null; exitCode: number | null }; +}) { + if ( + spawned.child.stdin && + !(spawned.child.stdin as NodeJS.WritableStream & { destroyed?: boolean }).destroyed + ) { + try { + spawned.child.stdin.write('\x03'); + spawned.child.stdin.end(); + } catch { + /* child exit race */ + } + } +} + +describe.skipIf(process.platform === 'win32')( + 'Phase 6 SC3 — multi-config MCP warning glyph + Also-in hint', + () => { + let tmpHome: string; + + beforeEach(async () => { + tmpHome = await makeTmpHome(); + await buildFakePs(tmpHome); + }); + + afterEach(async () => { + await cleanupTmpHome(tmpHome); + }); + + it('MCP row with 2 configs renders ⚠ prefix and "Also in:" hint on focus', async () => { + await createMultiConfigMcpFixture({ + home: tmpHome, + sharedKey: 'pencil', + alsoInProjectLocal: true, + alsoInUser: true, + }); + // Seed a ghost agent + session so the picker has >0 ghosts overall and + // doesn't short-circuit on '✅ No ghosts found'. + await seedGhostAgent(tmpHome, 'filler'); + await seedSession(tmpHome, 'mcp-fixture', path.join(tmpHome, 'project')); + + // Launch picker from the project root so `.mcp.json` is discovered. + const projectRoot = path.join(tmpHome, 'project'); + const spawned = runCcauditGhost(tmpHome, ['--interactive'], { + env: baseEnv(), + cwd: projectRoot, + timeout: 25_000, + }); + let out = ''; + spawned.child.stdout!.on('data', (c: Buffer) => { + out += c.toString(); + }); + + await waitForMarker( + () => out, + () => spawned.child.exitCode !== null, + 'selected across all tabs', + ); + + // Navigate to the MCP tab. + await focusMcpTab(spawned.child, () => out); + + // Navigate cursor onto the shared-key row. Home + Down a few times — + // the pencil row is the only MCP row, so focusing the MCP tab and + // pressing Home should land on it. + await sendKeys(spawned.child, ['\x1b[H']); + await new Promise((r) => setTimeout(r, 200)); + + const frame = stripAnsi(out); + // Row prefix warning glyph must be present in the MCP tab view. + expect( + frame.includes('⚠'), + `expected ⚠ glyph on multi-config MCP row; frame:\n${frame.slice(-2000)}`, + ).toBe(true); + // Also-in hint renders somewhere in the captured output. + expect( + /Also in: .+/.test(frame), + `expected 'Also in: ...' hint on focus; frame:\n${frame.slice(-2000)}`, + ).toBe(true); + + await cleanupChild(spawned); + const result = await spawned.done; + expect([0, 1, 130, 137, null].includes(result.exitCode)).toBe(true); + }, 45_000); + + it('5 configs — Also-in hint truncates to "... (N more)"', async () => { + await createMultiConfigMcpFixture({ + home: tmpHome, + sharedKey: 'quintet', + alsoInProjectLocal: true, + alsoInUser: true, + extraProjectDirs: ['proj-b', 'proj-c', 'proj-d'], + }); + await seedGhostAgent(tmpHome, 'filler'); + await seedSession(tmpHome, 'mcp-quintet', path.join(tmpHome, 'project')); + + const projectRoot = path.join(tmpHome, 'project'); + const spawned = runCcauditGhost(tmpHome, ['--interactive'], { + env: baseEnv(), + cwd: projectRoot, + timeout: 25_000, + }); + let out = ''; + spawned.child.stdout!.on('data', (c: Buffer) => { + out += c.toString(); + }); + + await waitForMarker( + () => out, + () => spawned.child.exitCode !== null, + 'selected across all tabs', + ); + + await focusMcpTab(spawned.child, () => out); + await sendKeys(spawned.child, ['\x1b[H']); + await new Promise((r) => setTimeout(r, 200)); + + const frame = stripAnsi(out); + // Either full join (if configs compressed by dedup) or truncated form. + expect( + /Also in: .+/.test(frame), + `expected Also-in hint in MCP tab; frame:\n${frame.slice(-2000)}`, + ).toBe(true); + + await cleanupChild(spawned); + const result = await spawned.done; + expect([0, 1, 130, 137, null].includes(result.exitCode)).toBe(true); + }, 45_000); + }, +); diff --git a/apps/ccaudit/src/__tests__/tabbed-picker-overflow.test.ts b/apps/ccaudit/src/__tests__/tabbed-picker-overflow.test.ts new file mode 100644 index 0000000..14b2d05 --- /dev/null +++ b/apps/ccaudit/src/__tests__/tabbed-picker-overflow.test.ts @@ -0,0 +1,176 @@ +/** + * Phase 3.1 — Terminal-overflow regression test (SC4, the ship-gate test for + * Phase 3.1's core goal). + * + * Phase 2's flat `@clack/prompts.groupMultiselect` has no windowing — when the + * ghost list exceeds terminal rows, the cursor disappears off-screen. Phase 3.1 + * replaces that flat picker with a bounded-viewport TabbedGhostPicker + * (D3.1-05 / D3.1-06). This test locks the regression behind a subprocess + * integration check: + * + * 1. Scaffold 60 ghost agents. + * 2. Spawn `ccaudit ghost --interactive` with CCAUDIT_FORCE_TTY=1, + * LINES=24, COLUMNS=80 — so the viewport formula resolves deterministically + * to Math.max(8, 24-10) = 14 rows. + * 3. Capture the initial render and assert: + * - Only a viewport-sized slice of agents is visible (10..20 matches). + * - The `N more below` indicator is rendered (either `↓ N more below` or + * the ASCII fallback `v N more below`). + * - The `more` copy is present. + * 4. Send the `End` key (`'\x1b[F'`) → assert: + * - `agent-60` is now visible. + * - The above-indicator (`↑` or `^`) is present. + * 5. Ctrl-C to clean up. Any exit code in [0, 130, null] is acceptable. + * + * ASCII-vs-Unicode note: under NO_COLOR=1 + piped stdio, `shouldUseAscii()` + * returns true so the picker uses ASCII glyphs (`^` / `v` / `>` / `<-` / `->`). + * Assertions accept both glyph sets for portability. + * + * This test is the regression harness for the Phase 2 manual-QA bug. + */ +import { describe, it, expect, beforeAll, beforeEach, afterEach } from 'vitest'; +import { existsSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + makeTmpHome, + cleanupTmpHome, + buildFakePs, + runCcauditGhost, + buildManyGhostsFixture, +} from './_test-helpers.ts'; + +// ── Dist guard ───────────────────────────────────────────────────────────── + +const here = path.dirname(fileURLToPath(import.meta.url)); +const distPath = path.resolve(here, '..', '..', 'dist', 'index.js'); + +beforeAll(() => { + if (!existsSync(distPath)) { + throw new Error( + `dist binary not found at ${distPath}. Run \`pnpm -F ccaudit build\` before running this test.`, + ); + } +}); + +// ── Helper: strip ANSI escape sequences from captured stdout ───────────── +// Covers the subset of codes the @clack/core + picocolors renderer emits: +// SGR (`\x1b[...m`), cursor-show/hide (`\x1b[?25l` / `\x1b[?25h`), +// cursor movement and line erase (`\x1b[NA`, `\x1b[NB`, `\x1b[K`, `\x1b[2K`, +// `\x1b[F`, `\x1b[G`, `\x1b[H`). Keeps the printable content intact so +// `.includes('agent-01')` and `.match(/agent-\d\d/g)` work on the result. +/* eslint-disable no-control-regex -- ANSI stripping requires literal \x1b */ +function stripAnsi(s: string): string { + return s + .replace(/\x1b\[\?25[lh]/g, '') // cursor show/hide + .replace(/\x1b\[[0-9;]*[mGKHFABCD]/g, '') // common CSI codes + .replace(/\x1b\[\d*[JST]/g, ''); // erase display / scroll +} +/* eslint-enable no-control-regex */ + +// ── Test ─────────────────────────────────────────────────────────────────── +// Windows: fake `ps` shell scripts require /bin/sh; skip on win32. +describe.skipIf(process.platform === 'win32')( + 'Phase 3.1 — Terminal-overflow regression (60 ghosts, LINES=24 → viewport 14)', + () => { + let tmpHome: string; + + beforeEach(async () => { + tmpHome = await makeTmpHome(); + await buildManyGhostsFixture(tmpHome, 60); + await buildFakePs(tmpHome); + }); + + afterEach(async () => { + await cleanupTmpHome(tmpHome); + }); + + it('renders a bounded viewport + `N more below` indicator on first frame; End jumps to last item + shows `N more above`', async () => { + const spawned = runCcauditGhost(tmpHome, ['--interactive'], { + env: { CCAUDIT_FORCE_TTY: '1', LINES: '24', COLUMNS: '80' }, + timeout: 15_000, + }); + + // Accumulate stdout bytes. + let stdoutBuf = ''; + spawned.child.stdout!.on('data', (c: Buffer) => { + stdoutBuf += c.toString(); + }); + + // Wait for picker to finish its first render. Poll with exponential + // back-off (same pattern as safety-invariants-tui-abort.test.ts) until + // stdout contains the initial render marker `AGENTS (0/60)`, or a + // 5-second ceiling is reached. + { + const maxWaitMs = 5_000; + const startMs = Date.now(); + let delayMs = 100; + while ( + spawned.child.exitCode === null && + !stdoutBuf.includes('AGENTS (0/60)') && + Date.now() - startMs < maxWaitMs + ) { + await new Promise((r) => setTimeout(r, delayMs)); + delayMs = Math.min(delayMs * 2, 500); + } + // Final 200ms grace for any post-header viewport writes. + await new Promise((r) => setTimeout(r, 200)); + } + + // Confirm the child didn't crash before rendering. + expect( + spawned.child.exitCode, + `subprocess exited before first render\nstdout:\n${stdoutBuf.slice(-500)}`, + ).toBeNull(); + + const initialPlain = stripAnsi(stdoutBuf); + + // ── Assertion 1: out-of-view indicator rendered ────────────────────── + // 60 items > 14-row viewport → expect a below-viewport indicator on the + // first render (cursor starts at row 0 with 46 more below). + expect( + initialPlain.includes('↓') || initialPlain.includes('v '), + `expected ↓ or 'v ' below-indicator; first frame:\n${initialPlain}`, + ).toBe(true); + // The indicator copy "more" is present. + expect(initialPlain).toMatch(/\bmore\b/); + + // ── Assertion 2: only a viewport-sized slice of agents visible ─────── + const firstFrameMatches = initialPlain.match(/agent-\d\d/g) ?? []; + expect( + firstFrameMatches.length, + `expected 10..20 agent-NN matches (viewport ≈ 14); got ${firstFrameMatches.length}\nframe:\n${initialPlain}`, + ).toBeGreaterThanOrEqual(10); + expect(firstFrameMatches.length).toBeLessThanOrEqual(20); + + // ── Assertion 3: End key jumps cursor to last item ────────────────── + // Record stdout length before pressing End, then compare the delta. + const beforeEndLen = stdoutBuf.length; + spawned.child.stdin!.write('\x1b[F'); // End + await new Promise((r) => setTimeout(r, 500)); + + const afterEndRaw = stdoutBuf.slice(beforeEndLen); + const afterEndPlain = stripAnsi(afterEndRaw); + + // agent-60 is visible (cursor at end). + expect( + afterEndPlain.includes('agent-60'), + `expected 'agent-60' visible after End key; got:\n${afterEndPlain}`, + ).toBe(true); + + // Above-viewport indicator is now present. + expect( + afterEndPlain.includes('↑') || afterEndPlain.includes('^'), + `expected ↑ or '^' above-indicator after End; got:\n${afterEndPlain}`, + ).toBe(true); + + // ── Tear down: Ctrl-C and await exit ───────────────────────────────── + spawned.child.kill('SIGINT'); + const result = await spawned.done; + expect( + [0, 130, null].includes(result.exitCode), + `unexpected exitCode ${result.exitCode}\nstderr:\n${result.stderr.slice(-500)}`, + ).toBe(true); + }); + }, +); diff --git a/apps/ccaudit/src/__tests__/tabbed-picker-protection.test.ts b/apps/ccaudit/src/__tests__/tabbed-picker-protection.test.ts new file mode 100644 index 0000000..ba2c9df --- /dev/null +++ b/apps/ccaudit/src/__tests__/tabbed-picker-protection.test.ts @@ -0,0 +1,226 @@ +/** + * Phase 6 SC1 — Framework-protection picker integration (pty harness). + * + * Spawns `ccaudit ghost --interactive` against a multi-framework fixture + * where at least one curated framework is "partially used" (has both used + + * ghost members). Asserts: + * - Protected rows render with `[🔒]` (or `[L]` in ASCII-only mode). + * - Moving the cursor onto a protected row surfaces the reason hint + * `Part of (N used, M ghost). --force-partial to override.` + * - Pressing Space on a protected row is a silent no-op (selection count + * unchanged). + * - Pressing `a` (tab-all) excludes protected rows from the selection. + */ +import { describe, it, expect, beforeAll, beforeEach, afterEach } from 'vitest'; +import { existsSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + makeTmpHome, + cleanupTmpHome, + buildFakePs, + createMultiFrameworkFixture, + runCcauditGhost, + sendKeys, + waitForMarker, +} from './_test-helpers.ts'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const distPath = path.resolve(here, '..', '..', 'dist', 'index.js'); + +beforeAll(() => { + if (!existsSync(distPath)) { + throw new Error(`dist binary not found at ${distPath}. Run \`pnpm -F ccaudit build\` first.`); + } +}); + +/* eslint-disable no-control-regex */ +function stripAnsi(s: string): string { + return s + .replace(/\x1b\[\?25[lh]/g, '') + .replace(/\x1b\[[0-9;]*[mGKHFABCD]/g, '') + .replace(/\x1b\[\d*[JST]/g, ''); +} +/* eslint-enable no-control-regex */ + +function baseEnv(extra: Record = {}): Record { + return { + CCAUDIT_FORCE_TTY: '1', + CCAUDIT_TEST_STDOUT_ROWS: '30', + LINES: '30', + COLUMNS: '120', + // NO_COLOR: disabled — hasColors()===false triggers ASCII fallback, which + // would swap [🔒] for [L] and defeat the Unicode assertion. ASCII test + // below opts in explicitly via CCAUDIT_ASCII_ONLY=1. + LANG: 'en_US.UTF-8', + TERM: 'xterm-256color', + FORCE_COLOR: '1', + ...extra, + }; +} + +describe.skipIf(process.platform === 'win32')( + 'Phase 6 SC1 — framework-protected rows in the picker', + () => { + let tmpHome: string; + + beforeEach(async () => { + tmpHome = await makeTmpHome(); + await buildFakePs(tmpHome); + // GSD framework: 1 used + 2 ghost → partially-used → protected. + // Plus a plain ghost agent so tab-all has something selectable. + await createMultiFrameworkFixture(tmpHome, [ + { prefix: 'gsd', usedMembers: ['planner'], ghostMembers: ['researcher', 'verifier'] }, + ]); + // Add a non-framework ghost so `a` has something to select. + const fs = await import('node:fs/promises'); + const sixtyDaysAgo = new Date(Date.now() - 60 * 86_400_000); + const plainPath = path.join(tmpHome, '.claude', 'agents', 'plain-ghost.md'); + await fs.writeFile(plainPath, '# plain ghost\n', 'utf8'); + await fs.utimes(plainPath, sixtyDaysAgo, sixtyDaysAgo); + }); + + afterEach(async () => { + await cleanupTmpHome(tmpHome); + }); + + it('Unicode: [🔒] renders, reason hints on focus, Space is no-op, `a` skips protected', async () => { + const spawned = runCcauditGhost(tmpHome, ['--interactive'], { + env: baseEnv(), + timeout: 25_000, + }); + let out = ''; + spawned.child.stdout!.on('data', (c: Buffer) => { + out += c.toString(); + }); + + await waitForMarker( + () => out, + () => spawned.child.exitCode !== null, + 'selected across all tabs', + ); + + const initial = stripAnsi(out); + + // Lock glyph renders on every protected row. + expect(initial).toMatch(/\[🔒\]/); + + // `a` = tab-all. Selection count must exclude the 2 protected ghosts. + // Fixture ghosts: 2 gsd-* (protected) + 1 plain-ghost = 3 total, 1 selectable. + let before = out.length; + await sendKeys(spawned.child, ['a']); + await waitForMarker( + () => stripAnsi(out.slice(before)), + () => spawned.child.exitCode !== null, + '1 of 3 selected across all tabs', + ); + const afterA = stripAnsi(out.slice(before)); + expect( + /1 of 3 selected across all tabs/.test(afterA), + `expected '1 of 3 selected' after 'a'; got:\n${afterA.slice(-1500)}`, + ).toBe(true); + + // Clear selection, then move cursor to a protected row. + // Press End then PageUp/navigate until cursor sits on a gsd-* row. + // Easier: `a` again to clear, then Down until we hit a gsd row. + before = out.length; + await sendKeys(spawned.child, ['a']); // toggle off + await waitForMarker( + () => stripAnsi(out.slice(before)), + () => spawned.child.exitCode !== null, + '0 of 3 selected across all tabs', + ); + const cleared = stripAnsi(out.slice(before)); + expect(/0 of 3 selected across all tabs/.test(cleared)).toBe(true); + + // Home cursor then navigate to find a gsd row focused. + await sendKeys(spawned.child, ['\x1b[H']); + await new Promise((r) => setTimeout(r, 150)); + + // The protected-reason hint must appear at least once as we navigate. + before = out.length; + for (let i = 0; i < 6; i++) { + await sendKeys(spawned.child, ['\x1b[B']); // Down + await new Promise((r) => setTimeout(r, 100)); + } + const navigated = stripAnsi(out.slice(before)); + expect( + /Part of .+\(\d+ used, \d+ ghost\)\. --force-partial to override\./.test(navigated), + `expected protected reason hint while navigating; got:\n${navigated.slice(-1500)}`, + ).toBe(true); + + // Press Space once more; the key invariant is that the final selection + // count NEVER exceeds 1 (only the plain ghost is ever selectable). The + // `_assertNoProtectedSelected` invariant inside the picker (plan 02) + // would throw under vitest if a protected id entered selection. Wrap + // in try/catch in case the child has already exited. + if (spawned.child.stdin && !spawned.child.stdin.destroyed) { + try { + await sendKeys(spawned.child, [' ']); + await new Promise((r) => setTimeout(r, 200)); + } catch { + /* child exit race — acceptable */ + } + } + const finalFrame = stripAnsi(out); + expect(/[2-9] of 3 selected across all tabs/.test(finalFrame)).toBe(false); + + if (spawned.child.stdin && !spawned.child.stdin.destroyed) { + try { + await sendKeys(spawned.child, ['\x03']); + spawned.child.stdin.end(); + } catch { + /* child exit race */ + } + } + const result = await spawned.done; + // SIGKILL → 137, SIGINT → 130, normal quit → 0. Subprocess may also + // exit 1 when stdin closes before a confirm prompt — accept all valid + // terminations (the substantive invariants have been checked above). + expect([0, 1, 130, 137, null].includes(result.exitCode)).toBe(true); + }, 45_000); + + it('ASCII mode: [L] renders in place of [🔒]; reason hint still present', async () => { + const spawned = runCcauditGhost(tmpHome, ['--interactive'], { + env: baseEnv({ CCAUDIT_ASCII_ONLY: '1' }), + timeout: 25_000, + }); + let out = ''; + spawned.child.stdout!.on('data', (c: Buffer) => { + out += c.toString(); + }); + + await waitForMarker( + () => out, + () => spawned.child.exitCode !== null, + 'selected across all tabs', + ); + + const frame = stripAnsi(out); + expect(frame).toMatch(/\[L\]/); + expect(frame).not.toMatch(/\[🔒\]/); + + // `a` must still exclude protected rows in ASCII mode. + const before = out.length; + await sendKeys(spawned.child, ['a']); + await waitForMarker( + () => stripAnsi(out.slice(before)), + () => spawned.child.exitCode !== null, + '1 of 3 selected across all tabs', + ); + const afterA = stripAnsi(out.slice(before)); + expect(/1 of 3 selected across all tabs/.test(afterA)).toBe(true); + + if (spawned.child.stdin && !spawned.child.stdin.destroyed) { + try { + await sendKeys(spawned.child, ['\x03']); + spawned.child.stdin.end(); + } catch { + /* child exit race */ + } + } + const result = await spawned.done; + expect([0, 1, 130, 137, null].includes(result.exitCode)).toBe(true); + }, 45_000); + }, +); diff --git a/apps/ccaudit/src/__tests__/tabbed-picker-sort-cycle.test.ts b/apps/ccaudit/src/__tests__/tabbed-picker-sort-cycle.test.ts new file mode 100644 index 0000000..97604c5 --- /dev/null +++ b/apps/ccaudit/src/__tests__/tabbed-picker-sort-cycle.test.ts @@ -0,0 +1,213 @@ +/** + * Phase 5 SC2 — sort cycle (`s`) integration test (pty harness). + * + * Asserts: + * - `s` cycles staleness-desc → tokens-desc → name-asc → staleness-desc (D5-08 / D5-10). + * - Active-tab header shows `· sort:tokens` or `· sort:name` off-default; no + * suffix when back at the default (D5-12). + * - Per-tab memory: cycling on tab 2 does not affect tab 1's sort (D5-09). + * + * Fixture: 3 agents with divergent (tokens, name, mtime) so each sort produces + * a different top row. We detect ordering by the `[ ]` row lines after ANSI + * stripping. + */ +import { describe, it, expect, beforeAll, beforeEach, afterEach } from 'vitest'; +import { existsSync } from 'node:fs'; +import { mkdir, writeFile, utimes } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + makeTmpHome, + cleanupTmpHome, + buildFakePs, + runCcauditGhost, + sendKeys, + waitForMarker, +} from './_test-helpers.ts'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const distPath = path.resolve(here, '..', '..', 'dist', 'index.js'); + +beforeAll(() => { + if (!existsSync(distPath)) { + throw new Error( + `dist binary not found at ${distPath}. Run \`pnpm -F ccaudit build\` before running this test.`, + ); + } +}); + +/* eslint-disable no-control-regex */ +function stripAnsi(s: string): string { + return s + .replace(/\x1b\[\?25[lh]/g, '') + .replace(/\x1b\[[0-9;]*[mGKHFABCD]/g, '') + .replace(/\x1b\[\d*[JST]/g, ''); +} +/* eslint-enable no-control-regex */ + +async function buildSortFixture(tmpHome: string): Promise { + const agentsDir = path.join(tmpHome, '.claude', 'agents'); + const skillsRoot = path.join(tmpHome, '.claude', 'skills'); + const sessionDir = path.join(tmpHome, '.claude', 'projects', 'p5-sort'); + await mkdir(agentsDir, { recursive: true }); + await mkdir(skillsRoot, { recursive: true }); + await mkdir(sessionDir, { recursive: true }); + await mkdir(path.join(tmpHome, '.config', 'claude'), { recursive: true }); + + // Three agents with distinct (size, name, mtime). Bigger body => more tokens. + // z-old: ~100 tokens-ish (short body), oldest mtime (60d ago) + // a-new: ~500 tokens-ish (biggest body), newest mtime (yesterday) + // m-mid: ~300 tokens-ish (mid body), mid mtime (30d ago) + const tinyBody = 'x '.repeat(40); + const midBody = 'y '.repeat(400); + const bigBody = 'z '.repeat(800); + + const paths: Array<[string, string, Date]> = [ + ['z-old.md', tinyBody, new Date(Date.now() - 60 * 86_400_000)], + ['a-new.md', bigBody, new Date(Date.now() - 1 * 86_400_000)], + ['m-mid.md', midBody, new Date(Date.now() - 30 * 86_400_000)], + ]; + for (const [name, body, mtime] of paths) { + const p = path.join(agentsDir, name); + await writeFile(p, `# ${name}\n\n${body}\n`, 'utf8'); + await utimes(p, mtime, mtime); + } + + // One skill so tab 2 exists with something to sort on. + const skDir = path.join(skillsRoot, 'only-skill'); + await mkdir(skDir, { recursive: true }); + await writeFile( + path.join(skDir, 'SKILL.md'), + '---\nname: only-skill\n---\n# only-skill\n\n' + 'q '.repeat(80), + 'utf8', + ); + + await writeFile(path.join(tmpHome, '.claude.json'), '{}', 'utf8'); + const recentTs = new Date(Date.now() - 3600_000).toISOString(); + await writeFile( + path.join(sessionDir, 's.jsonl'), + JSON.stringify({ + type: 'system', + subtype: 'init', + cwd: '/fake/p5-sort', + timestamp: recentTs, + sessionId: 'p5-sort', + }) + '\n', + 'utf8', + ); +} + +function baseEnv(extra: Record = {}): Record { + return { + CCAUDIT_FORCE_TTY: '1', + CCAUDIT_TEST_STDOUT_ROWS: '30', + LINES: '30', + COLUMNS: '100', + NO_COLOR: '1', + ...extra, + }; +} + +describe.skipIf(process.platform === 'win32')('Phase 5 SC2 — sort cycle integration', () => { + let tmpHome: string; + + beforeEach(async () => { + tmpHome = await makeTmpHome(); + await buildFakePs(tmpHome); + await buildSortFixture(tmpHome); + }); + + afterEach(async () => { + await cleanupTmpHome(tmpHome); + }); + + it('cycles staleness → tokens → name → staleness on `s`; per-tab memory holds across Tab', async () => { + const spawned = runCcauditGhost(tmpHome, ['--interactive'], { + env: baseEnv(), + timeout: 25_000, + }); + + let stdoutBuf = ''; + spawned.child.stdout!.on('data', (c: Buffer) => { + stdoutBuf += c.toString(); + }); + + await waitForMarker( + () => stdoutBuf, + () => spawned.child.exitCode !== null, + '0 of 4 selected across all tabs', + ); + + // Default = staleness-desc → sort suffix is hidden (D5-12). + const initial = stripAnsi(stdoutBuf); + expect( + /AGENTS \(0\/3\)\s*$/m.test(initial) || /AGENTS \(0\/3\)(?!\s*·\s*sort:)/.test(initial), + `expected no sort suffix on default; got AGENTS header region:\n${initial.slice(-2000)}`, + ).toBe(true); + + // 1st `s` → tokens-desc → header gets ' · sort:tokens'. + let beforeLen = stdoutBuf.length; + await sendKeys(spawned.child, ['s']); + await new Promise((r) => setTimeout(r, 300)); + const afterS1 = stripAnsi(stdoutBuf.slice(beforeLen)); + expect( + afterS1.includes('sort:tokens'), + `expected 'sort:tokens' header suffix after 1st s; got:\n${afterS1}`, + ).toBe(true); + + // 2nd `s` → name-asc. + beforeLen = stdoutBuf.length; + await sendKeys(spawned.child, ['s']); + await new Promise((r) => setTimeout(r, 300)); + const afterS2 = stripAnsi(stdoutBuf.slice(beforeLen)); + expect( + afterS2.includes('sort:name'), + `expected 'sort:name' header suffix after 2nd s; got:\n${afterS2}`, + ).toBe(true); + + // 3rd `s` → back to staleness-desc → suffix absent from the fresh frame. + beforeLen = stdoutBuf.length; + await sendKeys(spawned.child, ['s']); + await new Promise((r) => setTimeout(r, 300)); + const afterS3 = stripAnsi(stdoutBuf.slice(beforeLen)); + // The post-cycle frame must NOT carry an active `sort:tokens|name` suffix. + // We check the frame slice (not the full transcript which still contains prior suffixes). + expect( + !/sort:(tokens|name)/.test(afterS3), + `expected no sort suffix after 3rd s (back to default); got:\n${afterS3}`, + ).toBe(true); + + // Stability: 4 more presses = one full cycle + 1 extra, last = tokens-desc. + await sendKeys(spawned.child, ['s']); // tokens + await sendKeys(spawned.child, ['s']); // name + await sendKeys(spawned.child, ['s']); // staleness (default) + beforeLen = stdoutBuf.length; + await sendKeys(spawned.child, ['s']); // tokens again + await new Promise((r) => setTimeout(r, 300)); + const afterCycle = stripAnsi(stdoutBuf.slice(beforeLen)); + expect( + afterCycle.includes('sort:tokens'), + `expected stable cycle (tokens after 4 further 's' presses); got:\n${afterCycle}`, + ).toBe(true); + + // Tab to SKILLS, `s` there, switch back — AGENTS sort should remain at tokens (per-tab memory, D5-09). + beforeLen = stdoutBuf.length; + await sendKeys(spawned.child, ['\t']); // SKILLS + await new Promise((r) => setTimeout(r, 300)); + await sendKeys(spawned.child, ['s']); // SKILLS → tokens-desc + await new Promise((r) => setTimeout(r, 300)); + await sendKeys(spawned.child, ['\t']); // back to AGENTS + await new Promise((r) => setTimeout(r, 400)); + const afterBack = stripAnsi(stdoutBuf.slice(beforeLen)); + expect( + afterBack.includes('sort:tokens'), + `expected AGENTS to remember sort:tokens across tab switch; got:\n${afterBack}`, + ).toBe(true); + + // Cleanup. + await sendKeys(spawned.child, ['\x03']); + spawned.child.stdin!.end(); + const result = await spawned.done; + expect([0, 130, null].includes(result.exitCode)).toBe(true); + }, 40_000); +}); diff --git a/apps/ccaudit/src/__tests__/tabbed-picker-tab-nav-keys.test.ts b/apps/ccaudit/src/__tests__/tabbed-picker-tab-nav-keys.test.ts new file mode 100644 index 0000000..ebb7e3b --- /dev/null +++ b/apps/ccaudit/src/__tests__/tabbed-picker-tab-nav-keys.test.ts @@ -0,0 +1,243 @@ +/** + * Phase 3.1 — Tab-nav keys both-bindings integration test (SC2 belt-and-braces). + * + * Exercises the full interactive selection → bust → manifest-write pipeline + * TWICE — once with Tab (`'\t'`), once with ArrowRight (`'\x1b[C'`) — to prove + * that both forward tab-navigation bindings cycle tabs and that selection + * survives the tab switch all the way to the written manifest. + * + * Task 3 (in-source tabbed-picker.ts) already proves cross-tab state at the + * class level; this test is the end-to-end contract: the two bindings produce + * the same observable manifest shape, plus the symmetric Shift-Tab / ArrowLeft + * pair is transitively covered by Task 3's repeat-with-both-sequences + * assertion. + * + * Per-test flow: + * 1. Fixture: 2 agent files, 2 skill dirs with SKILL.md. Exactly two tabs open. + * 2. Spawn ghost --interactive with CCAUDIT_FORCE_TTY=1, LINES=24, COLUMNS=80. + * 3. Wait for picker ready (stdout contains `AGENTS (0/2)`). + * 4. Send key sequence: Space → {Tab | ArrowRight} → Space → Enter. + * 5. Confirmation prompt appears; send `y` + `\r`. + * 6. Await exit; assert exit code 0. + * 7. Read the single written manifest via @ccaudit/internal.readManifest and + * assert planned_ops.archive === 2 (one agent + one skill). + * + * ASCII-vs-Unicode: the subprocess sets NO_COLOR=1 so the picker uses the + * ASCII-fallback glyphs; this test doesn't assert on visual glyphs, only on + * the manifest contract — so ASCII/Unicode doesn't matter here. + */ +import { describe, it, expect, beforeAll, beforeEach, afterEach } from 'vitest'; +import { existsSync } from 'node:fs'; +import { mkdir, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { readManifest } from '@ccaudit/internal'; +import { + makeTmpHome, + cleanupTmpHome, + buildFakePs, + runCcauditGhost, + listManifestsDir, + sendKeys, +} from './_test-helpers.ts'; + +// ── Dist guard ───────────────────────────────────────────────────────────── + +const here = path.dirname(fileURLToPath(import.meta.url)); +const distPath = path.resolve(here, '..', '..', 'dist', 'index.js'); + +beforeAll(() => { + if (!existsSync(distPath)) { + throw new Error( + `dist binary not found at ${distPath}. Run \`pnpm -F ccaudit build\` before running this test.`, + ); + } +}); + +// ── Shared fixture builder ──────────────────────────────────────────────── + +/** + * Build a tiny two-tab fixture: 2 ghost agents + 2 ghost skills. + * The scanner will partition these into exactly two non-empty tabs + * (AGENTS, SKILLS), which is the minimum needed to exercise forward tab nav. + */ +async function buildTwoTabFixture(tmpHome: string): Promise { + await mkdir(path.join(tmpHome, '.claude', 'agents'), { recursive: true }); + await mkdir(path.join(tmpHome, '.claude', 'skills'), { recursive: true }); + await mkdir(path.join(tmpHome, '.config', 'claude'), { recursive: true }); + await writeFile(path.join(tmpHome, '.claude.json'), '{}', 'utf8'); + + // Minimal session jsonl. + const sessionDir = path.join(tmpHome, '.claude', 'projects', 'tab-nav-keys-project'); + await mkdir(sessionDir, { recursive: true }); + await writeFile( + path.join(sessionDir, 'session-1.jsonl'), + JSON.stringify({ + type: 'system', + subtype: 'init', + cwd: '/fake/tab-nav-keys', + timestamp: new Date(Date.now() - 60 * 60 * 1000).toISOString(), + sessionId: 'tab-nav-keys-session', + }) + '\n', + 'utf8', + ); + + // 2 agents — these become the AGENTS tab. + for (const name of ['a1', 'a2']) { + await writeFile(path.join(tmpHome, '.claude', 'agents', `${name}.md`), `# ${name}\n`, 'utf8'); + } + // 2 skills — directories with SKILL.md per the Skills schema. + for (const name of ['sk1', 'sk2']) { + const skillDir = path.join(tmpHome, '.claude', 'skills', name); + await mkdir(skillDir, { recursive: true }); + await writeFile( + path.join(skillDir, 'SKILL.md'), + `---\nname: ${name}\n---\n# ${name}\n`, + 'utf8', + ); + } +} + +/** + * Drive the picker + confirmation prompt with the provided `tabKey` as the + * forward-nav byte sequence. Returns the result of the subprocess run plus + * the written manifest's contents. + */ +async function runPickerWithTabKey( + tmpHome: string, + tabKey: string, +): Promise<{ + exitCode: number | null; + stdout: string; + stderr: string; + manifestBase: string; + manifest: Awaited>; +}> { + const spawned = runCcauditGhost(tmpHome, ['--interactive'], { + env: { CCAUDIT_FORCE_TTY: '1', LINES: '24', COLUMNS: '80' }, + timeout: 20_000, + }); + + // Accumulate stdout to detect picker readiness. + let stdoutBuf = ''; + spawned.child.stdout!.on('data', (c: Buffer) => { + stdoutBuf += c.toString(); + }); + + // Wait for the first frame: `AGENTS (0/2)` is emitted by the picker's + // per-tab header and only appears once the picker has rendered. + { + const maxWaitMs = 5_000; + const startMs = Date.now(); + let delayMs = 100; + while ( + spawned.child.exitCode === null && + !stdoutBuf.includes('AGENTS (0/2)') && + Date.now() - startMs < maxWaitMs + ) { + await new Promise((r) => setTimeout(r, delayMs)); + delayMs = Math.min(delayMs * 2, 500); + } + } + + // Send: Space, , Space, Enter. + await sendKeys(spawned.child, [' ', tabKey, ' ', '\r'], 120); + + // Wait for the confirmation prompt to render. runConfirmationPrompt writes + // the box and then `@clack/prompts.confirm` adds its `◆ Proceed with archive?` + // line. Detect readiness by polling for that line. + { + const maxWaitMs = 5_000; + const startMs = Date.now(); + let delayMs = 100; + while ( + spawned.child.exitCode === null && + !stdoutBuf.includes('Proceed with archive?') && + Date.now() - startMs < maxWaitMs + ) { + await new Promise((r) => setTimeout(r, delayMs)); + delayMs = Math.min(delayMs * 2, 500); + } + } + + // Send: y + Enter to confirm. + await sendKeys(spawned.child, ['y', '\r'], 120); + + // After the bust completes and ccaudit prints its success box, the Node + // event loop stays alive because @clack/core + @clack/prompts left a stdin + // keypress listener registered. Close stdin so the subprocess can drain + // and exit cleanly. Without this end() the subprocess hangs indefinitely + // and the test hits its vitest timeout (discovered in Task 4 first run). + spawned.child.stdin!.end(); + + const result = await spawned.done; + + const manifestsAfter = await listManifestsDir(tmpHome); + const jsonlManifests = manifestsAfter.filter((m) => m.endsWith('.jsonl')); + if (jsonlManifests.length !== 1) { + throw new Error( + `expected exactly 1 manifest, got ${jsonlManifests.length}: ${jsonlManifests.join(', ')}\n` + + `exitCode=${result.exitCode} confirmationReached=${result.stdout.includes('Proceed with archive?')}\n` + + `stdout:\n${result.stdout.slice(-1500)}\nstderr:\n${result.stderr.slice(-500)}`, + ); + } + const manifestBase = jsonlManifests[0]!; + const manifestPath = path.join(tmpHome, '.claude', 'ccaudit', 'manifests', manifestBase); + const manifest = await readManifest(manifestPath); + + return { + exitCode: result.exitCode, + stdout: result.stdout, + stderr: result.stderr, + manifestBase, + manifest, + }; +} + +// ── Tests ────────────────────────────────────────────────────────────────── +// Windows: fake `ps` shell scripts require /bin/sh; skip on win32. +describe.skipIf(process.platform === 'win32')( + 'Phase 3.1 — Tab-nav both bindings (Tab + ArrowRight) through the interactive bust pipeline', + () => { + let tmpHome: string; + + beforeEach(async () => { + tmpHome = await makeTmpHome(); + await buildTwoTabFixture(tmpHome); + await buildFakePs(tmpHome); + }); + + afterEach(async () => { + await cleanupTmpHome(tmpHome); + }); + + it("Tab key ('\\t') cycles tabs forward and produces a manifest with exactly 2 planned ops", async () => { + const { exitCode, manifest, stderr } = await runPickerWithTabKey(tmpHome, '\t'); + + expect(exitCode, `stderr:\n${stderr.slice(-500)}`).toBe(0); + expect(manifest.header).not.toBeNull(); + expect(manifest.header?.planned_ops.archive).toBe(2); + // Defensive: the actual op records should also number 2. + expect(manifest.ops.length).toBe(2); + // Narrow to archive ops (only ArchiveOp + SkippedOp have `category`); + // both planned ops are archives in this fixture. + const archiveOps = manifest.ops.filter((o) => o.op_type === 'archive'); + expect(archiveOps.length).toBe(2); + const cats = archiveOps.map((o) => o.category).sort(); + expect(cats).toEqual(['agent', 'skill']); + }, 25_000); + + it("ArrowRight key ('\\x1b[C') cycles tabs forward and produces a manifest with exactly 2 planned ops", async () => { + const { exitCode, manifest, stderr } = await runPickerWithTabKey(tmpHome, '\x1b[C'); + + expect(exitCode, `stderr:\n${stderr.slice(-500)}`).toBe(0); + expect(manifest.header).not.toBeNull(); + expect(manifest.header?.planned_ops.archive).toBe(2); + expect(manifest.ops.length).toBe(2); + const archiveOps = manifest.ops.filter((o) => o.op_type === 'archive'); + expect(archiveOps.length).toBe(2); + const cats = archiveOps.map((o) => o.category).sort(); + expect(cats).toEqual(['agent', 'skill']); + }, 25_000); + }, +); diff --git a/apps/ccaudit/src/__tests__/tabbed-picker-terminal-too-short.test.ts b/apps/ccaudit/src/__tests__/tabbed-picker-terminal-too-short.test.ts new file mode 100644 index 0000000..16556b2 --- /dev/null +++ b/apps/ccaudit/src/__tests__/tabbed-picker-terminal-too-short.test.ts @@ -0,0 +1,143 @@ +/** + * Phase 3.1 — Terminal-too-short gate integration test (D3.1-16). + * + * Spec: when the terminal is shorter than 14 rows, `ccaudit ghost --interactive` + * writes an exact stderr message and exits 1 BEFORE opening any prompt. + * The floor derives from the viewport formula + * `Math.max(8, (stdoutRows ?? 24) - 10)` — at 13 rows the chrome budget + * collapses and the tab bar / hints / row list cannot coexist. + * + * Mechanism choice: + * Under a piped-stdio subprocess, `process.stdout.rows` is always + * `undefined` regardless of the `LINES` env var (Node's readline/tty does + * NOT honour `LINES` for non-TTY streams). Rather than introduce a pty + * dependency (violates zero-runtime-deps invariant) or LD_PRELOAD a + * shim, this test uses the `CCAUDIT_TEST_STDOUT_ROWS` env var that + * select-ghosts.ts consults when resolving the stdoutRows gate input. + * The escape hatch is strictly test-only (never documented in --help), + * mirroring the CCAUDIT_FORCE_TTY pattern from Phase 3 (D-21). + * + * Assertions: + * 1. exitCode === 1 + * 2. stderr contains the exact D3.1-16 message (both halves). + * 3. No manifest file was written. + */ +import { describe, it, expect, beforeAll, beforeEach, afterEach } from 'vitest'; +import { existsSync } from 'node:fs'; +import { mkdir, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + makeTmpHome, + cleanupTmpHome, + runCcauditGhost, + listManifestsDir, + buildFakePs, +} from './_test-helpers.ts'; + +// ── Dist guard ───────────────────────────────────────────────────────────── + +const here = path.dirname(fileURLToPath(import.meta.url)); +const distPath = path.resolve(here, '..', '..', 'dist', 'index.js'); + +beforeAll(() => { + if (!existsSync(distPath)) { + throw new Error( + `dist binary not found at ${distPath}. Run \`pnpm -F ccaudit build\` before running this test.`, + ); + } +}); + +// ── Test ─────────────────────────────────────────────────────────────────── +// Phase 3.2 note: the entry preflight (runInteractiveGhostFlow at ghost.ts +// after the TTY guard) now runs `detectClaudeProcesses` BEFORE the picker +// opens. That means the fake-ps shim IS needed on platforms that lack a +// real `ps` under PATH=/bin; without it, the preflight fails +// closed with exit 2 ("Could not verify Claude Code is stopped…") before +// the D3.1-16 height gate inside selectGhosts can ever fire. The shim +// requires /bin/sh so we skip on Windows (same restriction as Phase 3). +describe.skipIf(process.platform === 'win32')( + 'Phase 3.1 — Terminal-too-short gate (D3.1-16): rows < 14 exits 1 with exact stderr', + () => { + let tmpHome: string; + + beforeEach(async () => { + tmpHome = await makeTmpHome(); + // Standard scaffold. + await mkdir(path.join(tmpHome, '.claude', 'agents'), { recursive: true }); + await mkdir(path.join(tmpHome, '.config', 'claude'), { recursive: true }); + await writeFile(path.join(tmpHome, '.claude.json'), '{}', 'utf8'); + // Session jsonl so discoverSessionFiles returns ≥1. + const sessionDir = path.join(tmpHome, '.claude', 'projects', 'short-term-project'); + await mkdir(sessionDir, { recursive: true }); + await writeFile( + path.join(sessionDir, 'session-1.jsonl'), + JSON.stringify({ + type: 'system', + subtype: 'init', + cwd: '/fake/short', + timestamp: new Date(Date.now() - 60 * 60 * 1000).toISOString(), + sessionId: 'short-session', + }) + '\n', + 'utf8', + ); + // ≥1 ghost so the adapter does not early-exit via + // { kind: 'empty-inventory' } before reaching the height gate. + await writeFile( + path.join(tmpHome, '.claude', 'agents', 'short-agent.md'), + '# short-agent\nNever invoked.\n', + 'utf8', + ); + // Phase 3.2 added an entry-time preflight that runs `ps` BEFORE + // selectGhosts (where the D3.1-16 height gate fires). Install the + // fake-ps shim so the preflight returns "clear" and execution + // reaches the height gate that this test is asserting on. + await buildFakePs(tmpHome); + }); + + afterEach(async () => { + await cleanupTmpHome(tmpHome); + }); + + it('exits 1 with the exact D3.1-16 stderr message and writes no manifest when rows=10', async () => { + // Baseline manifest directory — should remain empty throughout. + const baselineManifests = await listManifestsDir(tmpHome); + + const spawned = runCcauditGhost(tmpHome, ['--interactive'], { + env: { + CCAUDIT_FORCE_TTY: '1', + // LINES is set too so the intent is visible to a reader even + // though Node's non-TTY stdout ignores it. + LINES: '10', + COLUMNS: '80', + CCAUDIT_TEST_STDOUT_ROWS: '10', + }, + timeout: 5_000, + }); + + const result = await spawned.done; + + // Assertion 1: exit code is 1. + expect( + result.exitCode, + `expected exitCode=1, got ${result.exitCode}\nstderr:\n${result.stderr}`, + ).toBe(1); + + // Assertion 2a: first half of the exact D3.1-16 message (including "need ≥14 rows"). + expect(result.stderr).toContain('Terminal too short (need ≥14 rows, got 10)'); + + // Assertion 2b: second half (resize/bust hint). + expect(result.stderr).toContain( + 'Resize your terminal or use `--dangerously-bust-ghosts` non-interactively.', + ); + + // Assertion 3: no manifest was written. + const postManifests = await listManifestsDir(tmpHome); + const newManifests = postManifests.filter((m) => !baselineManifests.includes(m)); + expect( + newManifests, + `terminal-too-short gate violation: new manifest(s) appeared: ${newManifests.join(', ')}`, + ).toEqual([]); + }); + }, +); diff --git a/apps/ccaudit/src/__tests__/tmux-e2e-manual-qa.test.ts b/apps/ccaudit/src/__tests__/tmux-e2e-manual-qa.test.ts new file mode 100644 index 0000000..1784f1a --- /dev/null +++ b/apps/ccaudit/src/__tests__/tmux-e2e-manual-qa.test.ts @@ -0,0 +1,287 @@ +/** + * Optional tmux-backed E2E coverage for manual-QA-style picker flows. + * + * These tests are intentionally opt-in because they require tmux and exercise a + * real terminal pane. Run locally with: + * + * CCAUDIT_TMUX_E2E=1 pnpm --filter ccaudit-cli test -- tmux-e2e-manual-qa + * + * They complement (but do not replace) human-only checks from + * ccaudit-manual-tests.txt such as macOS Terminal.app physical resize and + * GitHub README rendering. + */ +import { describe, it, expect, beforeAll } from 'vitest'; +import { execFileSync } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { readdir } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + cleanupTmpHome, + makeTmpHome, + runCcauditCli, + stageRestoreInteractiveFixture, +} from './_test-helpers.ts'; +import { + stageGlyphFixture, + stageInteractiveBustFixture, + stagePaginationFixture, +} from './fixtures/manual-qa-followups.ts'; +import { startTmuxE2E, TMUX_KEYS, type TmuxE2ESession } from './fixtures/tmux-e2e.ts'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const distPath = path.resolve(here, '..', '..', 'dist', 'index.js'); +const optIn = process.env['CCAUDIT_TMUX_E2E'] === '1'; +const tmuxAvailable = (() => { + if (!optIn || process.platform === 'win32') return false; + try { + execFileSync('tmux', ['-V'], { stdio: 'ignore' }); + return true; + } catch { + return false; + } +})(); + +function sessionName(suffix: string): string { + return `ccaudit-${suffix}-${process.pid}-${Date.now()}`; +} + +function baseEnv(tmpHome: string, extra: Record = {}): Record { + return { + HOME: tmpHome, + USERPROFILE: tmpHome, + XDG_CONFIG_HOME: path.join(tmpHome, '.config'), + TZ: 'UTC', + COLUMNS: '120', + LINES: '30', + ...extra, + }; +} + +async function cleanup(session: TmuxE2ESession | null, tmpHome: string): Promise { + if (session !== null) await session.kill(); + await cleanupTmpHome(tmpHome); +} + +describe.skipIf(process.platform === 'win32' || !optIn || !tmuxAvailable)( + 'tmux E2E manual-QA coverage (opt-in)', + () => { + beforeAll(() => { + if (!existsSync(distPath)) { + throw new Error( + `dist binary not found at ${distPath}. Run \`pnpm -F ccaudit build\` before running this test.`, + ); + } + }); + + it('Phase 8.1 R1/R2: restore picker shows MEMORY tab and restore footer wording', async () => { + const tmpHome = await makeTmpHome(); + let session: TmuxE2ESession | null = null; + try { + await stageRestoreInteractiveFixture(tmpHome); + session = await startTmuxE2E({ + name: sessionName('restore-picker'), + tmpDir: tmpHome, + cwd: process.cwd(), + width: 140, + height: 35, + command: [process.execPath, distPath, 'restore', '--interactive'], + env: baseEnv(tmpHome, { NO_COLOR: '1' }), + }); + + const initial = await session.waitForText('AGENTS │ MEMORY'); + expect(initial).toContain('0 selected · 4 archived'); + + await session.sendKeys([TMUX_KEYS.right]); + const memoryTab = await session.waitForText('MEMORY (0/1)'); + expect(memoryTab).toContain('CLAUDE.md'); + } finally { + await cleanup(session, tmpHome); + } + }, 20_000); + + it('Phase 9 D1/F1-partial: large picker scrolls and survives tmux resize', async () => { + const tmpHome = await makeTmpHome(); + let session: TmuxE2ESession | null = null; + try { + await stagePaginationFixture(tmpHome, 550); + session = await startTmuxE2E({ + name: sessionName('pagination'), + tmpDir: tmpHome, + cwd: process.cwd(), + width: 120, + height: 30, + command: [process.execPath, distPath, 'ghost', '-i'], + env: baseEnv(tmpHome), + }); + + await session.waitForText('AGENTS (0/550)'); + await session.sendKeys( + Array.from({ length: 80 }, () => TMUX_KEYS.down), + { delayMs: 5 }, + ); + const scrolled = await session.capture({ startLine: -80 }); + expect(scrolled).toContain('agent-081'); + expect(scrolled).toContain('↓'); + + await session.resize(80, 15); + await session.sendKeys([TMUX_KEYS.down]); + await session.resize(120, 30); + await session.sendKeys([TMUX_KEYS.space]); + const resized = await session.capture({ startLine: -80 }); + expect(resized).toContain('AGENTS'); + expect(resized).toContain('selected across all tabs'); + expect(await session.isAlive()).toBe(true); + } finally { + await cleanup(session, tmpHome); + } + }, 20_000); + + it('Phase 9 E1: renders protected, multi-config MCP, and stale-memory glyph states', async () => { + const tmpHome = await makeTmpHome(); + let session: TmuxE2ESession | null = null; + try { + const { projectRoot } = await stageGlyphFixture(tmpHome); + session = await startTmuxE2E({ + name: sessionName('glyphs'), + tmpDir: tmpHome, + cwd: projectRoot, + width: 140, + height: 35, + command: [process.execPath, distPath, 'ghost', '-i'], + env: baseEnv(tmpHome), + }); + + const agents = await session.waitForText('gsd-researcher'); + expect(agents).toContain('🔒'); + expect(agents).toContain('◯'); + + await session.sendKeys([TMUX_KEYS.right]); + const mcp = await session.waitForText('MCP SERVERS'); + expect(mcp).toContain('⚠'); + expect(mcp).toContain('Also in:'); + + await session.sendKeys([TMUX_KEYS.right]); + const memory = await session.waitForText('MEMORY'); + expect(memory).toContain('CLAUDE.md'); + expect(memory).toContain('⌛'); + } finally { + await cleanup(session, tmpHome); + } + }, 20_000); + + it('Phase 9 H2: interactive archive via tmux then restore by name round-trips', async () => { + const tmpHome = await makeTmpHome(); + let session: TmuxE2ESession | null = null; + try { + await stageInteractiveBustFixture(tmpHome); + session = await startTmuxE2E({ + name: sessionName('h2'), + tmpDir: tmpHome, + cwd: process.cwd(), + width: 120, + height: 30, + command: [process.execPath, distPath, 'ghost', '-i'], + env: baseEnv(tmpHome, { + PATH: `${path.join(tmpHome, 'bin')}:${process.env['PATH'] ?? ''}`, + NO_COLOR: '1', + }), + }); + + await session.waitForText('h2-solo'); + await session.sendKeys([TMUX_KEYS.space, TMUX_KEYS.enter]); + await session.waitForText('Proceed with archive?'); + await session.sendKeys([TMUX_KEYS.left, TMUX_KEYS.enter]); + await session.waitForText('__CCAUDIT_TMUX_EXIT:0__', { timeoutMs: 10_000 }); + + expect(existsSync(path.join(tmpHome, '.claude', 'agents', 'h2-solo.md'))).toBe(false); + const archivedFiles = await readdir( + path.join(tmpHome, '.claude', 'ccaudit', 'archived', 'agents'), + ); + expect(archivedFiles).toContain('h2-solo.md'); + + const restored = await runCcauditCli(tmpHome, ['restore', '--name', 'h2-solo'], { + env: { NO_COLOR: '1' }, + }); + expect(restored.exitCode, `stderr:\n${restored.stderr}\nstdout:\n${restored.stdout}`).toBe( + 0, + ); + expect(existsSync(path.join(tmpHome, '.claude', 'agents', 'h2-solo.md'))).toBe(true); + } finally { + await cleanup(session, tmpHome); + } + }, 30_000); + + it('Phase 9 D2: Esc clears an active filter without exiting the picker', async () => { + const tmpHome = await makeTmpHome(); + let session: TmuxE2ESession | null = null; + try { + await stagePaginationFixture(tmpHome, 550); + session = await startTmuxE2E({ + name: sessionName('filter-esc'), + tmpDir: tmpHome, + cwd: process.cwd(), + width: 120, + height: 30, + command: [process.execPath, distPath, 'ghost', '-i'], + env: baseEnv(tmpHome), + }); + + await session.waitForText('AGENTS (0/550)'); + await session.sendKeys( + Array.from({ length: 80 }, () => TMUX_KEYS.down), + { delayMs: 5 }, + ); + expect(await session.capture({ startLine: 0 })).toContain('agent-081'); + + await session.sendKeys(['/']); + await session.sendLiteral('agent-09'); + await session.sendKeys([TMUX_KEYS.enter]); + await session.waitForText('Filtered: 10 of 550 visible'); + + await session.sendKeys([TMUX_KEYS.escape]); + const cleared = await session.waitForText('selected across all tabs'); + expect(await session.isAlive()).toBe(true); + const current = await session.capture({ startLine: 0 }); + expect(current).not.toContain('Filtered: 10 of 550 visible'); + expect(cleared).toContain('agent-081'); + + await session.sendKeys(['s']); + expect(await session.isAlive()).toBe(true); + expect(await session.capture({ startLine: 0 })).toContain('sort:tokens'); + } finally { + await cleanup(session, tmpHome); + } + }, 25_000); + + it('Phase 9 E4: ? help overlay includes a glyph legend', async () => { + const tmpHome = await makeTmpHome(); + let session: TmuxE2ESession | null = null; + try { + const { projectRoot } = await stageGlyphFixture(tmpHome); + session = await startTmuxE2E({ + name: sessionName('help-glyphs'), + tmpDir: tmpHome, + cwd: projectRoot, + width: 140, + height: 35, + command: [process.execPath, distPath, 'ghost', '-i'], + env: baseEnv(tmpHome), + }); + + await session.waitForText('gsd-researcher'); + await session.sendKeys(['?']); + const help = await session.waitForText('Glyphs'); + expect(help).toContain('Selected'); + expect(help).toContain('Unselected'); + expect(help).toContain('Protected / framework-locked'); + expect(help).toContain('Multi-config MCP server'); + expect(help).toContain('Stale memory file'); + expect(help).toContain('⌛'); + } finally { + await cleanup(session, tmpHome); + } + }, 20_000); + it.todo('Phase 9 F1/F2/F3: true macOS Terminal.app resize still needs human QA'); + }, +); diff --git a/apps/ccaudit/src/cli/_shared-args.ts b/apps/ccaudit/src/cli/_shared-args.ts index 9eac561..6e5140d 100644 --- a/apps/ccaudit/src/cli/_shared-args.ts +++ b/apps/ccaudit/src/cli/_shared-args.ts @@ -7,12 +7,21 @@ * The authoritative runtime source is initColor() in @ccaudit/terminal/color.ts, * which reads process.argv directly for root-level positioning robustness (per D-07). * Both sources agree because gunshi parsing does not modify process.argv. + * + * IMPORTANT: use camelCase internal keys plus `toKebab: true` for the public + * `--no-*` flags instead of either: + * 1) gunshi's `negatable: true` (renders `Negatable of --...`), or + * 2) literal `no-*` arg keys (renderer strips the prefix and prints + * placeholder text like `color` / `group-frameworks`). + * + * `noColor` + `toKebab: true` still exposes the documented `--no-color` flag, + * but avoids both gunshi help-rendering defects. */ export const outputArgs = { quiet: { type: 'boolean' as const, short: 'q', - description: 'Machine-readable output only (suppress decorative text)', + description: 'Machine-readable only', default: false, }, csv: { @@ -22,17 +31,19 @@ export const outputArgs = { }, ci: { type: 'boolean' as const, - description: 'CI mode: --json --quiet with exit codes (implies --json --quiet)', + description: 'CI: --json --quiet', default: false, }, - 'no-color': { + noColor: { type: 'boolean' as const, - description: 'Disable ANSI colors in output (also respects NO_COLOR env var)', + toKebab: true, + description: 'Disable ANSI colors (NO_COLOR too)', default: false, }, - 'no-group-frameworks': { + noGroupFrameworks: { type: 'boolean' as const, - description: 'Disable framework grouping — output reverts to v1.2.1 layout', + toKebab: true, + description: 'Disable framework grouping', default: false, }, } as const; @@ -69,39 +80,41 @@ if (import.meta.vitest) { }); }); - it('ci description mentions CI mode', () => { + it('ci description mentions CI', () => { expect((outputArgs as Record).ci.description).toContain( - 'CI mode', + 'CI', ); }); - it('has no-color key with type boolean and default false', () => { - expect(outputArgs).toHaveProperty('no-color'); - expect((outputArgs as Record)['no-color']).toMatchObject({ + it('has noColor key with type boolean, toKebab, and default false', () => { + expect(outputArgs).toHaveProperty('noColor'); + expect((outputArgs as Record).noColor).toMatchObject({ type: 'boolean', + toKebab: true, default: false, }); }); - it('no-color description mentions NO_COLOR env var', () => { - const desc = (outputArgs as Record)['no-color'].description; + it('noColor description mentions NO_COLOR env var', () => { + const desc = (outputArgs as Record).noColor.description; expect(typeof desc).toBe('string'); expect(desc).toContain('NO_COLOR'); }); - it('has no-group-frameworks key with type boolean and default false', () => { - expect(outputArgs).toHaveProperty('no-group-frameworks'); - expect((outputArgs as Record)['no-group-frameworks']).toMatchObject({ + it('has noGroupFrameworks key with type boolean, toKebab, and default false', () => { + expect(outputArgs).toHaveProperty('noGroupFrameworks'); + expect((outputArgs as Record).noGroupFrameworks).toMatchObject({ type: 'boolean', + toKebab: true, default: false, }); }); - it('no-group-frameworks description mentions v1.2.1 layout', () => { - const desc = (outputArgs as Record)['no-group-frameworks'] + it('noGroupFrameworks description mentions framework grouping', () => { + const desc = (outputArgs as Record).noGroupFrameworks .description; expect(typeof desc).toBe('string'); - expect(desc).toContain('v1.2.1'); + expect(desc).toContain('framework grouping'); }); }); } diff --git a/apps/ccaudit/src/cli/commands/ghost.ts b/apps/ccaudit/src/cli/commands/ghost.ts index 9c1a986..a48e375 100644 --- a/apps/ccaudit/src/cli/commands/ghost.ts +++ b/apps/ccaudit/src/cli/commands/ghost.ts @@ -1,6 +1,7 @@ import { rename, mkdir, readFile } from 'node:fs/promises'; import { existsSync } from 'node:fs'; import { platform as osPlatform, homedir } from 'node:os'; +import { dirname } from 'node:path'; import { define } from 'gunshi'; import { recordHistory } from '@ccaudit/internal'; import type { CommandResult } from '@ccaudit/internal'; @@ -24,6 +25,8 @@ import { calculateUrgencyScore, classifyRecommendation, buildChangePlan, + filterChangePlan, + calculateDryRunSavings, computeGhostHash, writeCheckpoint, resolveCheckpointPath, @@ -34,11 +37,15 @@ import { resolveManifestPath, patchFrontmatter, atomicWriteJson, + atomicWriteText, defaultProcessDeps, + detectClaudeProcesses, + walkParentChain, applyFrameworkProtection, detectClaudeCodeVersion, resolveMcpRegime, CONTEXT_WINDOW_SIZE, + isNoInteractiveEnv, } from '@ccaudit/internal'; import type { InvocationRecord, @@ -73,11 +80,514 @@ import { renderGhostOutputBox, renderHooksAdvisory, colorize, + checkTuiGuards, + shouldUseAscii, + selectGhosts, + runConfirmationPrompt, + promptAutoOpen, + renderRunningProcessMessage, + runPreflightRetryLoop, + type RunningProcessInput, } from '@ccaudit/terminal'; import { outputArgs } from '../_shared-args.ts'; import { resolveOutputMode, buildJsonEnvelope } from '../_output-mode.ts'; import { CCAUDIT_VERSION } from '../../_version.ts'; +// --------------------------------------------------------------------------- +// TEST-ONLY: CCAUDIT_TEST_PREFLIGHT_DIRTY= wraps ProcessDetectorDeps.runCommand +// so the first N process-listing invocations (`ps -A ...` on Unix, `tasklist` +// on Windows) return synthetic "one claude pid" output; subsequent calls +// delegate to the real detector. Used by BOTH the interactive flow +// (bustDeps.processDetector + CLI retry-loop detectFn) and the non-interactive +// --dangerously-bust-ghosts bust path (runBust's internal preflight) so ONE +// env value drives every preflight layer. Regex-guarded (/^\d+$/) — not +// documented in --help, README, or CHANGELOG. Mirrors the CCAUDIT_FORCE_TTY +// pattern. +// +// A per-invocation counter is required so that within a single subprocess +// execution the first N calls fake and subsequent calls delegate; each CLI +// invocation builds a fresh wrapper with a fresh counter. +// --------------------------------------------------------------------------- +function buildWrappedProcessDeps(): typeof defaultProcessDeps { + const dirtyRaw = process.env['CCAUDIT_TEST_PREFLIGHT_DIRTY']; + const dirtyCount = dirtyRaw && /^\d+$/.test(dirtyRaw) ? Number.parseInt(dirtyRaw, 10) : 0; + if (dirtyCount <= 0) return defaultProcessDeps; + let dirtyRemaining = dirtyCount; + return { + runCommand: async (cmd: string, args: string[], timeoutMs: number): Promise => { + // Only fake the process-listing commands (`ps -A ...` on Unix, + // `tasklist /FO ...` on Windows). All other runCommand calls + // (e.g., `ps -o ppid=` used by getParentPid) delegate to the real + // runCommand so walkParentChain continues to work correctly against + // the real process tree. + const isListCmd = + (cmd === 'ps' && args[0] === '-A') || (cmd === 'tasklist' && args.includes('/FO')); + if (isListCmd && dirtyRemaining > 0) { + dirtyRemaining -= 1; + // Diagnostic marker (B2): proves the hook fired inside runBust's + // preflight. Integration test asserts this appears at least N times. + process.stderr.write(`[PREFLIGHT_DIRTY] synthetic dirty #${dirtyCount - dirtyRemaining}\n`); + if (cmd === 'ps') { + // Unix ps -A -o pid=,comm= shape: " " + return ' 99999 claude\n'; + } + // Windows tasklist /FO CSV /NH shape: quoted CSV row. + return '"claude.exe","99999","Console","1","45,000 K"\r\n'; + } + return defaultProcessDeps.runCommand(cmd, args, timeoutMs); + }, + getParentPid: defaultProcessDeps.getParentPid, + platform: defaultProcessDeps.platform, + }; +} + +// --------------------------------------------------------------------------- +// buildInteractivePickerFeed — Phase 6 (D6-01 / D6-09 runtime wiring) +// +// `applyFrameworkProtection` strips framework-protected ghosts out of +// `filtered` when --force-partial is OFF. Phase 6 wants those rows to +// still RENDER in the picker (dimmed + [🔒]) so the user understands +// why they cannot be selected. This helper merges the protected items +// back into the picker feed and attaches `InventoryItem.protection` to +// each merged item so `isProtected()` returns true and the toggle guard +// blocks selection. The server-side INV-S6 gate (hash-matched checkpoint) +// still enforces correctness regardless of UI behavior. +// +// When `--force-partial` is ON, `filtered` already contains every ghost +// (applyFrameworkProtection pass-through), so we return it unchanged — +// no protected row is locked in that mode by design (D6-13). +// +// When `groupFrameworks` is false, no protection grouping runs upstream, +// so this helper is a pass-through filter for tier !== 'used'. +// --------------------------------------------------------------------------- +function buildInteractivePickerFeed( + interactiveProtection: FrameworkBustResult, + enriched: TokenCostResult[], + groupFrameworks: boolean, +): TokenCostResult[] { + const filtered = interactiveProtection.filtered.filter((r) => r.tier !== 'used'); + if (!groupFrameworks || interactiveProtection.protectedItems.length === 0) { + return filtered; + } + + // Recompute the per-path protection annotation from the same grouping + // applyFrameworkProtection used internally. Reuse toGhostItems/groupByFramework + // so behavior stays in lockstep with scanner annotate.ts without exposing new + // API surface from the remediation package. + const ghostItems = toGhostItems(enriched); + const grouped = groupByFramework(ghostItems); + const protectionByPath = new Map< + string, + { framework: string; total: number; ghostCount: number; reason: string } + >(); + for (const fw of grouped.frameworks) { + if (fw.status !== 'partially-used') continue; + const ghostCount = fw.totals.likelyGhost + fw.totals.definiteGhost; + const reason = `Part of ${fw.displayName} (${fw.totals.used} used, ${ghostCount} ghost). --force-partial to override.`; + const protection = { + framework: fw.id, + total: fw.totals.defined, + ghostCount, + reason, + }; + for (const m of fw.members) protectionByPath.set(m.path, protection); + } + + const annotatedProtected = interactiveProtection.protectedItems.map((r) => { + const p = protectionByPath.get(r.item.path); + if (p === undefined) return r; + return { ...r, item: { ...r.item, protection: p } }; + }); + + // Preserve filtered ordering, then append protected items (stable). + return [...filtered, ...annotatedProtected]; +} + +// --------------------------------------------------------------------------- +// runInteractiveGhostFlow — private module helper (Plan 03, D-05..D-21, D-26) +// +// Orchestrates the full interactive archive flow on a TTY: +// guard check → empty-inventory short-circuit → checkpoint write → +// selectGhosts picker → runConfirmationPrompt → runBust (ceremony skipped) +// +// Plan 04 will add a SECOND call site (the auto-open 'y' branch). This +// function MUST remain module-scoped — do NOT inline or nest inside run(ctx). +// --------------------------------------------------------------------------- +async function runInteractiveGhostFlow(args: { + /** Full enriched inventory (all tiers) from the outer scan+enrich step. */ + enriched: TokenCostResult[]; + /** Pre-filtered to tier !== 'used' (ghost items only). */ + ghosts: TokenCostResult[]; + /** Resolved since window string (e.g. "7d") for checkpoint + BustDeps. */ + sinceStr: string; + /** Resolved regime flag ('eager' | 'deferred' | 'auto'). */ + regimeFlag: McpRegime | 'auto'; + /** Claude Code version detected at startup (null if regime != 'auto'). */ + detectedCcVersion: string | null; + /** The output mode object resolved by resolveOutputMode, plus raw ci flag. */ + mode: { + json: boolean; + csv: boolean; + quiet: boolean; + /** Raw --ci flag value (not absorbed into mode.json/mode.quiet yet for guard check). */ + ci: boolean; + groupFrameworks: boolean; + verbose: boolean; + privacy: boolean; + }; + /** Whether --force-partial was passed. */ + forcePartial: boolean; + /** Pre-resolved MCP regime (computed by caller after enrichScanResults). */ + resolvedRegime: McpRegime; + /** Pre-computed worst-case session overhead for checkpoint.total_overhead. */ + totalOverhead: number; +}): Promise { + const { + enriched, + ghosts, + sinceStr, + regimeFlag, + detectedCcVersion, + mode, + forcePartial, + resolvedRegime, + totalOverhead, + } = args; + + // ── Guard: narrow terminal (D-08, D-23) ────────────────────────────────── + // Note: non-TTY + explicit --interactive is already handled by the caller + // (which sets effectiveDryRun=true and falls through). If we somehow reach + // here with a non-TTY, the guard will catch it defensively. + // TEST-ONLY: CCAUDIT_FORCE_TTY=1 — see ghost.ts Site A in run() for full rationale. + const forceTty = process.env['CCAUDIT_FORCE_TTY'] === '1'; + const isTty = forceTty || (Boolean(process.stdout.isTTY) && Boolean(process.stdin.isTTY)); + const ttyCols = process.stdout.columns; + const guard = checkTuiGuards({ + mode: { + json: mode.json, + csv: mode.csv, + quiet: mode.quiet, + ci: mode.ci, + dryRun: false, // interactive path already bypassed dry-run branch + dangerouslyBustGhosts: false, // interactive path is distinct + }, + isTty, + ttyCols, + isExplicitInteractive: true, + }); + + switch (guard.kind) { + case 'hard-error': + // Unreachable: D-06 is caught before the scan at the flag-parse stage. + console.error(guard.message); + process.exitCode = guard.exitCode; + return; + case 'fallback-dry-run': + // Non-TTY path — caller should have handled this, but defensive guard. + console.error(guard.reason); + return; + case 'refuse-narrow': + console.error(guard.message); + process.exitCode = 1; + return; + case 'suppress-auto-open': + // Unreachable when isExplicitInteractive=true — defensive. + return; + case 'ok': + break; + } + + // TEST-ONLY wrapped detectors — see buildWrappedProcessDeps() docstring. + // CCAUDIT_TEST_PREFLIGHT_DIRTY= gives EACH layer its own counter of N + // synthetic dirty calls. `entryProcessDeps` drives the entry preflight; + // `bustProcessDeps` drives bustDeps.processDetector AND the bust-time CLI + // retry loop's detectFn. Separate counters are required so exhausting the + // entry counter does not starve the bust-time layer — the SC5b integration + // test exercises BOTH layers in a single subprocess run. + const entryProcessDeps = buildWrappedProcessDeps(); + const bustProcessDeps = buildWrappedProcessDeps(); + + // ── Phase 3.2 SC4: running-Claude preflight BEFORE picker opens ─────── + // Mirrors the preflight that runBust runs at bust.ts:265, hoisted to run + // BEFORE the user invests selection time. Also determines self-invocation + // via walkParentChain (same logic as bust.ts:274) so the entry retry loop + // can short-circuit cleanly when ccaudit is spawned from inside Claude. + { + // DIRTY-counter accounting (CCAUDIT_TEST_PREFLIGHT_DIRTY): this initial + // detectClaudeProcesses call consumes ONE counter tick on entryProcessDeps; + // each user-confirmed retry inside runPreflightRetryLoop consumes one more. + const detected = await detectClaudeProcesses(process.pid, entryProcessDeps); + let initialResult: RunningProcessInput | undefined; + if (detected.status === 'spawn-failed') { + // Fail-closed (D-02 invariant, matches bust.ts:268): cannot verify → refuse. + console.error(`Could not verify Claude Code is stopped: ${detected.error}`); + console.error('Run from a clean shell where ps (Unix) or tasklist (Windows) is available.'); + process.exitCode = 2; + return; + } + if (detected.processes.length > 0) { + const chain = await walkParentChain(process.pid, entryProcessDeps); + const detectedPids = new Set(detected.processes.map((p) => p.pid)); + const selfInvocation = chain.some((p) => detectedPids.has(p)); + initialResult = { + selfInvocation, + pids: detected.processes.map((p) => p.pid), + }; + } + if (initialResult !== undefined) { + const outcome = await runPreflightRetryLoop({ + detectFn: () => detectClaudeProcesses(process.pid, entryProcessDeps), + phase: 'entry', + initialResult, + }); + if (outcome.status === 'cancelled') { + console.error('No changes made.'); + return; // exit 0 — INV-S2 compatible: no checkpoint written yet + } + if (outcome.status === 'spawn-failed') { + console.error(`Could not verify Claude Code is stopped: ${outcome.error}`); + console.error('Run from a clean shell where ps (Unix) or tasklist (Windows) is available.'); + process.exitCode = 2; + return; + } + // outcome.status === 'clear' → Claude was closed during the retry prompt. + // Fall through to normal flow. + } + // detected.processes.length === 0 → preflight clear; fall through. + } + // ──────────────────────────────────────────────────────────────────────── + + // ── D-13: empty inventory → skip picker entirely ────────────────────────── + if (ghosts.length === 0) { + console.log('✅ No ghosts found. Your inventory is clean.'); + return; + } + + // ── Write dry-run checkpoint BEFORE opening picker (D-26) ──────────────── + // Apply framework protection first so the checkpoint hash matches runBust. + const interactiveProtection = applyFrameworkProtection(enriched, { + forcePartial, + groupFrameworks: mode.groupFrameworks, + }); + const interactivePlan = buildChangePlan(interactiveProtection.filtered); + const ghostHash = await computeGhostHash(interactiveProtection.filtered); + + const checkpoint = { + checkpoint_version: 1 as const, + ccaudit_version: CCAUDIT_VERSION, + timestamp: new Date().toISOString(), + since_window: sinceStr, + ghost_hash: ghostHash, + item_count: interactivePlan.counts, + savings: interactivePlan.savings, + total_overhead: totalOverhead, + mcp_regime: resolvedRegime, + cc_version: detectedCcVersion, + }; + + try { + await writeCheckpoint(checkpoint, resolveCheckpointPath()); + } catch (err) { + console.error(`[ccaudit] Failed to write checkpoint: ${(err as Error).message}`); + process.exitCode = 2; + return; + } + + // ── Open picker (D-02, D-09..D-13) ─────────────────────────────────────── + const useAscii = shouldUseAscii(process.env, process.stdout, ttyCols); + // Only ghost-tier items go into the picker (D-11). Phase 6 (D6-01/D6-09): + // when --force-partial is OFF, also render protected rows (dimmed + [🔒]) + // so users can see WHY the rows cannot be selected. The picker's toggle + // guard prevents selection; the server-side INV-S6 gate (hash-matched + // checkpoint written above) is the real enforcer. + const pickerGhosts = buildInteractivePickerFeed( + interactiveProtection, + enriched, + mode.groupFrameworks, + ); + const pickOutcome = await selectGhosts({ + ghosts: pickerGhosts, + now: Date.now(), + useAscii, + forcePartial, + }); + + if (pickOutcome.kind === 'cancel') { + console.error('No changes made.'); + return; // exit 0 (D-08) + } + if (pickOutcome.kind === 'empty-inventory') { + console.log('✅ No ghosts found. Your inventory is clean.'); + return; + } + + const selectedItems = pickOutcome.ids; + if (selectedItems.size === 0) { + console.error('No changes made.'); + return; + } + + // ── Confirmation screen + prompt (D-17..D-21, boolean-only per D-21) ────── + const filteredPlan = filterChangePlan(interactivePlan, selectedItems); + const estSavings = calculateDryRunSavings(filteredPlan); + const manifestDir = dirname(resolveManifestPath()); + + const confirmOutcome = await runConfirmationPrompt({ + plan: filteredPlan, + estSavings, + manifestDir, + useAscii, + }); + + // v0.5: ConfirmationOutcome is { kind: 'proceed' } | { kind: 'cancel' } only (D-21). + if (confirmOutcome.kind === 'cancel') { + console.error('No changes made.'); + return; // exit 0 (D-08) + } + // confirmOutcome.kind === 'proceed' — fall through to bust. + + // ── Execute bust with ceremony skipped (D-19, D-20) ────────────────────── + const sinceMs = parseDuration(sinceStr); + const bustDeps: BustDeps = { + readCheckpoint, + checkpointPath: () => resolveCheckpointPath(), + scanAndEnrich: async () => { + const sessionFiles = await discoverSessionFiles({ sinceMs }); + const invocations: InvocationRecord[] = []; + const projPaths = new Set(); + for (const file of sessionFiles) { + const r2 = await parseSession(file, sinceMs); + invocations.push(...r2.invocations); + if (r2.meta.projectPath) projPaths.add(r2.meta.projectPath); + } + const { results: scanResults } = await scanAll(invocations, { + projectPaths: [...projPaths], + }); + const rawEnriched = await enrichScanResults(scanResults, { + regime: regimeFlag, + ccVersion: detectedCcVersion, + }); + const protection = applyFrameworkProtection(rawEnriched, { + forcePartial, + groupFrameworks: mode.groupFrameworks, + }); + return protection.filtered; + }, + computeHash: (e) => computeGhostHash(e), + processDetector: bustProcessDeps, + selfPid: process.pid, + // runCeremony is unused when skipCeremony=true, but the dep is required + // by BustDeps shape. Provide a no-op to satisfy the interface. + runCeremony: async () => ({ status: 'accepted' as const }), + renameFile: async (from, to) => { + await rename(from, to); + }, + mkdirRecursive: async (dir, modeArg) => { + await mkdir(dir, { recursive: true, mode: modeArg }); + }, + readFileUtf8: (p) => readFile(p, 'utf8'), + patchMemoryFrontmatter: patchFrontmatter, + atomicWriteJson: (target, value) => atomicWriteJson(target, value), + atomicWriteText: (target, text) => atomicWriteText(target, text), + pathExistsSync: existsSync, + createManifestWriter: (p) => new ManifestWriter(p), + manifestPath: () => resolveManifestPath(), + now: () => new Date(), + ccauditVersion: CCAUDIT_VERSION, + nodeVersion: process.version, + sinceWindow: sinceStr, + os: osPlatform(), + }; + + let result = await runBust({ + yes: true, + deps: bustDeps, + selectedItems, + skipCeremony: true, + }); + + // ── Phase 3.2 SC5b: bust-time running-process retry loop ────────────────── + // runBust still runs the authoritative preflight at bust.ts:264-286; if it + // returns { status: 'running-process' }, we reuse the shared retry helper + // and re-invoke runBust with the SAME selectedItems Set — identity preserved + // across every retry, no picker re-open. Self-invocation short-circuits via + // runPreflightRetryLoop (closing the parent session would kill ccaudit). + while (result.status === 'running-process') { + const retryOutcome = await runPreflightRetryLoop({ + detectFn: () => detectClaudeProcesses(process.pid, bustProcessDeps), + phase: 'bust', + initialResult: { selfInvocation: result.selfInvocation, pids: result.pids }, + }); + if (retryOutcome.status === 'cancelled') { + console.error('No changes made.'); + return; // exit 0 — no checkpoint mutation, selectedItems still valid + } + if (retryOutcome.status === 'spawn-failed') { + console.error(`Could not verify Claude Code is stopped: ${retryOutcome.error}`); + console.error('Run from a clean shell where ps (Unix) or tasklist (Windows) is available.'); + process.exitCode = 2; + return; + } + // retryOutcome.status === 'clear' → re-invoke runBust with SAME selectedItems. + result = await runBust({ + yes: true, + deps: bustDeps, + selectedItems, // <-- identity preserved (SC5b) + skipCeremony: true, + }); + } + + // ── D-26: hash-mismatch is terminal informational (no [y/N] prompt) ─────── + if (result.status === 'hash-mismatch') { + console.error('Filesystem changed since scan. Re-run ccaudit ghost --interactive to re-scan.'); + console.error('No changes made.'); + return; // exit 0 — informational, not an error (D-26) + } + + // ── Render outcome ──────────────────────────────────────────────────────── + if (result.status === 'success' && !mode.quiet) { + const displayManifestPath = mode.privacy + ? result.manifestPath.replace(homedir(), '~') + : result.manifestPath; + console.log(''); + console.log( + renderShareableBlock({ + beforeTokens: result.summary.beforeTokens, + afterTokens: result.summary.afterTokens, + freedTokens: result.summary.freedTokens, + pctWindow: result.summary.pctWindow, + healthBefore: result.summary.healthBefore, + healthAfter: result.summary.healthAfter, + gradeBefore: result.summary.gradeBefore, + gradeAfter: result.summary.gradeAfter, + counts: { + archivedAgents: result.counts.archive.agents, + archivedSkills: result.counts.archive.skills, + disabledMcp: result.counts.disable.completed, + flaggedMemory: result.counts.flag.completed + (result.counts.flag.refreshed ?? 0), + }, + manifestPath: displayManifestPath, + privacy: mode.privacy, + beforeProvenance: { source: 'dry-run', at: result.summary.checkpointTimestamp }, + }), + ); + console.log(''); + } else if (result.status === 'partial-success' && !mode.quiet) { + const partialManifestPath = mode.privacy + ? result.manifestPath.replace(homedir(), '~') + : result.manifestPath; + console.log(''); + console.log(`Done with failures. ${result.failed} op(s) failed — see manifest for details.`); + console.log(`Manifest: ${partialManifestPath}`); + } else if (result.status !== 'success' && result.status !== 'partial-success') { + // running-process was consumed by the retry loop above; this branch now + // handles checkpoint-missing / checkpoint-invalid / process-detection-failed / + // user-aborted / config-parse-error / config-write-error. + console.error(`[ccaudit] Interactive bust failed: ${result.status}`); + process.exitCode = bustResultToExitCode(result); + } +} + export const ghostCommand = define({ name: 'ghost', description: 'Show ghost inventory report (default)', @@ -98,19 +608,24 @@ export const ghostCommand = define({ json: { type: 'boolean', short: 'j', - description: 'Output as JSON (see docs/JSON-SCHEMA.md for schema)', + description: 'JSON output (docs/JSON-SCHEMA.md)', default: false, }, verbose: { type: 'boolean', - short: 'v', description: 'Show scan details', default: false, }, dryRun: { type: 'boolean', + description: 'Preview changes without mutating files (writes checkpoint)', + default: false, + }, + interactive: { + type: 'boolean', + short: 'i', description: - 'Preview changes without mutating files (writes checkpoint to ~/.claude/ccaudit/.last-dry-run)', + 'Open interactive TUI picker to archive a subset of ghosts. Requires a TTY; non-TTY falls back to --dry-run.', default: false, }, dangerouslyBustGhosts: { @@ -121,8 +636,7 @@ export const ghostCommand = define({ }, yesProceedBusting: { type: 'boolean', - description: - 'Skip the confirmation ceremony (required for non-TTY/CI). Name is intentionally unwieldy — do not copy-paste from the internet.', + description: 'Skip confirmation prompts (required for non-TTY/CI).', default: false, }, privacy: { @@ -134,7 +648,7 @@ export const ghostCommand = define({ forcePartial: { type: 'boolean', description: - 'Bypass framework-as-unit bust protection. Archive ghost members of partially-used frameworks. Also affects --dry-run preview and checkpoint hash — both runs MUST use the same value.', + 'Allow partial-framework busts. Also changes dry-run eligibility and checkpoint hash, so both runs must use the same value. Under --interactive, unlocks protected rows for the current run.', default: false, }, regime: { @@ -157,6 +671,20 @@ export const ghostCommand = define({ const _argv = process.argv.slice(2); const _isDryRun = ctx.values.dryRun === true; const _isBust = ctx.values.dangerouslyBustGhosts === true; + const _isInteractive = ctx.values.interactive === true; + + // D-06: --interactive + --json is a hard error at parse time, before any scan. + if (_isInteractive && ctx.values.json === true) { + console.error('Error: --interactive cannot be combined with --json.'); + process.exitCode = 2; + return; + } + + // D-07: effectiveDryRun is set to true when --interactive is passed but no TTY + // is available. The --interactive branch below sets this; the dry-run branch + // below checks it so the non-TTY fallback path runs correctly. + let effectiveDryRun = false; + // historyResult accumulates structured result for the history entry. // Each branch populates this before returning. let _historyResult: CommandResult = null; @@ -220,12 +748,8 @@ export const ghostCommand = define({ // Must be called before ANY rendering. Takes no arguments per D-07. initColor(); - // Resolve output mode from all flag values - const noGroupFrameworksVal = ctx.values['no-group-frameworks']; - const mode = resolveOutputMode({ - ...ctx.values, - noGroupFrameworks: typeof noGroupFrameworksVal === 'boolean' ? noGroupFrameworksVal : false, - }); + // Resolve output mode from all flag values. + const mode = resolveOutputMode(ctx.values); if (mode.verbose) { console.error(`[ccaudit] Scanning sessions (window: ${sinceStr})...`); @@ -289,10 +813,83 @@ export const ghostCommand = define({ tier: r.tier, })); + // Step 3.55: Interactive branch (Phase 2, Plan 03) + // Placed BEFORE the dry-run branch so --interactive short-circuits here. + // Non-TTY path sets effectiveDryRun=true and falls through to Step 3.6. + if (ctx.values.interactive === true) { + // Phase 9 D2 (SC2): CCAUDIT_NO_INTERACTIVE=1 hard-refuses explicit --interactive + // with exit code 2. Fails closed before TTY detection or any scan-derived state. + if (isNoInteractiveEnv()) { + process.stderr.write('refusing: CCAUDIT_NO_INTERACTIVE is set\n'); + process.exitCode = 2; + return; + } + // Phase 9 D1 (SC1): zero-ghost short-circuit. Skip TUI entirely, print a + // single clean line to stdout, exit 0. Applies uniformly to TTY + non-TTY. + if (!enriched.some((r) => r.tier !== 'used')) { + console.log('Inventory is clean — no ghosts to archive.'); + return; + } + // Site A: TEST-ONLY: CCAUDIT_FORCE_TTY=1 lets the Phase 3 INV-S2 integration test + // exercise the runInteractiveGhostFlow path from a non-pty subprocess + // (Phase 3 D-21 / CONTEXT.md). NEVER document in --help. This env var has + // no effect on production usage because users on a real terminal already + // have isTTY === true, and CI/non-TTY users would never set it. + const forceTty = process.env['CCAUDIT_FORCE_TTY'] === '1'; + const isTty = forceTty || (Boolean(process.stdout.isTTY) && Boolean(process.stdin.isTTY)); + if (!isTty) { + // D-07: non-TTY + explicit --interactive → fall back to dry-run with notice. + console.error('No TTY detected — running in dry-run mode.'); + effectiveDryRun = true; + // Fall through to the dry-run branch below. + } else { + // TTY path: delegate to the extracted interactive flow helper. + const ghosts = enriched.filter((r) => r.tier !== 'used'); + // Pre-compute regime and worst-case overhead so the helper avoids duplicating + // the expensive resolveMcpRegime + groupGhostsByProject calls. + let interactiveResolvedRegime: McpRegime; + if (regimeFlag === 'auto') { + const eagerMcpTotal = enriched + .filter((r) => r.item.category === 'mcp-server') + .reduce((sum, r) => sum + (r.tokenEstimate?.tokens ?? 0), 0); + interactiveResolvedRegime = resolveMcpRegime({ + totalMcpToolTokens: eagerMcpTotal, + contextWindow: CONTEXT_WINDOW_SIZE, + ccVersion: detectedCcVersion, + override: null, + }).regime; + } else { + interactiveResolvedRegime = regimeFlag; + } + const { global: iGlobal, projects: iProjects } = groupGhostsByProject(ghosts, homedir()); + const { total: iWorstCase } = calculateWorstCaseOverhead(iGlobal, iProjects); + await runInteractiveGhostFlow({ + enriched, + ghosts, + sinceStr, + regimeFlag, + detectedCcVersion, + mode: { + json: mode.json, + csv: mode.csv, + quiet: mode.quiet, + ci: ctx.values.ci === true, + groupFrameworks: mode.groupFrameworks, + verbose: mode.verbose, + privacy: mode.privacy, + }, + forcePartial: ctx.values.forcePartial === true, + resolvedRegime: interactiveResolvedRegime, + totalOverhead: iWorstCase, + }); + return; + } + } + // Step 3.6: Dry-run branch (Phase 7, D-01 through D-20) // Lifted before the inventory rendering chain per RESEARCH §CLI Integration — // single decision point, four output modes per command mode (8 test cases total). - if (ctx.values.dryRun) { + if (ctx.values.dryRun || effectiveDryRun) { // v1.3.0 Phase 4: framework-as-unit bust protection (D-27). // Filter must run BEFORE buildChangePlan and BEFORE computeGhostHash so // both dry-run and bust paths see the same eligible set (hashes match). @@ -622,6 +1219,32 @@ export const ghostCommand = define({ // closure below. let bustProtection: FrameworkBustResult | null = null; + /** + * CCAUDIT_SELECT_IDS — internal integration-test hook (Phase 1, Plan 03). + * + * Comma-separated canonical item IDs (format: canonicalItemId(InventoryItem)). + * When set, parses into a Set passed to runBust so only the listed + * items are archived/disabled. When absent, preserves the v1.4.0 full-inventory + * behavior byte-for-byte (selectedItems === undefined). + * + * This env var is NOT a public flag and MUST NOT appear in --help output. + * Phase 2's --interactive flag replaces this for user-facing subset selection. + */ + let selectedItems: Set | undefined; + const rawSelectIds = process.env['CCAUDIT_SELECT_IDS']; + if (rawSelectIds !== undefined) { + const ids = rawSelectIds + .split(',') + .map((id) => id.trim()) + .filter((id) => id.length > 0); + selectedItems = new Set(ids); + if (mode.verbose) { + console.error( + `[ccaudit] CCAUDIT_SELECT_IDS set — subset bust with ${selectedItems.size} item(s)`, + ); + } + } + // Rule #4: Build BustDeps with a SELF-CONTAINED scanAndEnrich. // scanAndEnrich drives the FULL discover → parse → scan → enrich pipeline // internally rather than closing over the outer `enriched` variable. This @@ -668,7 +1291,7 @@ export const ghostCommand = define({ return protection.filtered; }, computeHash: (e) => computeGhostHash(e), - processDetector: defaultProcessDeps, + processDetector: buildWrappedProcessDeps(), selfPid: process.pid, runCeremony: async ({ plan, yes: ceremonyYes }) => { // Print the change plan to stdout BEFORE the prompts (D-15) so the @@ -703,6 +1326,7 @@ export const ghostCommand = define({ readFileUtf8: (p) => readFile(p, 'utf8'), patchMemoryFrontmatter: patchFrontmatter, atomicWriteJson: (target, value) => atomicWriteJson(target, value), + atomicWriteText: (target, text) => atomicWriteText(target, text), pathExistsSync: existsSync, createManifestWriter: (p) => new ManifestWriter(p), manifestPath: () => resolveManifestPath(), @@ -718,7 +1342,7 @@ export const ghostCommand = define({ console.error('[ccaudit] Starting bust pipeline...'); } - const result = await runBust({ yes, deps }); + const result = await runBust({ yes, deps, selectedItems }); // ── Output rendering per BustResult variant ──────────────── // bustProtection is reassigned inside the scanAndEnrich closure, so @@ -835,25 +1459,15 @@ export const ghostCommand = define({ console.error('Run ccaudit --dry-run again to generate a fresh plan.'); break; case 'running-process': - if (result.selfInvocation) { - console.error("You're running ccaudit from inside a Claude Code session."); - console.error(''); - console.error('Open a separate terminal window and run the command from there.'); - console.error( - "ccaudit cannot modify Claude Code's configuration while Claude Code is reading it.", - ); - } else { - console.error(`Claude Code is still running (pids: ${result.pids.join(', ')}).`); - console.error(''); - console.error(colorize.red("Don't cross the streams!")); - console.error(''); - console.error( - 'Close all Claude Code instances before running --dangerously-bust-ghosts.', - ); - console.error( - 'Modifying configuration while Claude Code is active can corrupt session state.', - ); - } + // Phase 3.2 SC5: single source of truth for the preflight copy. + // Byte-for-byte equal to the previous inline console.error block + // (locked by the SC5 inline-snapshot test in _preflight-copy.ts). + process.stderr.write( + renderRunningProcessMessage({ + selfInvocation: result.selfInvocation, + pids: result.pids, + }), + ); break; case 'process-detection-failed': console.error(`Could not verify Claude Code is stopped: ${result.error}`); @@ -1313,6 +1927,84 @@ export const ghostCommand = define({ console.log(''); console.log(colorize.dim('Total includes hook upper-bound (worst case).')); } + + // Phase 3.2 SC7: hook archival is deferred — surface the status once so + // users who came looking for hook support are not silently abandoned. + // Explicit D-23 suppression gate (all four modes — structurally we are + // already inside the text-mode arm of the json/csv/else branch, but we + // also check !mode.json && !mode.csv defensively to make the gate + // self-documenting and robust to future restructuring). --quiet is + // NOT absorbed into the outer text-mode branch — it gates per-line. + // --ci lives on ctx.values.ci, not on mode. + if ( + hookItems.length > 0 && + !mode.json && + !mode.csv && + !mode.quiet && + ctx.values.ci !== true + ) { + console.log(''); + console.log( + colorize.dim('Hook archival deferred — selectable archive coming in a future phase'), + ); + } + } + + // ── D-22 through D-25: auto-open interactive picker prompt ────────────── + // Triggered only when: + // - no --interactive (that branch ran earlier and returned) + // - no --dry-run, no --dangerously-bust-ghosts (enforced by D-23 mode suppressors) + // - report mode is human (not --json/--csv/--quiet/--ci) + // - stdin + stdout are TTY + // - ≥1 ghost was found (hasGhosts) + // - terminal ≥ 60 cols + // + // Uses checkTuiGuards with isExplicitInteractive=false to apply D-23's full + // 6-flag suppression matrix (json/csv/quiet/ci/dryRun/dangerouslyBustGhosts). + if (hasGhosts && !isNoInteractiveEnv()) { + const guardAutoOpen = checkTuiGuards({ + mode: { + json: mode.json, + csv: mode.csv, + quiet: mode.quiet, + ci: ctx.values.ci === true, + dryRun: Boolean(ctx.values.dryRun), + dangerouslyBustGhosts: Boolean(ctx.values.dangerouslyBustGhosts), + }, + isTty: Boolean(process.stdout.isTTY) && Boolean(process.stdin.isTTY), + ttyCols: process.stdout.columns, + isExplicitInteractive: false, + }); + if (guardAutoOpen.kind === 'ok') { + const autoOpenOutcome = await promptAutoOpen(); + if (autoOpenOutcome === 'open') { + // 2nd call site of runInteractiveGhostFlow (Plan 03 defined it; Plan 04 reuses it). + // Reuse the same enriched array — do NOT re-scan. + const autoOpenGhosts = enriched.filter((r) => r.tier !== 'used'); + await runInteractiveGhostFlow({ + enriched, + ghosts: autoOpenGhosts, + sinceStr, + regimeFlag, + detectedCcVersion, + mode: { + json: mode.json, + csv: mode.csv, + quiet: mode.quiet, + ci: ctx.values.ci === true, + groupFrameworks: mode.groupFrameworks, + verbose: mode.verbose, + privacy: mode.privacy, + }, + forcePartial: ctx.values.forcePartial === true, + resolvedRegime: resolvedRegimeForOverhead, + totalOverhead: worstCaseTotal, + }); + return; + } + // autoOpenOutcome === 'decline' → fall through and exit 0 normally (report already printed) + } + // guardAutoOpen.kind === 'suppress-auto-open' → do nothing, exit normally (D-23) } // Set exit code: ghost/inventory/mcp exit 1 when ghosts found (per D-01, D-02, D-03) diff --git a/apps/ccaudit/src/cli/commands/inventory.ts b/apps/ccaudit/src/cli/commands/inventory.ts index 00fe4f2..13ef213 100644 --- a/apps/ccaudit/src/cli/commands/inventory.ts +++ b/apps/ccaudit/src/cli/commands/inventory.ts @@ -48,7 +48,7 @@ export const inventoryCommand = define({ json: { type: 'boolean', short: 'j', - description: 'Output as JSON (see docs/JSON-SCHEMA.md for schema)', + description: 'JSON output (docs/JSON-SCHEMA.md)', default: false, }, verbose: { @@ -103,12 +103,8 @@ export const inventoryCommand = define({ // Initialize color detection from process.argv (--no-color) and env (NO_COLOR) initColor(); - // Resolve output mode from all flag values - const noGroupFrameworksVal = ctx.values['no-group-frameworks']; - const mode = resolveOutputMode({ - ...ctx.values, - noGroupFrameworks: typeof noGroupFrameworksVal === 'boolean' ? noGroupFrameworksVal : false, - }); + // Resolve output mode from all flag values. + const mode = resolveOutputMode(ctx.values); if (mode.verbose) { console.error(`[ccaudit] Scanning sessions (window: ${sinceStr})...`); diff --git a/apps/ccaudit/src/cli/commands/mcp.ts b/apps/ccaudit/src/cli/commands/mcp.ts index 440a9c0..f913795 100644 --- a/apps/ccaudit/src/cli/commands/mcp.ts +++ b/apps/ccaudit/src/cli/commands/mcp.ts @@ -137,7 +137,7 @@ export const mcpCommand = define({ json: { type: 'boolean', short: 'j', - description: 'Output as JSON (see docs/JSON-SCHEMA.md for schema)', + description: 'JSON output (docs/JSON-SCHEMA.md)', default: false, }, verbose: { diff --git a/apps/ccaudit/src/cli/commands/purge-archive.ts b/apps/ccaudit/src/cli/commands/purge-archive.ts new file mode 100644 index 0000000..2ee0f5f --- /dev/null +++ b/apps/ccaudit/src/cli/commands/purge-archive.ts @@ -0,0 +1,437 @@ +// apps/ccaudit/src/cli/commands/purge-archive.ts -- Phase 9 SC6 +// +// Gunshi subcommand: `ccaudit purge-archive [--dry-run | --yes] [--json]` +// +// Drains ~/.claude/ccaudit/archived/ via the Plan 09-03 domain core. +// Default behavior is dry-run; a real purge REQUIRES an explicit --yes +// gate (no prompt fallback, per CONTEXT D6). +// +// Scope is archive ops ONLY — flag (memory frontmatter) and disable (MCP +// re-enable) ops are ignored by classifyArchiveOps and never touched. +// +// Exit codes: +// 0 success OR partial failure (partial is reported in failures[], not fatal) +// 1 classification-level failure (manifest dir unreadable, executePurge Result.err, +// flag mutual-exclusion violation) +// 2 safe-mode abort (unused on this command today — kept for parity with other subcommands) + +import { readdir, rename, stat, unlink, mkdir } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import path from 'node:path'; +import { define } from 'gunshi'; +import { Result } from '@praha/byethrow'; +import { + classifyArchiveOps, + executePurge, + discoverManifests, + readManifest, + openPurgeManifestWriter, + recordHistory, +} from '@ccaudit/internal'; +import type { + ExecutePurgeDeps, + PurgePlan, + PurgeResult, + ManifestOp, + ArchivePurgeOp, +} from '@ccaudit/internal'; +import { initColor, colorize } from '@ccaudit/terminal'; +import { outputArgs } from '../_shared-args.ts'; +import { resolveOutputMode, buildJsonEnvelope } from '../_output-mode.ts'; +import { CCAUDIT_VERSION } from '../../_version.ts'; + +// -- Production deps builder ---------------------------------------- + +function buildProductionExecutePurgeDeps(): ExecutePurgeDeps { + return { + pathExists: async (p: string) => { + try { + await stat(p); + return true; + } catch { + return false; + } + }, + mkdirRecursive: (dir: string) => mkdir(dir, { recursive: true }).then(() => undefined), + renameFile: (from: string, to: string) => rename(from, to), + unlinkFile: (p: string) => unlink(p), + createPurgeManifestWriter: (input) => + openPurgeManifestWriter({ + ccaudit_version: input.ccaudit_version, + purge_timestamp: input.purge_timestamp, + }), + ccauditVersion: CCAUDIT_VERSION, + now: () => new Date(), + }; +} + +// -- Gunshi command definition ------------------------------------- + +export const purgeArchiveCommand = define({ + name: 'purge-archive', + description: + 'Drain ~/.claude/ccaudit/archived/ via reclaim-if-free / drop-if-occupied / drop-if-stale (archive ops only)', + toKebab: true, + // Suppress gunshi's decorative pre-run banner — same pattern as restore/reclaim + // so --json output is pure JSON on stdout. + renderHeader: null, + args: { + ...outputArgs, + json: { + type: 'boolean' as const, + short: 'j', + description: 'JSON output (docs/JSON-SCHEMA.md §Purge)', + default: false, + }, + 'dry-run': { + type: 'boolean' as const, + description: 'Classify without mutating the filesystem (default)', + default: false, + }, + yes: { + type: 'boolean' as const, + description: 'Execute the classified plan (required for real purge)', + default: false, + }, + }, + async run(ctx) { + initColor(); + const outMode = resolveOutputMode(ctx.values); + + // -- History instrumentation --------------------------------- + const historyStartMs = Date.now(); + const argv = process.argv.slice(2); + const homeDir = process.env.HOME ?? process.env.USERPROFILE ?? homedir(); + const safeRecordHistory = async ( + entry: Omit[0], 'privacy'>, + ): Promise => { + if (process.env.CCAUDIT_NO_HISTORY === '1') return; + try { + await recordHistory({ ...entry, privacy: outMode.privacy }); + } catch (err) { + process.stderr.write( + `[ccaudit] warning: failed to record history: ${ + err instanceof Error ? err.message : String(err) + }\n`, + ); + } + }; + + // -- Argument parsing + validation ---------------------------- + const dryRunFlag = ctx.values['dry-run'] === true; + const yesFlag = ctx.values.yes === true; + + // D6 safety gate: --yes and --dry-run are mutually exclusive. + if (dryRunFlag && yesFlag) { + const message = 'flags are mutually exclusive: --dry-run, --yes'; + if (outMode.json) { + process.stdout.write( + JSON.stringify( + buildJsonEnvelope('purge-archive', 'n/a', 1, { + purge: { + summary: { + purgedCount: 0, + reclaimedCount: 0, + skippedOccupiedCount: 0, + staleFilteredCount: 0, + }, + failures: [{ path: '', reason: message }], + dryRun: true, + manifestPath: null, + }, + }), + ) + '\n', + ); + } else { + process.stderr.write(`${message}\n`); + } + await safeRecordHistory({ + homeDir, + command: 'purge-archive', + argv, + exitCode: 1, + durationMs: Date.now() - historyStartMs, + cwd: process.cwd(), + result: null, + errors: [message], + ccauditVersion: CCAUDIT_VERSION, + }); + process.exit(1); + } + + // Effective mode: dry-run unless --yes is explicitly set. + const dryRun = !yesFlag; + + // -- Load manifest union + classify --------------------------- + let plan: PurgePlan; + const manifestErrors: { path: string; reason: string }[] = []; + try { + const entries = await discoverManifests({ + readdir: (dir: string) => readdir(dir), + stat: async (p: string) => { + const s = await stat(p); + return { mtime: s.mtime }; + }, + }); + const allOps: ManifestOp[] = []; + for (const entry of entries) { + try { + const parsed = await readManifest(entry.path); + allOps.push(...parsed.ops); + } catch (mErr) { + const reason = mErr instanceof Error ? mErr.message : String(mErr); + manifestErrors.push({ path: entry.path, reason }); + process.stderr.write( + `[ccaudit] warning: skipping unreadable manifest ${entry.path}: ${reason}\n`, + ); + } + } + // Probe disk via the same injected pathExists the executor uses. + const pathExists = async (p: string): Promise => { + try { + await stat(p); + return true; + } catch { + return false; + } + }; + plan = await classifyArchiveOps(allOps, pathExists); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + if (outMode.json) { + process.stdout.write( + JSON.stringify( + buildJsonEnvelope('purge-archive', 'n/a', 1, { + purge: { + summary: { + purgedCount: 0, + reclaimedCount: 0, + skippedOccupiedCount: 0, + staleFilteredCount: 0, + }, + failures: [{ path: '', reason: message }], + ...(manifestErrors.length > 0 ? { manifestErrors } : {}), + dryRun, + manifestPath: null, + }, + }), + ) + '\n', + ); + } else { + process.stderr.write(`ccaudit purge-archive failed: ${message}\n`); + } + await safeRecordHistory({ + homeDir, + command: 'purge-archive', + argv, + exitCode: 1, + durationMs: Date.now() - historyStartMs, + cwd: process.cwd(), + result: null, + errors: [message], + ccauditVersion: CCAUDIT_VERSION, + }); + process.exit(1); + } + + // -- Execute (dry-run or real) -------------------------------- + const deps = buildProductionExecutePurgeDeps(); + const execResult = await executePurge(plan, deps, { dryRun }); + + if (Result.isFailure(execResult)) { + // All items failed OR (theoretically) an internal error. Treat as + // exit 1 since at least one item was requested and none succeeded. + const message = execResult.error.message; + if (outMode.json) { + process.stdout.write( + JSON.stringify( + buildJsonEnvelope('purge-archive', 'n/a', 1, { + purge: { + summary: { + purgedCount: 0, + reclaimedCount: 0, + skippedOccupiedCount: 0, + staleFilteredCount: 0, + }, + failures: [{ path: '', reason: message }], + ...(manifestErrors.length > 0 ? { manifestErrors } : {}), + dryRun, + manifestPath: null, + }, + }), + ) + '\n', + ); + } else { + process.stderr.write(`ccaudit purge-archive: ${message}\n`); + } + await safeRecordHistory({ + homeDir, + command: 'purge-archive', + argv, + exitCode: 1, + durationMs: Date.now() - historyStartMs, + cwd: process.cwd(), + result: { + purgedCount: 0, + reclaimedCount: 0, + skippedOccupiedCount: 0, + staleFilteredCount: 0, + dryRun, + failures: 1, + }, + errors: [message], + ccauditVersion: CCAUDIT_VERSION, + }); + process.exit(1); + } + + const result = execResult.value; + + // Exit code: 0 (partial failures are NOT fatal per the plan's INV table). + const exitCode = 0; + + if (outMode.json) { + process.stdout.write( + JSON.stringify( + buildJsonEnvelope('purge-archive', 'n/a', exitCode, { + purge: { + summary: result.summary, + failures: result.failures.map((f) => ({ path: f.path, reason: f.reason })), + ...(manifestErrors.length > 0 ? { manifestErrors } : {}), + dryRun, + manifestPath: result.manifestPath, + }, + }), + ) + '\n', + ); + } else { + process.stdout.write(renderPurgeHuman(plan, result, dryRun)); + } + + await safeRecordHistory({ + homeDir, + command: 'purge-archive', + argv, + exitCode, + durationMs: Date.now() - historyStartMs, + cwd: process.cwd(), + result: { + purgedCount: result.summary.purgedCount, + reclaimedCount: result.summary.reclaimedCount, + skippedOccupiedCount: result.summary.skippedOccupiedCount, + staleFilteredCount: result.summary.staleFilteredCount, + dryRun, + failures: result.failures.length, + }, + errors: result.failures.map((f) => `${f.path}: ${f.reason}`), + ccauditVersion: CCAUDIT_VERSION, + }); + process.exit(exitCode); + }, +}); + +// -- Human-readable rendering -------------------------------------- + +function renderPurgeHuman(plan: PurgePlan, result: PurgeResult, dryRun: boolean): string { + const lines: string[] = []; + const headerLabel = dryRun ? 'Purge archive (dry-run)' : 'Purge archive'; + lines.push(headerLabel); + lines.push(''); + + const reclaimCount = plan.reclaim.length; + const dropOccupied = plan.drop.filter((d) => d.reason === 'source_occupied').length; + const dropStale = plan.drop.filter((d) => d.reason === 'stale_archive_missing').length; + const skipCount = plan.skip.length; + + const totalConsidered = reclaimCount + dropOccupied + dropStale + skipCount; + if (totalConsidered === 0) { + lines.push('Archive is empty or already reconciled — nothing to do.'); + return lines.join('\n') + '\n'; + } + + // Section 1: To reclaim + if (reclaimCount > 0) { + lines.push(`To reclaim: ${reclaimCount} item(s)`); + for (const { op } of plan.reclaim) { + const name = path.basename(op.archive_path, path.extname(op.archive_path)); + lines.push(` ${name.padEnd(28)} → ${op.source_path}`); + } + lines.push(''); + } + + // Section 2: To drop (with reason column) + const totalDrop = dropOccupied + dropStale; + if (totalDrop > 0) { + lines.push(`To drop: ${totalDrop} item(s)`); + for (const { op, reason } of plan.drop) { + const name = path.basename(op.archive_path, path.extname(op.archive_path)); + lines.push(` ${name.padEnd(28)} [${reason}]`); + } + lines.push(''); + } + + // Section 3: Skipped (broken state) + if (skipCount > 0) { + lines.push(`Skipped (broken state): ${skipCount} item(s)`); + for (const { op } of plan.skip) { + const name = path.basename(op.archive_path, path.extname(op.archive_path)); + lines.push(` ${name.padEnd(28)} [both_missing]`); + } + lines.push(''); + } + + // Section 4: failures (real-run only) + if (!dryRun && result.failures.length > 0) { + lines.push(colorize.yellow(`Failures: ${result.failures.length}`)); + for (const f of result.failures) { + lines.push(` ${f.path} [${f.reason}]`); + } + lines.push(''); + } + + // Summary + const summaryParts: string[] = []; + if (dryRun) { + summaryParts.push(`${result.summary.reclaimedCount} would be reclaimed`); + summaryParts.push(`${result.summary.purgedCount} would be purged`); + if (result.summary.skippedOccupiedCount > 0) { + summaryParts.push(`${result.summary.skippedOccupiedCount} drop-source-occupied`); + } + if (result.summary.staleFilteredCount > 0) { + summaryParts.push(`${result.summary.staleFilteredCount} drop-stale`); + } + if (skipCount > 0) { + summaryParts.push(`${skipCount} skipped`); + } + lines.push(`Summary (dry-run): ${summaryParts.join(', ')}.`); + lines.push(''); + lines.push('Dry-run. Pass --yes to execute.'); + } else { + summaryParts.push(`${result.summary.reclaimedCount} reclaimed`); + summaryParts.push(`${result.summary.purgedCount} purged`); + if (result.summary.skippedOccupiedCount > 0) { + summaryParts.push(`${result.summary.skippedOccupiedCount} drop-source-occupied`); + } + if (result.summary.staleFilteredCount > 0) { + summaryParts.push(`${result.summary.staleFilteredCount} drop-stale`); + } + if (result.failures.length > 0) { + summaryParts.push(`${result.failures.length} failed`); + } + const summaryLine = `Summary: ${summaryParts.join(', ')}.`; + lines.push( + result.failures.length > 0 ? colorize.yellow(summaryLine) : colorize.green(summaryLine), + ); + if (result.manifestPath !== null) { + lines.push(''); + lines.push(`Follow-up manifest: ${result.manifestPath}`); + } + } + + return lines.join('\n') + '\n'; +} + +// Inert re-export to keep some unused import warnings quiet in narrower lint +// configs; ArchivePurgeOp is part of the public type surface consumed by the +// JSON envelope shape via executePurge's return type. +export type { ArchivePurgeOp }; diff --git a/apps/ccaudit/src/cli/commands/restore.ts b/apps/ccaudit/src/cli/commands/restore.ts index b4bdb4a..2276887 100644 --- a/apps/ccaudit/src/cli/commands/restore.ts +++ b/apps/ccaudit/src/cli/commands/restore.ts @@ -13,6 +13,7 @@ import { readFile, rename, mkdir, stat, readdir } from 'node:fs/promises'; import { homedir } from 'node:os'; import path from 'node:path'; import { define } from 'gunshi'; +import { Result } from '@praha/byethrow'; import { recordHistory } from '@ccaudit/internal'; import { CCAUDIT_VERSION } from '../../_version.ts'; import { @@ -24,6 +25,12 @@ import { setFrontmatterValue, defaultProcessDeps, extractServerName, + findManifestsForRestore, + dedupManifestOps, + collectRestoreableItems, + filterRestoreableItems, + matchByName, + isNoInteractiveEnv, } from '@ccaudit/internal'; import type { RestoreDeps, @@ -34,7 +41,13 @@ import type { DisableOp, ManifestOp, } from '@ccaudit/internal'; -import { initColor, colorize, renderHeader } from '@ccaudit/terminal'; +import { + initColor, + colorize, + renderHeader, + openRestorePicker, + type RestoreItem, +} from '@ccaudit/terminal'; import { outputArgs } from '../_shared-args.ts'; import { resolveOutputMode, buildJsonEnvelope } from '../_output-mode.ts'; @@ -91,6 +104,63 @@ function buildProductionRestoreDeps(warnings: string[]): RestoreDeps { }; } +// -- Pure helpers (Plan 08-03) ---------------------------------------------- + +/** + * Format the D8-09 ambiguity block for `--name` matches. + * + * Returns `''` when there is no ambiguity (0 or 1 candidate). For ≥2 + * candidates returns the verbatim block (em-dash preserved) ending in a + * trailing newline. + */ +export function formatAmbiguityError(pattern: string, candidates: string[]): string { + if (candidates.length < 2) return ''; + const lines = [ + `"${pattern}" is ambiguous \u2014 candidates:`, + ...candidates.map((c) => ` ${c}`), + `Use --all-matching to restore every candidate.`, + ]; + return lines.join('\n') + '\n'; +} + +/** + * D8-11 mutual-exclusion gate for the three restore mode flags. + * Returns Err with a fixed message when ≥2 of {--interactive, --name, + * --all-matching} are set; otherwise Ok. + */ +export function validateRestoreFlagExclusion(flags: { + interactive?: boolean; + name?: string; + allMatching?: string; +}): Result.Result { + const active = [flags.interactive, flags.name, flags.allMatching].filter( + (v) => v !== undefined && v !== false && v !== '', + ).length; + if (active >= 2) { + return Result.fail('flags are mutually exclusive: --interactive, --name, --all-matching'); + } + return Result.succeed(); +} + +// -- Preflight error ----------------------------------------------------------- + +/** + * Typed error for pre-dispatch validation failures (mutual-exclusion check, + * CCAUDIT_NO_INTERACTIVE refusal, TTY guard, no-match / ambiguity, empty + * interactive archive). The catch block recognises this type and short-circuits + * without printing a stack trace, emitting the JSON envelope when --json is + * active and calling safeRecordHistory before exiting. + */ +class RestorePreflightError extends Error { + constructor( + public readonly exitCode: number, + message: string, + ) { + super(message); + this.name = 'RestorePreflightError'; + } +} + // -- Gunshi command definition ----------------------------------------------- export const restoreCommand = define({ @@ -108,12 +178,11 @@ export const restoreCommand = define({ json: { type: 'boolean' as const, short: 'j', - description: 'Output as JSON (see docs/JSON-SCHEMA.md for schema)', + description: 'JSON output (docs/JSON-SCHEMA.md)', default: false, }, verbose: { type: 'boolean' as const, - short: 'v', description: 'Show detailed output including warnings', default: false, }, @@ -122,6 +191,22 @@ export const restoreCommand = define({ description: 'List all archived items across all busts (read-only)', default: false, }, + interactive: { + type: 'boolean' as const, + short: 'i', + description: 'Open interactive picker to select items to restore', + default: false, + }, + name: { + type: 'string' as const, + metavar: 'pattern', + description: 'Restore item matching this substring (fuzzy, case-insensitive)', + }, + allMatching: { + type: 'string' as const, + metavar: 'pattern', + description: 'Restore every item matching this substring (bulk)', + }, }, async run(ctx) { initColor(); @@ -158,27 +243,224 @@ export const restoreCommand = define({ // ctx._ is the FULL argv — same indexing issue, do NOT use ctx._[0] either. const positionalName = ctx.positionals[ctx.commandPath.length] ?? null; const listFlag = ctx.values.list === true; - - const mode: RestoreMode = listFlag - ? { kind: 'list' } - : positionalName !== null - ? { kind: 'single', name: String(positionalName) } - : { kind: 'full' }; + const interactiveFlag = ctx.values.interactive === true; + const nameFlag = + typeof ctx.values.name === 'string' && ctx.values.name.length > 0 + ? ctx.values.name + : undefined; + const allMatchingFlag = + typeof ctx.values.allMatching === 'string' && ctx.values.allMatching.length > 0 + ? ctx.values.allMatching + : undefined; const warnings: string[] = []; const deps = buildProductionRestoreDeps(warnings); + // M4: The outer try/catch covers ALL pre-dispatch flows including preflight + // validation (mutual-exclusion, CCAUDIT_NO_INTERACTIVE, TTY guard) as well + // as discovery (findManifestsForRestore, readManifest, openRestorePicker) + // and executeRestore itself. RestorePreflightError is caught here and + // short-circuits gracefully without a stack trace. + let nameResolvedId: string | null = null; + let allMatchingResolvedIds: string[] | null = null; + let interactiveIds: string[] | null = null; let result: RestoreResult; try { + // D8-11: mutual exclusion between --interactive / --name / --all-matching. + const exclusion = validateRestoreFlagExclusion({ + interactive: interactiveFlag, + name: nameFlag, + allMatching: allMatchingFlag, + }); + if (exclusion.type === 'Failure') { + throw new RestorePreflightError(1, exclusion.error); + } + // --interactive + --list is also a hard error (list is read-only; picker + // is an executing flow — no sensible combination). + if (interactiveFlag && listFlag) { + throw new RestorePreflightError(1, 'flags are mutually exclusive: --interactive, --list'); + } + + // Phase 9 D2 (SC2): CCAUDIT_NO_INTERACTIVE=1 hard-refuses explicit --interactive + // with exit code 2. Mirrors the ghost.ts gate so refusal behavior is uniform. + if (interactiveFlag && isNoInteractiveEnv()) { + throw new RestorePreflightError(2, 'refusing: CCAUDIT_NO_INTERACTIVE is set'); + } + + // TTY guard (mirrors ghost.ts Site A). CCAUDIT_FORCE_TTY=1 is the + // test-only hook; otherwise require a real interactive TTY on both + // stdout and stdin. + const forceTty = process.env['CCAUDIT_FORCE_TTY'] === '1'; + const isTty = forceTty || (Boolean(process.stdout.isTTY) && Boolean(process.stdin.isTTY)); + if (interactiveFlag && !isTty) { + throw new RestorePreflightError( + 1, + '--interactive requires a TTY. Use --name or --all-matching in non-interactive contexts.', + ); + } + // --name client-side resolution (D8-09): exact 1 match → single-id + // subset restore; 0 → no-match error; ≥2 → verbatim ambiguity block. + // NEVER auto-picks the newest on ambiguity. + if (nameFlag !== undefined) { + const entries = await findManifestsForRestore(deps); + const pairs: Array<{ entry: (typeof entries)[number]; ops: readonly ManifestOp[] }> = []; + for (const entry of entries) { + try { + const m = await readManifest(entry.path); + if (m.header === null) { + deps.onWarning?.( + `⚠️ Skipping corrupt manifest ${path.basename(entry.path)} (no header record)`, + ); + continue; + } + pairs.push({ entry, ops: m.ops }); + } catch (err) { + deps.onWarning?.( + `⚠️ Skipping unreadable manifest ${path.basename(entry.path)}: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + } + } + const deduped = dedupManifestOps(pairs); + const matches = matchByName(deduped, nameFlag); + if (matches.length === 0) { + throw new RestorePreflightError(1, `no archived item matches "${nameFlag}"`); + } + if (matches.length > 1) { + throw new RestorePreflightError( + 1, + formatAmbiguityError( + nameFlag, + matches.map((m) => m.canonical_id), + ).trimEnd(), + ); + } + nameResolvedId = matches[0]!.canonical_id; + } + + // D81-03: --all-matching CLI-side pre-dispatch gate mirroring --name. + // 0 matches → stderr + exit 1 (fixes the legacy executor path that + // mapped name-not-found → exit 0 silently succeeding on typos). + // ≥1 match → collect canonical_ids and route through { kind: + // 'interactive', ids: [...] }. The executor's all-matching branch + // stays for API consumers but becomes CLI-unreachable. + if (allMatchingFlag !== undefined) { + const entries = await findManifestsForRestore(deps); + const pairs: Array<{ entry: (typeof entries)[number]; ops: readonly ManifestOp[] }> = []; + for (const entry of entries) { + try { + const m = await readManifest(entry.path); + if (m.header === null) { + deps.onWarning?.( + `⚠️ Skipping corrupt manifest ${path.basename(entry.path)} (no header record)`, + ); + continue; + } + pairs.push({ entry, ops: m.ops }); + } catch (err) { + deps.onWarning?.( + `⚠️ Skipping unreadable manifest ${path.basename(entry.path)}: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + } + } + const deduped = dedupManifestOps(pairs); + const matches = matchByName(deduped, allMatchingFlag); + if (matches.length === 0) { + throw new RestorePreflightError(1, `no archived item matches "${allMatchingFlag}"`); + } + allMatchingResolvedIds = matches.map((m) => m.canonical_id); + } + + // Plan 08-04: --interactive dispatch — discover archive inventory, + // run the TUI picker, and translate confirmed selection into an + // { kind: 'interactive', ids } RestoreMode. Cancelled/empty paths + // must NOT reach executeRestore (INV-S2 mirror — zero manifest + // writes on abort). + if (interactiveFlag) { + const entries = await findManifestsForRestore(deps); + const pairs: Array<{ entry: (typeof entries)[number]; ops: readonly ManifestOp[] }> = []; + for (const entry of entries) { + try { + const m = await readManifest(entry.path); + if (m.header === null) { + deps.onWarning?.( + `⚠️ Skipping corrupt manifest ${path.basename(entry.path)} (no header record)`, + ); + continue; + } + pairs.push({ entry, ops: m.ops }); + } catch (err) { + deps.onWarning?.( + `⚠️ Skipping unreadable manifest ${path.basename(entry.path)}: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + } + } + // Phase 8.1 D81-01 C1a: collectRestoreableItems (not dedupManifestOps) + // so memory (flag/refresh) ops surface in the picker. + // Phase 8.2: strip stale archive ops before populating the picker. + const { kept: collected } = await filterRestoreableItems( + collectRestoreableItems(pairs), + deps.pathExists, + ); + if (collected.length === 0) { + throw new RestorePreflightError(0, 'Nothing to restore — archive is empty.'); + } + const pickerItems: RestoreItem[] = collected.map((d) => { + let category: RestoreItem['category']; + if (d.op.op_type === 'archive') { + category = d.op.category; + } else if (d.op.op_type === 'disable') { + category = 'mcp'; + } else { + // flag / refresh — memory frontmatter ops + category = 'memory'; + } + return { canonical_id: d.canonical_id, op: d.op, category }; + }); + const outcome = await openRestorePicker(pickerItems); + if (outcome.kind === 'cancelled') { + throw new RestorePreflightError(0, 'No changes made.'); + } + interactiveIds = outcome.selectedIds; + } + + const mode: RestoreMode = listFlag + ? { kind: 'list' } + : interactiveIds !== null + ? { kind: 'interactive', ids: interactiveIds } + : nameResolvedId !== null + ? { kind: 'interactive', ids: [nameResolvedId] } + : allMatchingResolvedIds !== null + ? { kind: 'interactive', ids: allMatchingResolvedIds } + : positionalName !== null + ? { kind: 'single', name: String(positionalName) } + : { kind: 'full' }; + result = await executeRestore(mode, deps); } catch (err) { - // Defensive catch: executeRestore uses injectable deps that shouldn't - // throw outside their own error paths, but guard against unexpected errors. + // Defensive catch: covers both pre-dispatch flows (findManifestsForRestore, + // readManifest, openRestorePicker) and executeRestore itself. Any failure + // routes into the graceful degradation path: structured stderr/JSON output, + // history write, and nonzero exit. + // + // RestorePreflightError: typed validation failures (mutual-exclusion, + // CCAUDIT_NO_INTERACTIVE, TTY guard, no-match, ambiguity, empty archive). + // Emit JSON envelope when --json is active; plain stderr otherwise. + // No stack trace in either case. + const isPreflight = err instanceof RestorePreflightError; + const exitCode = isPreflight ? err.exitCode : 2; const message = err instanceof Error ? err.message : String(err); if (outMode.json) { process.stdout.write( - JSON.stringify(buildJsonEnvelope('restore', 'n/a', 2, { error: message })) + '\n', + JSON.stringify(buildJsonEnvelope('restore', 'n/a', exitCode, { error: message })) + '\n', ); + } else if (isPreflight) { + process.stderr.write(message + '\n'); } else { process.stderr.write(`ccaudit restore failed: ${message}\n`); } @@ -186,18 +468,26 @@ export const restoreCommand = define({ homeDir: _homeDir, command: 'restore', argv: _argv, - exitCode: 2, + exitCode, durationMs: Date.now() - _historyStartMs, cwd: process.cwd(), result: null, errors: [message], ccauditVersion: CCAUDIT_VERSION, }); - process.exit(2); + process.exit(exitCode); } const exitCode = restoreResultToExitCode(result); + // Emit one stderr warning per skipped item BEFORE any stdout output so + // --json / --csv / --quiet stdout streams remain pure (D8-16/17). + if (result.status === 'success' || result.status === 'partial-success') { + for (const entry of result.skipped ?? []) { + process.stderr.write(`warning: skipped ${entry.path} — source already exists\n`); + } + } + // Output mode matrix (precedence: json > csv > quiet > rendered) if (outMode.json) { process.stdout.write( @@ -290,6 +580,9 @@ function restoreResultToJson(result: RestoreResult, warnings: string[]): Record< manifest_path: result.manifestPath, duration_ms: result.duration_ms, failed: 0, + selectionFilter: result.selectionFilter ?? null, + skipped: summarizeSkipped(result.skipped ?? []), + filteredStaleCount: result.filteredStaleCount ?? 0, }; case 'partial-success': return { @@ -299,6 +592,9 @@ function restoreResultToJson(result: RestoreResult, warnings: string[]): Record< manifest_path: result.manifestPath, duration_ms: result.duration_ms, failed: result.failed, + selectionFilter: result.selectionFilter ?? null, + skipped: summarizeSkipped(result.skipped ?? []), + filteredStaleCount: result.filteredStaleCount ?? 0, }; case 'no-manifests': return { @@ -320,6 +616,7 @@ function restoreResultToJson(result: RestoreResult, warnings: string[]): Record< ...base, status: 'list', entries: result.entries.map(summarizeListEntry), + filteredStaleCount: result.filteredStaleCount, }; case 'running-process': return { @@ -352,6 +649,16 @@ function restoreResultToJson(result: RestoreResult, warnings: string[]): Record< } } +function summarizeSkipped( + skipped: Array<{ reason: 'source_exists'; path: string; canonical_id: string }>, +): Array<{ reason: 'source_exists'; path: string; canonicalId: string }> { + return skipped.map((entry) => ({ + reason: entry.reason, + path: entry.path, + canonicalId: entry.canonical_id, + })); +} + function summarizeListEntry(entry: ManifestListEntry): Record { return { path: entry.path, @@ -425,7 +732,7 @@ function renderRestoreRendered( : ''; const failedNote = c.unarchived.failed > 0 ? ` (${c.unarchived.failed} failed)` : ''; lines.push( - `${c.unarchived.moved} agents/skills restored to their original locations${alreadyAtSourceNote}${failedNote}`, + `${c.unarchived.moved} items restored to their original locations${alreadyAtSourceNote}${failedNote}`, ); lines.push( `${c.reenabled.completed} MCP servers re-enabled in configuration${c.reenabled.failed > 0 ? ` (${c.reenabled.failed} failed)` : ''}`, @@ -571,12 +878,13 @@ if (import.meta.vitest) { manifestPath: '/p', manifestPaths: ['/p'], duration_ms: 10, + skipped: [], }, 0, ], [{ status: 'no-manifests' }, 0], [{ status: 'name-not-found', name: 'x' }, 0], - [{ status: 'list', entries: [] }, 0], + [{ status: 'list', entries: [], filteredStaleCount: 0 }, 0], [ { status: 'partial-success', @@ -589,6 +897,7 @@ if (import.meta.vitest) { manifestPath: '/p', manifestPaths: ['/p'], duration_ms: 5, + skipped: [], }, 1, ], @@ -618,6 +927,7 @@ if (import.meta.vitest) { manifestPath: '/m', manifestPaths: ['/m'], duration_ms: 50, + skipped: [], }; expect(renderRestoreQuiet(result)).toBe('restore\tsuccess\t2\t1\t1\t3\n'); }); @@ -645,6 +955,7 @@ if (import.meta.vitest) { manifestPath: '/m', manifestPaths: ['/m'], duration_ms: 10, + skipped: [], }; const rows = renderRestoreCsv(result).trim().split('\n'); expect(rows.length).toBe(4); // header + 3 rows @@ -660,4 +971,75 @@ if (import.meta.vitest) { expect(extractServerName('projects./foo.mcpServers.my.server')).toBe('my.server'); }); }); + + describe('formatAmbiguityError', () => { + it('returns empty string for 0 candidates', () => { + expect(formatAmbiguityError('pencil', [])).toBe(''); + }); + + it('returns empty string for 1 candidate (no ambiguity)', () => { + expect(formatAmbiguityError('pencil', ['agent:pencil-sharpener'])).toBe(''); + }); + + it('renders the verbatim D8-09 block for 2 candidates (em-dash preserved)', () => { + const out = formatAmbiguityError('pencil', ['agent:pencil-a', 'agent:pencil-b']); + expect(out).toBe( + '"pencil" is ambiguous \u2014 candidates:\n' + + ' agent:pencil-a\n' + + ' agent:pencil-b\n' + + 'Use --all-matching to restore every candidate.\n', + ); + }); + + it('includes every candidate on its own indented line for ≥3 matches', () => { + const out = formatAmbiguityError('x', ['a', 'b', 'c']); + const lines = out.split('\n'); + expect(lines[0]).toBe('"x" is ambiguous \u2014 candidates:'); + expect(lines[1]).toBe(' a'); + expect(lines[2]).toBe(' b'); + expect(lines[3]).toBe(' c'); + expect(lines[4]).toBe('Use --all-matching to restore every candidate.'); + expect(lines[5]).toBe(''); + }); + }); + + describe('validateRestoreFlagExclusion', () => { + const ERR = 'flags are mutually exclusive: --interactive, --name, --all-matching'; + it('Ok for no flags', () => { + expect(validateRestoreFlagExclusion({}).type).toBe('Success'); + }); + it('Ok for --interactive alone', () => { + expect(validateRestoreFlagExclusion({ interactive: true }).type).toBe('Success'); + }); + it('Ok for --name alone', () => { + expect(validateRestoreFlagExclusion({ name: 'x' }).type).toBe('Success'); + }); + it('Ok for --all-matching alone', () => { + expect(validateRestoreFlagExclusion({ allMatching: 'y' }).type).toBe('Success'); + }); + it('Err for interactive + name', () => { + const r = validateRestoreFlagExclusion({ interactive: true, name: 'x' }); + expect(r.type).toBe('Failure'); + if (r.type === 'Failure') expect(r.error).toBe(ERR); + }); + it('Err for interactive + allMatching', () => { + const r = validateRestoreFlagExclusion({ interactive: true, allMatching: 'y' }); + expect(r.type).toBe('Failure'); + if (r.type === 'Failure') expect(r.error).toBe(ERR); + }); + it('Err for name + allMatching', () => { + const r = validateRestoreFlagExclusion({ name: 'x', allMatching: 'y' }); + expect(r.type).toBe('Failure'); + if (r.type === 'Failure') expect(r.error).toBe(ERR); + }); + it('Err for all three set', () => { + const r = validateRestoreFlagExclusion({ + interactive: true, + name: 'x', + allMatching: 'y', + }); + expect(r.type).toBe('Failure'); + if (r.type === 'Failure') expect(r.error).toBe(ERR); + }); + }); } diff --git a/apps/ccaudit/src/cli/commands/trend.ts b/apps/ccaudit/src/cli/commands/trend.ts index 8dbc8a3..7dd31cd 100644 --- a/apps/ccaudit/src/cli/commands/trend.ts +++ b/apps/ccaudit/src/cli/commands/trend.ts @@ -35,7 +35,7 @@ export const trendCommand = define({ json: { type: 'boolean', short: 'j', - description: 'Output as JSON (see docs/JSON-SCHEMA.md for schema)', + description: 'JSON output (docs/JSON-SCHEMA.md)', default: false, }, verbose: { diff --git a/apps/ccaudit/src/cli/index.ts b/apps/ccaudit/src/cli/index.ts index 2a43351..37b4a03 100644 --- a/apps/ccaudit/src/cli/index.ts +++ b/apps/ccaudit/src/cli/index.ts @@ -5,6 +5,7 @@ import { inventoryCommand } from './commands/inventory.ts'; import { mcpCommand } from './commands/mcp.ts'; import { restoreCommand } from './commands/restore.ts'; import { reclaimCommand } from './commands/reclaim.ts'; +import { purgeArchiveCommand } from './commands/purge-archive.ts'; import { trendCommand } from './commands/trend.ts'; import { installSkillCommand } from './commands/install-skill.ts'; @@ -31,6 +32,7 @@ export async function run(): Promise { mcp: mcpCommand, restore: restoreCommand, // Phase 9 reclaim: reclaimCommand, // Phase 4 + 'purge-archive': purgeArchiveCommand, // Phase 9 SC6 trend: trendCommand, 'install-skill': installSkillCommand, }, diff --git a/docs/JSON-SCHEMA.md b/docs/JSON-SCHEMA.md index 1b3b1df..8a9883a 100644 --- a/docs/JSON-SCHEMA.md +++ b/docs/JSON-SCHEMA.md @@ -78,6 +78,91 @@ Bust, restore, and reclaim emit a `counts` object rather than an `items` array. } ``` +### Additive fields (v1.5) + +Successful and partial-success `restore` responses carry two additional +fields surfacing the subset-restore surface landed in v1.5: + +- **`selectionFilter`** — type `null | { "mode": "subset", "ids": string[] }`. + Present on `status: "success"` and `status: "partial-success"`. `null` when + the invocation restored the full inventory. Populated as + `{ "mode": "subset", "ids": [...] }` for the three subset entry points: + `--interactive`, `--name ` (single-match), and `--all-matching `. + `ids` are op-shaped canonical identifier strings — one per restored operation. + Three shapes exist, matching the three op types: + - **Archive ops** (agents, skills, commands): `:` — + e.g. `agent:/home/user/.claude/ccaudit/archived/code-reviewer.md` + - **Disable ops** (MCP servers): `mcp::` — + e.g. `mcp:/home/user/.claude.json:ccaudit-disabled:playwright` + - **Memory ops** (flag / refresh): `memory:::` — + e.g. `memory:flag:/home/user/.claude/projects/myproject/CLAUDE.md:3fa85f64-5717-4562-b3fc-2c963f66afa6` +- **`skipped`** — type `Array<{ "reason": "source_exists", "path": string, "canonicalId": string }>`. + Always present on success + partial-success (may be empty `[]`). One entry + per item whose source path already existed on disk at restore time — those + items are never overwritten (safety invariant). Each entry is also surfaced + as a single stderr warning line: `warning: skipped — source already exists`. + `canonicalId` follows the same op-shaped format as `selectionFilter.ids`. + +```json +"selectionFilter": { + "mode": "subset", + "ids": [ + "agent:/home/user/.claude/ccaudit/archived/code-reviewer.md", + "mcp:/home/user/.claude.json:ccaudit-disabled:playwright" + ] +}, +"skipped": [ + { + "reason": "source_exists", + "path": "/home/user/.claude/agents/code-reviewer.md", + "canonicalId": "agent:/home/user/.claude/ccaudit/archived/code-reviewer.md" + } +] +``` + +> **Manifest casing exception.** Manifest JSONL headers intentionally keep +> snake_case fields such as `manifest.header.selection_filter` for backward +> compatibility with v1.5 dry-run/bust manifests. Public `--json` envelopes use +> camelCase and do not expose the manifest header casing directly. + +> **Pre-dispatch validation envelope.** Restore preflight failures such as +> mutually-exclusive flags, `CCAUDIT_NO_INTERACTIVE`, TTY refusal, no-match, or +> ambiguity emit the standard envelope when `--json` is active: +> `{ "meta": { "command": "restore", "exitCode": , ... }, "error": "..." }`. +> Without `--json`, the same message is written to stderr. + +### Additive field: `filteredStaleCount` (v1.5, Phase 8.2) + +`restore` responses on `status: "success"`, `status: "partial-success"`, +and `status: "list"` carry an additive `filteredStaleCount` field: + +- **Type:** non-negative integer (number). +- **Semantics:** count of archive ops suppressed from the listing + because their `archive_path` is missing on disk AND their + `source_path` is present — i.e. items that have already been + restored (or were never on disk post-bust) and whose manifest + records would otherwise re-appear forever as no-op + `already_at_source` entries. The filter is scoped to archive ops + only; memory (flag/refresh) and MCP (disable) ops are never + suppressed. Archive ops with BOTH paths missing are kept listed so + the restore executor can surface the fail-loud signal. +- **Where it appears:** success, partial-success, and list response + variants. Defaults to `0` when nothing is filtered. Absent on + pre-v1.5 envelopes; consumers must tolerate `undefined`. +- **Envelope shape note:** the field sits at the data root + (`envelope.filteredStaleCount`), not nested under `counts` or a + `summary` object — the restore envelope flattens data alongside + `status`, `selectionFilter`, and `skipped` rather than grouping + them under a `summary` key. + +```json +"status": "list", +"filteredStaleCount": 3, +"entries": [ ... ] +``` + +> Additive, non-breaking. No pre-v1.5 field semantics change. + **`reclaim`** → `counts.reclaim: { orphansDetected, reclaimed, skipped, failed }` ```json @@ -88,6 +173,222 @@ Bust, restore, and reclaim emit a `counts` object rather than an `items` array. > `skipped` = files whose inferred source path already existed (safety invariant: never overwritten). +## Purge (v1.5, Phase 9 SC6) + +`ccaudit purge-archive` drains `~/.claude/ccaudit/archived/`. It emits an +additive top-level `purge` namespace in the JSON envelope (no changes to +`bust` / `restore` / `reclaim` shapes). + +```json +"purge": { + "summary": { + "purgedCount": 2, + "reclaimedCount": 1, + "skippedOccupiedCount": 1, + "staleFilteredCount": 1 + }, + "failures": [], + "dryRun": false, + "manifestPath": "/home/u/.claude/ccaudit/manifests/purge-2026-04-22T16-00-00-000Z-abcd.jsonl" +} +``` + +| Field | Type | Description | +| ------------------------------------ | ----------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `purge.summary.purgedCount` | number | Count of archive files dropped (source_occupied) + stale follow-up ops appended (stale_archive_missing). Always `purgedCount = skippedOccupiedCount + staleFilteredCount`. | +| `purge.summary.reclaimedCount` | number | Count of archives moved back to source (source was free). | +| `purge.summary.skippedOccupiedCount` | number | Subset of `purgedCount`: the archive was unlinked because the source path already existed. Source is never overwritten. | +| `purge.summary.staleFilteredCount` | number | Subset of `purgedCount`: the archive file was already missing; only the follow-up op is appended (Phase 8.2 staleness shape). | +| `purge.failures[]` | `Array<{ path: string; reason: string }>` | Per-item failures. Empty `[]` on full success. Partial failures do NOT change the exit code — the CLI still exits 0 as long as ≥1 item worked. | +| `purge.dryRun` | boolean | True when `--dry-run` (the default) was active. False when `--yes` executed real mutations. | +| `purge.manifestPath` | `string \| null` | Absolute path of the `purge--.jsonl` follow-up manifest (only real runs that produced ≥1 mutation). `null` on dry-run or no-op. | +| `purge.manifestErrors[]` | `Array<{ path: string; reason: string }>` | Unreadable manifest files skipped while building the purge plan. Present only when at least one manifest was skipped. | + +**Scope.** Archive ops only. Flag ops (memory frontmatter) and disable ops (MCP key-rename `name → ccaudit-disabled:name`) are **never** touched by purge. `classifyArchiveOps` filters by `op_type === 'archive'`. + +**Safety gate.** `--yes` is required for real purge; `--dry-run` is the default. Passing both together errors on stderr with `flags are mutually exclusive: --dry-run, --yes` and exits 1. + +### `archive_purge` manifest op (append-only) + +Real purge runs write a fresh `purge--.jsonl` manifest (sibling to `bust-*.jsonl` under `~/.claude/ccaudit/manifests/`). Each successful mutation records one `archive_purge` op — the **original `ArchiveOp` is never rewritten**; dedup in restore's manifest union suppresses originals whose `op_id` matches a follow-up op's `original_op_id`. + +```json +{ + "op_id": "8b2e1c13-...", + "op_type": "archive_purge", + "timestamp": "2026-04-22T16:00:00.000Z", + "status": "completed", + "original_op_id": "op-a-reclaim", + "purged": true, + "reason": "reclaimed" +} +``` + +| Field | Type | Description | +| ---------------- | ------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | +| `op_type` | `"archive_purge"` (string literal) | Follow-up op; never a rewrite. | +| `original_op_id` | string | The `op_id` of the original `ArchiveOp` this record supersedes. Used by `restore`'s dedup suppression. | +| `purged` | `true` (boolean literal) | Always `true`; reserved shape field for future extension. | +| `reason` | `"reclaimed" \| "source_occupied" \| "stale_archive_missing"` | Matches the classifier branch that produced the op. | + +## Bust summary (v1.5) + +Successful `bust` responses carry a `summary` object alongside `counts`: + +```json +"summary": { + "beforeTokens": 63722, + "freedTokens": 12800, + "totalPlannedTokens": 63722, + "afterTokens": 50922, + "pctWindow": 6, + "healthBefore": 23, + "healthAfter": 71, + "gradeBefore": "Poor", + "gradeAfter": "Good", + "checkpointTimestamp": "2026-04-18T09:42:11.000Z", + "checkpointMcpRegime": "eager" +} +``` + +| Field | Type | Description | +| --------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------- | +| `beforeTokens` | number | Grand-total ghost token overhead at dry-run checkpoint time. | +| `freedTokens` | number | Tokens actually freed by this bust. _Behavior changed in v1.5 — see note below._ | +| `totalPlannedTokens` | number | _Additive since v1.5._ Full-plan token figure preserved from the dry-run checkpoint; unaffected by subset filtering. | +| `afterTokens` | number | `max(0, beforeTokens - freedTokens)`. | +| `pctWindow` | number | `freedTokens` as a percentage of a 200 000-token context window. | +| `healthBefore` | number | Composite health score (0–100) before the bust. | +| `healthAfter` | number | Composite health score (0–100) after the bust. | +| `gradeBefore` | string | Letter grade label for `healthBefore` (e.g. `Poor`, `Good`). | +| `gradeAfter` | string | Letter grade label for `healthAfter`. | +| `checkpointTimestamp` | string | ISO 8601 UTC timestamp of the dry-run checkpoint that gated this bust. | +| `checkpointMcpRegime` | string | `'eager' \| 'deferred' \| 'unknown'` — regime pinned at dry-run time so Before/After totals stay consistent across steps. | + +### `freedTokens` behavior change in v1.5 + +In v1.4.x, `freedTokens` was always the full-inventory plan total because +every bust was full-inventory. v1.5 introduces subset busts via the +`--interactive` TUI picker and the `CCAUDIT_SELECT_IDS` env hook. When a +subset bust runs, `freedTokens` now reflects the subset-accurate figure +(sum of per-item token estimates across the archived subset only). + +The companion `totalPlannedTokens` field preserves the full-plan figure +from the checkpoint so dashboards can still answer "what was the full +opportunity?" after a subset bust. + +Consumers that compared `freedTokens` across runs MUST now check +`manifest.header.selection_filter.mode` (see next section) to distinguish +subset vs full-inventory busts. For full-inventory busts (the default +non-interactive path), `freedTokens === totalPlannedTokens` and the v1.4 +contract is preserved. + +## Manifest header (v1.5) + +Every manifest record emitted by `bust` carries a header with a +`selection_filter` field that identifies whether the bust was a full or +subset operation. + +```json +"selection_filter": { "mode": "full" } +``` + +```json +"selection_filter": { + "mode": "subset", + "ids": ["agent:code-reviewer", "mcp-server:playwright", "skill:my-skill"] +} +``` + +| Field | Type | Description | +| ----------------------- | -------------------- | --------------------------------------------------------------------------------------------------------------------------------- | +| `selection_filter.mode` | `'full' \| 'subset'` | `'full'` for default non-interactive busts and empty-selection abort paths. `'subset'` when `--interactive` or env hook filtered. | +| `selection_filter.ids` | `string[]` | Present only when `mode === 'subset'`. Canonical item IDs actually archived, sorted lexicographically for determinism. | + +Older manifests (pre-v1.5) omit the field entirely; readers should treat +its absence as `{ mode: 'full' }`. + +### Additive contract + +All v1.5 envelope additions are **additive**. Consumers reading a v1.4 +envelope must tolerate the absence of `totalPlannedTokens` and +`selection_filter`; consumers reading a v1.5+ envelope must tolerate their +presence. The `--json` envelope never drops or renames fields without a +major-version bump flagged in `CHANGELOG.md`. + +### Example: full-inventory bust + +```json +{ + "meta": { "command": "bust", "version": "1.5.0", "exitCode": 0 }, + "manifestPath": "~/.claude/ccaudit/manifests/bust-2026-04-19T10:00:00Z.jsonl", + "counts": { + "archive": { "agents": 116, "skills": 74, "failed": 0 }, + "disable": { "completed": 4, "failed": 0 }, + "flag": { "completed": 6, "refreshed": 0, "failed": 0 } + }, + "summary": { + "beforeTokens": 63722, + "freedTokens": 63722, + "totalPlannedTokens": 63722, + "afterTokens": 0, + "pctWindow": 32, + "healthBefore": 23, + "healthAfter": 91, + "gradeBefore": "Poor", + "gradeAfter": "Excellent", + "checkpointTimestamp": "2026-04-19T09:59:30.000Z", + "checkpointMcpRegime": "eager" + } +} +``` + +Corresponding `manifest.header.selection_filter`: + +```json +{ "mode": "full" } +``` + +### Example: subset bust (v1.5 --interactive) + +```json +{ + "meta": { "command": "bust", "version": "1.5.0", "exitCode": 0 }, + "manifestPath": "~/.claude/ccaudit/manifests/bust-2026-04-19T10:05:00Z.jsonl", + "counts": { + "archive": { "agents": 2, "skills": 1, "failed": 0 }, + "disable": { "completed": 1, "failed": 0 }, + "flag": { "completed": 0, "refreshed": 0, "failed": 0 } + }, + "summary": { + "beforeTokens": 63722, + "freedTokens": 8400, + "totalPlannedTokens": 63722, + "afterTokens": 55322, + "pctWindow": 4, + "healthBefore": 23, + "healthAfter": 41, + "gradeBefore": "Poor", + "gradeAfter": "Fair", + "checkpointTimestamp": "2026-04-19T10:04:45.000Z", + "checkpointMcpRegime": "eager" + } +} +``` + +Corresponding `manifest.header.selection_filter`: + +```json +{ + "mode": "subset", + "ids": ["agent:code-reviewer", "agent:pencil-dev", "mcp-server:playwright", "skill:my-skill"] +} +``` + +Note: `summary.freedTokens` (8400) < `summary.totalPlannedTokens` (63722) +because only a subset was archived. `selection_filter.mode === 'subset'` +is the signal that the two figures will differ. + ## Item categories The `category` field on each item is one of: diff --git a/eslint.config.ts b/eslint.config.ts index d37f782..f431678 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -9,6 +9,9 @@ export default tseslint.config( parserOptions: { projectService: { allowDefaultProject: ['apps/ccaudit/scripts/*.mjs', 'scripts/*.mjs'], + // Bumped from default 8 to accommodate the growing number of .mjs + // build/release scripts under apps/ccaudit/scripts (currently 5+). + maximumDefaultProjectFileMatchCount_THIS_WILL_SLOW_DOWN_LINTING: 20, }, tsconfigRootDir: import.meta.dirname, }, @@ -35,6 +38,6 @@ export default tseslint.config( }, }, { - ignores: ['**/dist/**', '**/node_modules/**', '**/coverage/**'], + ignores: ['**/dist/**', '**/node_modules/**', '**/coverage/**', '.claude/**'], }, ); diff --git a/package.json b/package.json index 9dd66d4..b94f398 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ "format": "oxfmt --write .", "format:check": "oxfmt --check .", "typecheck": "pnpm -r typecheck", - "verify": "pnpm -w typecheck && pnpm -w lint && pnpm -w build && pnpm -w test && pnpm format:check" + "verify": "pnpm -w typecheck && pnpm -w lint && pnpm -w build && node apps/ccaudit/scripts/bundle-smoke-test.mjs && CCAUDIT_PHASE_BASELINE=apps/ccaudit/scripts/bundle-baseline-phase-09.txt CCAUDIT_PHASE_BUDGET_BYTES=10240 node apps/ccaudit/scripts/bundle-size-check.mjs && pnpm -w test && pnpm format:check" }, "devDependencies": { "@eslint/js": "catalog:", diff --git a/packages/internal/src/cli/env.ts b/packages/internal/src/cli/env.ts new file mode 100644 index 0000000..72a8e6f --- /dev/null +++ b/packages/internal/src/cli/env.ts @@ -0,0 +1,58 @@ +/** + * Phase 9 D2 — env escape hatch helper. + * + * `CCAUDIT_NO_INTERACTIVE=1` (or `=true`, case-insensitive) gates all + * interactivity globally. The helper is a pure read of `process.env` with + * a strict whitelist per D2 / T-09-01: only "1" or "true" (trimmed, + * case-insensitive) count as truthy. Everything else — including "yes", + * "on", "0", "false", "", undefined, or whitespace-only — is false. + * + * Both `ghost` and `restore` route their `--interactive` and auto-open + * decisions through this helper so the refusal behavior is uniform. + */ +export function isNoInteractiveEnv(env: NodeJS.ProcessEnv = process.env): boolean { + const raw = env['CCAUDIT_NO_INTERACTIVE']; + if (raw === undefined) return false; + const v = raw.trim().toLowerCase(); + return v === '1' || v === 'true'; +} + +if (import.meta.vitest) { + const { describe, it, expect } = import.meta.vitest; + + describe('isNoInteractiveEnv', () => { + it('true for "1"', () => { + expect(isNoInteractiveEnv({ CCAUDIT_NO_INTERACTIVE: '1' })).toBe(true); + }); + it('true for "true"', () => { + expect(isNoInteractiveEnv({ CCAUDIT_NO_INTERACTIVE: 'true' })).toBe(true); + }); + it('true for "TRUE" (case-insensitive)', () => { + expect(isNoInteractiveEnv({ CCAUDIT_NO_INTERACTIVE: 'TRUE' })).toBe(true); + }); + it('true for " true " (trimmed)', () => { + expect(isNoInteractiveEnv({ CCAUDIT_NO_INTERACTIVE: ' true ' })).toBe(true); + }); + it('false for "0"', () => { + expect(isNoInteractiveEnv({ CCAUDIT_NO_INTERACTIVE: '0' })).toBe(false); + }); + it('false for "false"', () => { + expect(isNoInteractiveEnv({ CCAUDIT_NO_INTERACTIVE: 'false' })).toBe(false); + }); + it('false for unset', () => { + expect(isNoInteractiveEnv({})).toBe(false); + }); + it('false for empty string', () => { + expect(isNoInteractiveEnv({ CCAUDIT_NO_INTERACTIVE: '' })).toBe(false); + }); + it('false for whitespace only', () => { + expect(isNoInteractiveEnv({ CCAUDIT_NO_INTERACTIVE: ' ' })).toBe(false); + }); + it('false for "yes" (strict whitelist — T-09-01)', () => { + expect(isNoInteractiveEnv({ CCAUDIT_NO_INTERACTIVE: 'yes' })).toBe(false); + }); + it('false for "on" (strict whitelist — T-09-01)', () => { + expect(isNoInteractiveEnv({ CCAUDIT_NO_INTERACTIVE: 'on' })).toBe(false); + }); + }); +} diff --git a/packages/internal/src/cli/index.ts b/packages/internal/src/cli/index.ts new file mode 100644 index 0000000..7758f30 --- /dev/null +++ b/packages/internal/src/cli/index.ts @@ -0,0 +1 @@ +export { isNoInteractiveEnv } from './env.ts'; diff --git a/packages/internal/src/index.ts b/packages/internal/src/index.ts index 61c9994..15f133d 100644 --- a/packages/internal/src/index.ts +++ b/packages/internal/src/index.ts @@ -74,12 +74,19 @@ export { toGhostItems, LIKELY_GHOST_MS, DEFINITE_GHOST_MS, + // Phase 6 pure helpers + presentPath, + computeConfigRefs, + compareConfigRef, + isProtected, } from './scanner/index.ts'; export type { InventoryItem, ScanResult, ScannerOptions, InvocationSummary, + FrameworkProtection, + ScannedMcpServer, } from './scanner/index.ts'; export type { ClaudeConfig } from './scanner/index.ts'; @@ -96,6 +103,8 @@ export { formatTokenEstimate, formatTotalOverhead, formatSavingsLine, + formatTokensApprox, + sumSelectionTokens, listMcpTools, measureMcpTokens, BYTES_PER_TOKEN, @@ -145,7 +154,9 @@ export type { // Remediation module (Phase 7) export { buildChangePlan, + filterChangePlan, calculateDryRunSavings, + canonicalItemId, computeGhostHash, resolveCheckpointPath, writeCheckpoint, @@ -169,9 +180,22 @@ export { resolveManifestPath, patchFrontmatter, atomicWriteJson, + atomicWriteText, defaultProcessDeps, + // Phase 3.2 Plan 04 — entry-time preflight in runInteractiveGhostFlow needs + // the detector + parent-chain walker to mirror bust.ts:264-286 BEFORE the + // picker opens. Already re-exported from remediation/index.ts; surfaced + // here so the CLI can import alongside runBust without subpath reach-in. + detectClaudeProcesses, + walkParentChain, +} from './remediation/index.ts'; +export type { + BustResult, + BustDeps, + BustCounts, + CeremonyResult, + SelectionFilter, } from './remediation/index.ts'; -export type { BustResult, BustDeps, BustCounts, CeremonyResult } from './remediation/index.ts'; // Remediation module (v1.3.0 Phase 4 — framework-as-unit bust protection) // Surfaced here so the CLI layer (apps/ccaudit/src/cli/commands/ghost.ts) @@ -194,6 +218,11 @@ export { readManifest, removeFrontmatterKeys, setFrontmatterValue, + dedupManifestOps, + collectRestoreableItems, + matchByName, + isStaleArchiveOp, + filterRestoreableItems, } from './remediation/index.ts'; export type { RestoreDeps, @@ -219,6 +248,31 @@ export type { OrphanEntry, } from './remediation/index.ts'; +// Phase 9 SC6: purge-archive domain core + purge manifest writer. +// CLI subcommand (Plan 09-04) consumes these via `@ccaudit/internal`. +export { + classifyArchiveOps, + executePurge, + writePurgeManifest, + openPurgeManifestWriter, + closePurgeManifestWriter, + resolvePurgeManifestPath, + buildPurgeManifestHeader, + buildArchivePurgeOp, +} from './remediation/index.ts'; +export type { + PurgePlan, + PurgeResult, + PurgeSummary, + PurgeFailure, + DropReason, + ExecutePurgeDeps, + ArchivePurgeOp, +} from './remediation/index.ts'; + +// Phase 9: CLI env helpers +export { isNoInteractiveEnv } from './cli/index.ts'; + // Phase 6: append-only history log export { HistoryWriter, recordHistory } from './history/index.ts'; export type { diff --git a/packages/internal/src/remediation/__fixtures__/ghost-hash-golden.json b/packages/internal/src/remediation/__fixtures__/ghost-hash-golden.json new file mode 100644 index 0000000..25ff478 --- /dev/null +++ b/packages/internal/src/remediation/__fixtures__/ghost-hash-golden.json @@ -0,0 +1,76 @@ +{ + "_comment": "Frozen input/output pair for computeGhostHash. If this hash changes, the hash contract has drifted — that is a BREAKING change requiring CHANGELOG + checkpoint-version bump. DO NOT edit expectedHash casually.", + "input": [ + { + "item": { + "name": "agent-alpha", + "path": "/synthetic/agents/alpha.md", + "scope": "global", + "category": "agent", + "projectPath": null, + "mtimeMs": 1700000000000 + }, + "tier": "definite-ghost", + "lastUsed": null, + "invocationCount": 0, + "tokenEstimate": { "tokens": 100, "confidence": "estimated", "source": "test" } + }, + { + "item": { + "name": "skill-beta", + "path": "/synthetic/skills/beta/SKILL.md", + "scope": "project", + "category": "skill", + "projectPath": "/synthetic/proj-a", + "mtimeMs": 1700000001000 + }, + "tier": "definite-ghost", + "lastUsed": null, + "invocationCount": 0, + "tokenEstimate": { "tokens": 200, "confidence": "estimated", "source": "test" } + }, + { + "item": { + "name": "mcp-gamma", + "path": "/synthetic/.claude.json", + "scope": "global", + "category": "mcp-server", + "projectPath": null, + "mtimeMs": 1700000002000 + }, + "tier": "likely-ghost", + "lastUsed": null, + "invocationCount": 0, + "tokenEstimate": { "tokens": 300, "confidence": "estimated", "source": "test" } + }, + { + "item": { + "name": "mcp-delta", + "path": "/synthetic/.claude.json", + "scope": "global", + "category": "mcp-server", + "projectPath": null, + "mtimeMs": 1700000002000 + }, + "tier": "definite-ghost", + "lastUsed": null, + "invocationCount": 0, + "tokenEstimate": { "tokens": 400, "confidence": "estimated", "source": "test" } + }, + { + "item": { + "name": "CLAUDE.md", + "path": "/synthetic/memory/CLAUDE.md", + "scope": "project", + "category": "memory", + "projectPath": "/synthetic/proj-a", + "mtimeMs": 1700000003000 + }, + "tier": "definite-ghost", + "lastUsed": null, + "invocationCount": 0, + "tokenEstimate": { "tokens": 500, "confidence": "estimated", "source": "test" } + } + ], + "expectedHash": "sha256:10e831d9fd26785cb6ab7959813190024b3e1ae8d240c64425a6cec27c6a6287" +} diff --git a/packages/internal/src/remediation/archive-move.ts b/packages/internal/src/remediation/archive-move.ts new file mode 100644 index 0000000..da01200 --- /dev/null +++ b/packages/internal/src/remediation/archive-move.ts @@ -0,0 +1,203 @@ +// @ccaudit/internal — shared archive→source move helper (Phase 9 Plan 03) +// +// Lifts the "move archive back to its original source path" logic out of +// reclaim.ts so both `reclaim` and `purge-archive` (Phase 9 SC6) share a +// single implementation site. +// +// SAFETY INVARIANT (CLAUDE.md §Safety invariants): +// - NEVER overwrite a file at the inferred source path. If the source +// already exists, the move is refused and a Result.err is returned. +// - The caller decides whether to skip-with-warning (reclaim) or classify +// as drop/source_occupied (purge). This helper does not mutate fs +// unless the refuse-if-source-exists precondition passes. +// +// I/O is fully injected so callers can share real node:fs/promises primitives +// or test doubles. Zero behavior change from the previous inline reclaim code. +// +// Note: We intentionally do NOT import `writeFilePreservingMtime` here. +// Reclaim/purge restore content via `rename` (same-filesystem move), which +// preserves mtime by definition of the syscall. Cross-filesystem renames +// on EXDEV are not observed in the archive root → home layout (both live +// under ~/.claude), so a copy+utimes fallback is not warranted in v1.5. + +import path from 'node:path'; +import { Result } from '@praha/byethrow'; + +// -- Deps ----------------------------------------------------------- + +export interface ArchiveMoveDeps { + /** True iff the path exists (any kind). */ + pathExists: (p: string) => Promise; + /** Create directory hierarchy. */ + mkdirRecursive: (dir: string) => Promise; + /** Move file from `from` to `to`. Caller guarantees parent dir exists. */ + renameFile: (from: string, to: string) => Promise; +} + +// -- API ------------------------------------------------------------ + +export interface MoveArchiveInput { + archivePath: string; + sourcePath: string; +} + +export interface MoveArchiveOk { + moved: true; +} + +/** Distinguishable failure reasons for callers that need to classify. */ +export type MoveArchiveFailure = + | { reason: 'source_exists'; message: string } + | { reason: 'archive_missing'; message: string } + | { reason: 'io_error'; message: string }; + +/** + * Move a file from the archive to its original source path. + * + * Preconditions: + * - `archivePath` must exist (else `archive_missing`) + * - `sourcePath` must NOT exist (else `source_exists`) — INVARIANT + * + * On success: + * - Parent directory of `sourcePath` is created (recursive, mode determined by caller — default 0o755) + * - File is moved via `renameFile` (mtime preserved by syscall contract) + */ +export async function moveArchiveToSource( + input: MoveArchiveInput, + deps: ArchiveMoveDeps, +): Promise> { + const { archivePath, sourcePath } = input; + + // 1. Archive must still be on disk (defensive: caller typically checked, + // but another process may have moved it between classification and exec). + const archiveExists = await deps.pathExists(archivePath); + if (!archiveExists) { + return Result.fail({ + reason: 'archive_missing', + message: `archive file not found at ${archivePath}`, + }); + } + + // 2. INVARIANT: never overwrite existing source. + const sourceExists = await deps.pathExists(sourcePath); + if (sourceExists) { + return Result.fail({ + reason: 'source_exists', + message: `refusing to overwrite existing source at ${sourcePath}`, + }); + } + + // 3. Ensure parent dir, then rename. + try { + await deps.mkdirRecursive(path.dirname(sourcePath)); + await deps.renameFile(archivePath, sourcePath); + return Result.succeed({ moved: true }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return Result.fail({ + reason: 'io_error', + message: `rename ${archivePath} → ${sourcePath}: ${message}`, + }); + } +} + +// -- In-source unit tests ------------------------------------------ + +if (import.meta.vitest) { + const { describe, it, expect, vi } = import.meta.vitest; + + const baseDeps = (overrides: Partial = {}): ArchiveMoveDeps => ({ + pathExists: overrides.pathExists ?? (async () => false), + mkdirRecursive: overrides.mkdirRecursive ?? (async () => undefined), + renameFile: overrides.renameFile ?? (async () => undefined), + }); + + describe('moveArchiveToSource', () => { + it('happy path: archive exists, source free → renames and returns ok', async () => { + const renameFile = vi.fn(async () => undefined); + const mkdirRecursive = vi.fn(async () => undefined); + const pathExists = vi.fn(async (p: string) => p.includes('archived')); + const result = await moveArchiveToSource( + { + archivePath: '/h/.claude/ccaudit/archived/agents/foo.md', + sourcePath: '/h/.claude/agents/foo.md', + }, + baseDeps({ renameFile, mkdirRecursive, pathExists }), + ); + expect(Result.isSuccess(result)).toBe(true); + if (Result.isSuccess(result)) { + expect(result.value.moved).toBe(true); + } + expect(renameFile).toHaveBeenCalledWith( + '/h/.claude/ccaudit/archived/agents/foo.md', + '/h/.claude/agents/foo.md', + ); + expect(mkdirRecursive).toHaveBeenCalledWith('/h/.claude/agents'); + }); + + it('refuses when source already exists (never overwrites)', async () => { + const renameFile = vi.fn(async () => undefined); + const pathExists = vi.fn(async () => true); // both exist + const result = await moveArchiveToSource( + { archivePath: '/a', sourcePath: '/b' }, + baseDeps({ renameFile, pathExists }), + ); + expect(Result.isFailure(result)).toBe(true); + if (Result.isFailure(result)) { + expect(result.error.reason).toBe('source_exists'); + } + expect(renameFile).not.toHaveBeenCalled(); + }); + + it('fails fast when archive is missing', async () => { + const renameFile = vi.fn(async () => undefined); + const pathExists = vi.fn(async () => false); // archive missing + const result = await moveArchiveToSource( + { archivePath: '/a', sourcePath: '/b' }, + baseDeps({ renameFile, pathExists }), + ); + expect(Result.isFailure(result)).toBe(true); + if (Result.isFailure(result)) { + expect(result.error.reason).toBe('archive_missing'); + } + expect(renameFile).not.toHaveBeenCalled(); + }); + + it('wraps rename I/O errors as io_error', async () => { + const pathExists = vi.fn(async (p: string) => p === '/h/.claude/ccaudit/archived/a.md'); + const renameFile = vi.fn(async () => { + throw Object.assign(new Error('EACCES'), { code: 'EACCES' }); + }); + const result = await moveArchiveToSource( + { archivePath: '/h/.claude/ccaudit/archived/a.md', sourcePath: '/h/.claude/a.md' }, + baseDeps({ pathExists, renameFile }), + ); + expect(Result.isFailure(result)).toBe(true); + if (Result.isFailure(result)) { + expect(result.error.reason).toBe('io_error'); + expect(result.error.message).toContain('EACCES'); + } + }); + + it('creates parent dir before renaming', async () => { + const calls: string[] = []; + const pathExists = async (p: string) => p.includes('archived'); + const mkdirRecursive = async (d: string) => { + calls.push(`mkdir:${d}`); + }; + const renameFile = async (from: string, to: string) => { + calls.push(`rename:${from}->${to}`); + }; + const result = await moveArchiveToSource( + { + archivePath: '/h/.claude/ccaudit/archived/x/y/z.md', + sourcePath: '/h/.claude/x/y/z.md', + }, + { pathExists, mkdirRecursive, renameFile }, + ); + expect(Result.isSuccess(result)).toBe(true); + expect(calls[0]).toBe('mkdir:/h/.claude/x/y'); + expect(calls[1]).toBe('rename:/h/.claude/ccaudit/archived/x/y/z.md->/h/.claude/x/y/z.md'); + }); + }); +} diff --git a/packages/internal/src/remediation/atomic-write.ts b/packages/internal/src/remediation/atomic-write.ts index 4d9535d..a01fa25 100644 --- a/packages/internal/src/remediation/atomic-write.ts +++ b/packages/internal/src/remediation/atomic-write.ts @@ -66,6 +66,14 @@ export async function atomicWriteJson( targetPath: string, value: T, options: AtomicWriteOptions = {}, +): Promise { + await atomicWriteRaw(targetPath, JSON.stringify(value, null, 2), options); +} + +async function atomicWriteRaw( + targetPath: string, + body: string, + options: AtomicWriteOptions = {}, ): Promise { const opts = { ...DEFAULTS, ...options }; const dir = path.dirname(targetPath); @@ -77,8 +85,6 @@ export async function atomicWriteJson( // Parent dir with restrictive mode. Mode is a no-op on Windows. await mkdir(dir, { recursive: true, mode: opts.dirMode }); - const body = JSON.stringify(value, null, 2); - try { await writeFile(tmpPath, body, { mode: opts.mode, encoding: 'utf8' }); await renameWithRetry(tmpPath, targetPath, opts); @@ -94,6 +100,23 @@ export async function atomicWriteJson( } } +/** + * Atomically write a pre-formatted text string to `targetPath` via a tmp file + * + rename, with the same Windows EPERM retry semantics as `atomicWriteJson`. + * + * Use this instead of `atomicWriteJson` when the caller has already produced + * the final serialized content and must preserve exact byte formatting (e.g. + * surgical MCP key renames in `~/.claude.json` that must leave unmodified keys + * byte-identical to the original — INV-S1). + */ +export async function atomicWriteText( + targetPath: string, + text: string, + options: AtomicWriteOptions = {}, +): Promise { + await atomicWriteRaw(targetPath, text, options); +} + // -- renameWithRetry --------------------------------------------- /** @@ -199,6 +222,7 @@ if (import.meta.vitest) { mkdtemp, rm, readFile, + readdir, stat: fsStat, chmod, mkdir: mk, @@ -267,6 +291,48 @@ if (import.meta.vitest) { ); }); + describe('atomicWriteText', () => { + let tmp: string; + beforeEach(async () => { + tmp = await mkdtemp(path.join(tmpdir(), 'atomic-write-text-')); + }); + afterEach(async () => { + await rm(tmp, { recursive: true, force: true }); + }); + + it('round-trips caller-provided text byte-for-byte', async () => { + const target = path.join(tmp, 'raw.txt'); + const body = '{ "keep" : [1,2,3],\n "spacing": "verbatim"\n}\n'; + await atomicWriteText(target, body); + const raw = await readFile(target, 'utf8'); + expect(raw).toBe(body); + }); + + it('creates parent directories recursively', async () => { + const target = path.join(tmp, 'nested', 'dir', 'raw.txt'); + await atomicWriteText(target, 'hello\n'); + const s = await fsStat(target); + expect(s.isFile()).toBe(true); + await expect(readFile(target, 'utf8')).resolves.toBe('hello\n'); + }); + + it('unlinks tmp file when rename fails', async () => { + const target = path.join(tmp, 'existing-dir'); + await mk(target, { recursive: true }); + const randSpy = vi.spyOn(Math, 'random').mockReturnValue(0.123456789); + const tmpPath = `${target}.tmp-${process.pid}-${Math.random() + .toString(36) + .slice(2, 10) + .padEnd(8, '0')}`; + try { + await expect(atomicWriteText(target, 'cannot replace a directory')).rejects.toThrow(); + } finally { + randSpy.mockRestore(); + } + await expect(readdir(tmp)).resolves.not.toContain(path.basename(tmpPath)); + }); + }); + describe('renameWithRetry', () => { it('succeeds immediately when rename works first try', async () => { const tmp2 = await mkdtemp(path.join(tmpdir(), 'rename-retry-')); diff --git a/packages/internal/src/remediation/bust.ts b/packages/internal/src/remediation/bust.ts index bdaefb8..9895e98 100644 --- a/packages/internal/src/remediation/bust.ts +++ b/packages/internal/src/remediation/bust.ts @@ -30,8 +30,14 @@ import { rename } from 'node:fs/promises'; import path from 'node:path'; import { createInterface } from 'node:readline'; import type { TokenCostResult } from '../token/types.ts'; -import { type ReadCheckpointResult } from './checkpoint.ts'; -import { buildChangePlan, type ChangePlan, type ChangePlanItem } from './change-plan.ts'; +import { canonicalItemId, type ReadCheckpointResult } from './checkpoint.ts'; +import { + buildChangePlan, + filterChangePlan, + type ChangePlan, + type ChangePlanItem, +} from './change-plan.ts'; +import { calculateDryRunSavings } from './savings.ts'; import { buildArchivePath, buildDisabledMcpKey } from './collisions.ts'; import { detectClaudeProcesses, @@ -51,6 +57,7 @@ import { buildRefreshOp, buildSkippedOp, type ManifestOp, + type SelectionFilter, } from './manifest.ts'; // -- Result types ------------------------------------------------- @@ -81,6 +88,9 @@ export type BustResult = summary: { beforeTokens: number; freedTokens: number; + /** Full-plan token figure preserved for consumers when a subset bust filtered the plan. + * Equals `freedTokens` on full-inventory bust. */ + totalPlannedTokens: number; afterTokens: number; pctWindow: number; healthBefore: number; @@ -111,7 +121,7 @@ export type BustResult = /** Per-category op counters threaded through the pipeline for the manifest footer. */ export interface BustCounts { - archive: { agents: number; skills: number; failed: number }; + archive: { agents: number; skills: number; commands: number; failed: number }; disable: { completed: number; failed: number }; flag: { completed: number; failed: number; refreshed: number; skipped: number }; } @@ -167,6 +177,8 @@ export interface BustDeps { readFileUtf8: (p: string) => Promise; patchMemoryFrontmatter: (filePath: string, nowIso: string) => Promise; atomicWriteJson: (targetPath: string, value: T) => Promise; + /** Write pre-formatted text atomically. Used by surgical MCP disable (INV-S1). */ + atomicWriteText: (targetPath: string, text: string) => Promise; pathExistsSync: (p: string) => boolean; // Manifest @@ -190,15 +202,40 @@ export interface BustDeps { * BustResult without mutating anything when it fails, so the CLI layer can * cleanly print the error message and exit with the appropriate code. * - * @param opts.yes If true, both D-15 prompts are skipped (the - * --yes-proceed-busting bypass flag from D-16). - * @param opts.deps Full dependency-injection surface. Tests pass a minimal - * BustDeps with fakes for every I/O path; production passes - * real implementations via a buildProductionDeps() helper - * (defined at the CLI command layer, not here). + * @param opts.yes If true, both D-15 prompts are skipped (the + * --yes-proceed-busting bypass flag from D-16). + * @param opts.deps Full dependency-injection surface. Tests pass a minimal + * BustDeps with fakes for every I/O path; production passes + * real implementations via a buildProductionDeps() helper + * (defined at the CLI command layer, not here). + * @param opts.skipCeremony When true, `deps.runCeremony` is NOT called. The interactive + * TUI confirmation (Phase 2) already happened upstream, so the + * 3-prompt readline ceremony is redundant and harmful (stdin + * collision with @clack). Default: false/undefined → ceremony + * runs as before (non-interactive `--dangerously-bust-ghosts` + * path is unchanged). */ -export async function runBust(opts: { yes: boolean; deps: BustDeps }): Promise { - const { yes, deps } = opts; +export async function runBust(opts: { + yes: boolean; + deps: BustDeps; + /** + * Optional subset filter. `undefined` = full-inventory bust (v1.4.0 contract). + * `Set` of canonicalItemId values = subset bust: items whose id is not + * in the set are excluded from the change plan AFTER hash verification. + * `new Set()` (empty) = explicit "select nothing" no-op; manifest is written + * with zero planned_ops and a warning is emitted. This is distinct from + * `undefined` so Phase 2's TUI can deliver an empty selection cleanly. + */ + selectedItems?: Set; + /** + * When true, bypass `deps.runCeremony` entirely. Used by the interactive TUI + * path (Phase 2) which already presented its own confirmation screen before + * calling runBust. The non-interactive `--dangerously-bust-ghosts` path must + * NOT pass this flag — it relies on the ceremony for safety. + */ + skipCeremony?: boolean; +}): Promise { + const { yes, deps, selectedItems, skipCeremony } = opts; const start = Date.now(); // ── Gate 1: checkpoint exists (D-01) ───────────────────────────── @@ -258,15 +295,36 @@ export async function runBust(opts: { yes: boolean; deps: BustDeps }): Promise canonicalItemId(i)); + selectionFilter = { mode: 'subset', ids: actionedIds }; // buildHeader sorts + } + const header = buildHeader({ ccaudit_version: deps.ccauditVersion, checkpoint_ghost_hash: checkpoint.ghost_hash, @@ -287,6 +361,7 @@ export async function runBust(opts: { yes: boolean; deps: BustDeps }): Promise i.category === 'agent'); - const skillItems = plan.archive.filter((i) => i.category === 'skill'); + const agentItems = filteredPlan.archive.filter((i) => i.category === 'agent'); + const skillItems = filteredPlan.archive.filter((i) => i.category === 'skill'); + const commandItems = filteredPlan.archive.filter((i) => i.category === 'command'); for (const item of agentItems) { const op = await archiveOne(item, 'agent', deps, counts); @@ -316,14 +392,18 @@ export async function runBust(opts: { yes: boolean; deps: BustDeps }): Promise 0) { - const disableResult = await disableMcpTransactional(plan.disable, deps, counts); + if (filteredPlan.disable.length > 0) { + const disableResult = await disableMcpTransactional(filteredPlan.disable, deps, counts); if (disableResult.status === 'parse-error') { await writer.close(null); // footer omitted -> Phase 9 partial-bust marker return { @@ -347,7 +427,7 @@ export async function runBust(opts: { yes: boolean; deps: BustDeps }): Promise { - if ((r.item.category === 'agent' || r.item.category === 'skill') && r.tier === 'definite-ghost') - return false; - if (r.item.category === 'mcp-server' && r.tier !== 'used') return false; - return true; - }); + // health after: remove only the items that were actually actioned. + // Unified path for both full-bust and subset-bust: build a Set of actioned paths + // from filteredPlan (which equals fullPlan when selectedItems === undefined). + // This correctly excludes archived commands from the health-after count, + // fixing the M5 regression where full-bust left command ghosts in remainingEnriched. + const actionedPaths = new Set([ + ...filteredPlan.archive.map((i) => i.path), + ...filteredPlan.flag.map((i) => i.path), + ...filteredPlan.disable.map((i) => `${i.name}::${i.path}`), + ]); + const remainingEnriched = enriched.filter((r) => + r.item.category === 'mcp-server' + ? !actionedPaths.has(`${r.item.name}::${r.item.path}`) + : !actionedPaths.has(r.item.path), + ); const healthScoreAfter = calculateHealthScore(remainingEnriched); const healthAfter = healthScoreAfter.score; const gradeAfter = healthScoreAfter.grade; @@ -449,6 +544,7 @@ export async function runBust(opts: { yes: boolean; deps: BustDeps }): Promise { @@ -503,7 +599,8 @@ async function archiveOne( }); } const claudeRoot = path.dirname(categoryRoot); - const categorySegment = category === 'agent' ? 'agents' : 'skills'; + const categorySegment = + category === 'agent' ? 'agents' : category === 'skill' ? 'skills' : 'commands'; const archivedDir = path.join(claudeRoot, 'ccaudit', 'archived', categorySegment); // Read the original bytes BEFORE the rename so the manifest's content @@ -538,6 +635,8 @@ async function archiveOne( if (category === 'skill') { counts.archive.skills += 1; + } else if (category === 'command') { + counts.archive.commands += 1; } else { counts.archive.agents += 1; } @@ -570,8 +669,11 @@ async function archiveOne( * Returns null when the segment is absent (caller treats as a programming * bug and records a failed archive op). */ -function findCategoryRoot(sourcePath: string, category: 'agent' | 'skill'): string | null { - const segment = category === 'agent' ? 'agents' : 'skills'; +function findCategoryRoot( + sourcePath: string, + category: 'agent' | 'skill' | 'command', +): string | null { + const segment = category === 'agent' ? 'agents' : category === 'skill' ? 'skills' : 'commands'; const parts = sourcePath.split(path.sep); for (let i = parts.length - 1; i >= 0; i--) { if (parts[i] === segment) { @@ -581,6 +683,224 @@ function findCategoryRoot(sourcePath: string, category: 'agent' | 'skill'): stri return null; } +// -- Surgical MCP config text patcher (INV-S1 byte-preservation) - + +/** + * Walk the JSON text at document depth 1 and return the byte range of the + * root-level `"mcpServers"` value object `{ start, end }` (where `start` is + * the index of the opening `{` and `end` is one past the closing `}`). + * + * Returns null if the block cannot be located (document is malformed, or the + * key does not exist at the root level). + * + * Purpose: used by patchMcpConfigText to restrict the key-search to the + * global mcpServers block, preventing false matches against identically-named + * servers under `projects..mcpServers` that appear earlier in the + * document (C2 fix). + */ +function findTopLevelMcpServersBlock(text: string): { start: number; end: number } | null { + let depth = 0; + let inString = false; + for (let i = 0; i < text.length; i++) { + const ch = text[i]; + if (inString) { + if (ch === '\\') { + i++; + continue; + } + if (ch === '"') inString = false; + continue; + } + if (ch === '"') { + if (depth === 1 && text.startsWith('"mcpServers"', i)) { + let j = i + '"mcpServers"'.length; + while (j < text.length && /\s/.test(text[j]!)) j++; + if (text[j] !== ':') { + inString = true; + continue; + } + j++; + while (j < text.length && /\s/.test(text[j]!)) j++; + if (text[j] !== '{') return null; + let d = 0; + let inS = false; + for (let k = j; k < text.length; k++) { + const c = text[k]; + if (inS) { + if (c === '\\') { + k++; + continue; + } + if (c === '"') inS = false; + continue; + } + if (c === '"') { + inS = true; + continue; + } + if (c === '{') d++; + else if (c === '}') { + d--; + if (d === 0) return { start: j, end: k + 1 }; + } + } + return null; + } + inString = true; + continue; + } + if (ch === '{') depth++; + else if (ch === '}') depth--; + } + return null; +} + +function findDirectJsonKeyInObject( + text: string, + key: string, + block: { start: number; end: number }, +): { keyStart: number; valueStart: number } | null { + const quotedKey = JSON.stringify(key); + let depth = 0; + let inString = false; + for (let i = block.start; i < block.end; i++) { + const ch = text[i]; + if (inString) { + if (ch === '\\') { + i++; + continue; + } + if (ch === '"') inString = false; + continue; + } + if (ch === '"') { + if (depth === 1 && text.startsWith(quotedKey, i)) { + let j = i + quotedKey.length; + while (j < block.end && /\s/.test(text[j]!)) j++; + if (text[j] === ':') return { keyStart: i, valueStart: j + 1 }; + } + inString = true; + continue; + } + if (ch === '{') depth++; + else if (ch === '}') depth--; + } + return null; +} + +/** + * Apply surgical text-level mutations to a JSON config string so that + * unmodified keys are byte-identical in the output (INV-S1). + * + * Each mutation removes a named key from inside the root-level `mcpServers` + * block (scoped search prevents false matches against project-level entries + * that appear earlier in the document — C2 fix) and appends a new root-level + * `"newKey": ` before the document's closing `}`. + * + * Returns null if any mutation target is not found or the text is malformed, + * signalling the caller to fall back to atomicWriteJson (non-byte-preserving). + * + * Exported for in-source unit testing. + */ +export function patchMcpConfigText( + raw: string, + mutations: Array<{ name: string; newKey: string; value: unknown }>, +): string | null { + let text = raw; + + for (const { name, newKey, value } of mutations) { + // C2: scope the key search to the root-level mcpServers block so a + // project-level `projects.

.mcpServers.` that appears textually + // earlier cannot be matched instead of the global entry. + const block = findTopLevelMcpServersBlock(text); + if (!block) return null; + const found = findDirectJsonKeyInObject(text, name, block); + if (found === null) return null; + const keyStart = found.keyStart; + + // Find the opening `{` of the value object. + let i = found.valueStart; + while (i < text.length && (text[i] === ' ' || text[i] === '\n' || text[i] === '\t')) i++; + if (text[i] !== '{') return null; + + // Walk to the matching closing `}`, respecting JSON string boundaries. + let depth = 0; + let valueEnd = -1; + let inString = false; + for (let j = i; j < text.length; j++) { + const ch = text[j]; + if (inString) { + if (ch === '\\') { + j++; + continue; + } // skip escaped char + if (ch === '"') inString = false; + continue; + } + if (ch === '"') { + inString = true; + continue; + } + if (ch === '{') depth++; + else if (ch === '}') { + depth--; + if (depth === 0) { + valueEnd = j + 1; + break; + } + } + } + if (valueEnd === -1) return null; + + // Determine the bytes to excise, including surrounding comma + whitespace. + // Case A: trailing comma exists → remove key+value+comma (+ leading \n+indent). + // Case B: no trailing comma (last key) → remove preceding comma+whitespace. + let removeStart = keyStart; + let removeEnd = valueEnd; + + const afterValue = text.slice(valueEnd); + const trailingCommaMatch = /^(\s*,)/.exec(afterValue); + if (trailingCommaMatch) { + removeEnd = valueEnd + trailingCommaMatch[1].length; + // Also remove the leading newline + indent before the key. + const beforeKey = text.slice(0, removeStart); + const leadingWsMatch = /\n[ \t]*$/.exec(beforeKey); + if (leadingWsMatch) removeStart -= leadingWsMatch[0].length; + } else { + // Last key: remove the preceding comma + any whitespace. + const beforeKey = text.slice(0, removeStart); + const precedingCommaMatch = /,(\s*)$/.exec(beforeKey); + if (precedingCommaMatch) removeStart -= precedingCommaMatch[0].length; + } + + text = text.slice(0, removeStart) + text.slice(removeEnd); + + // Detect root-level indentation from the document's first property. + const indentMatch = /^{\n([ \t]+)/.exec(text); + const indent = indentMatch ? indentMatch[1] : ' '; + + // Serialize the new value, re-indenting continuation lines. + const serializedValue = JSON.stringify(value, null, 2) + .split('\n') + .map((line, idx) => (idx === 0 ? line : indent + line)) + .join('\n'); + + // Insert before the document's final `}` (preserving any trailing newline). + const lastBraceMatch = /\n?}(\n?)$/.exec(text); + if (!lastBraceMatch) return null; + const insertAt = text.length - lastBraceMatch[0].length; + const needsComma = !/,\s*$/.test(text.slice(0, insertAt)); + const prefix = needsComma ? ',' : ''; + const trailingNl = lastBraceMatch[1] ?? ''; + text = + text.slice(0, insertAt) + + `${prefix}\n${indent}"${newKey}": ${serializedValue}` + + `\n}${trailingNl}`; + } + + return text; +} + // -- Disable MCP (dual-schema transactional mutator) -------------- /** Internal result of the Disable MCP step. Mapped to BustResult by runBust. */ @@ -664,6 +984,13 @@ async function disableMcpTransactional( const existingKeys = new Set(Object.keys(config)); const planOps: ManifestOp[] = []; + // Surgical text mutations for byte-preserving write (INV-S1). + // Only populated for flat-schema and global-scope cases where the key + // removal + root insertion can be expressed as a position-independent + // text substitution. Project-scope mutations fall back to atomicWriteJson. + const surgicalMutations: Array<{ name: string; newKey: string; value: unknown }> = []; + let hasSurgicalFallback = false; // set true if any item requires full JSON rewrite + for (const item of configItems) { if (isFlatMcpJson) { // FLAT `.mcp.json` schema. Mutate top-level `mcpServers`, write @@ -679,6 +1006,7 @@ async function disableMcpTransactional( (config as Record)[newKey] = originalValue; delete mcpServers[item.name]; config.mcpServers = mcpServers; + surgicalMutations.push({ name: item.name, newKey, value: originalValue }); planOps.push( buildDisableOp({ config_path: configPath, @@ -699,6 +1027,7 @@ async function disableMcpTransactional( (config as Record)[newKey] = originalValue; delete mcpServers[item.name]; config.mcpServers = mcpServers; + surgicalMutations.push({ name: item.name, newKey, value: originalValue }); planOps.push( buildDisableOp({ config_path: configPath, @@ -714,6 +1043,9 @@ async function disableMcpTransactional( // `projects..mcpServers`. The disabled key is stored at // the project level (sibling of the project's own `mcpServers`) so // Phase 9 can restore it by locating the matching project path. + // Project-scope mutations use full JSON rewrite (surgical patcher + // would need to navigate nested paths — not yet implemented). + hasSurgicalFallback = true; const projects = (config.projects ?? {}) as Record< string, { mcpServers?: Record } @@ -748,8 +1080,23 @@ async function disableMcpTransactional( // outer ops array; if the write throws, the file's planOps are discarded // and the caller returns {status:'write-error'} without writing any of // them to the manifest (fail-fast per D-14). + // + // Byte-preserving write (INV-S1): attempt surgical text patch first for + // flat-schema and global-scope items. Falls back to atomicWriteJson when + // any project-scope item is present (hasSurgicalFallback) or when the + // patcher returns null (malformed/unexpected file structure). try { - await deps.atomicWriteJson(configPath, config); + let wrote = false; + if (!hasSurgicalFallback && surgicalMutations.length > 0) { + const patched = patchMcpConfigText(raw, surgicalMutations); + if (patched !== null) { + await deps.atomicWriteText(configPath, patched); + wrote = true; + } + } + if (!wrote) { + await deps.atomicWriteJson(configPath, config); + } } catch (err) { return { status: 'write-error', path: configPath, error: (err as Error).message }; } @@ -856,10 +1203,12 @@ function defaultCeremonyIo(): CeremonyIO { // -- In-source tests ---------------------------------------------- if (import.meta.vitest) { - const { describe, it, expect, beforeEach, afterEach } = import.meta.vitest; + const { describe, it, expect, beforeEach, afterEach, vi } = import.meta.vitest; const { mkdtemp, writeFile: wf, rm, readFile: rf, mkdir: mk } = await import('node:fs/promises'); const { tmpdir } = await import('node:os'); const { readManifest } = await import('./manifest.ts'); + const { canonicalItemId } = await import('./checkpoint.ts'); + const { existsSync } = await import('node:fs'); type ArchiveOp = import('./manifest.ts').ArchiveOp; type DisableOp = import('./manifest.ts').DisableOp; @@ -903,6 +1252,9 @@ if (import.meta.vitest) { atomicWriteJson: async (target, value) => { await wf(target, JSON.stringify(value, null, 2), 'utf8'); }, + atomicWriteText: async (target, text) => { + await wf(target, text, 'utf8'); + }, pathExistsSync: () => false, createManifestWriter: (p) => new ManifestWriter(p), manifestPath: () => manifestPath, @@ -1069,7 +1421,7 @@ if (import.meta.vitest) { archive: [], disable: [], flag: [], - counts: { agents: 0, skills: 0, mcp: 0, memory: 0 }, + counts: { agents: 0, skills: 0, mcp: 0, memory: 0, commands: 0 }, savings: { tokens: 0 }, }; } @@ -1734,4 +2086,564 @@ if (import.meta.vitest) { } }); }); + + // ── INV-S4 + INV-S5: selectedItems filter (Plan 01-02) ──────────── + describe('runBust — selectedItems subset filter (INV-S4 + INV-S5)', () => { + let tmp: string; + + beforeEach(async () => { + tmp = await mkdtemp(path.join(tmpdir(), 'bust-subset-')); + }); + afterEach(async () => { + await rm(tmp, { recursive: true, force: true }); + }); + + // Build a 5-item fixture: 2 agents, 1 skill, 1 mcp-server, 1 memory. + // Tokens: agent1=100, agent2=200, skill1=300, mcp1=400, mem1=50. + // Only archive/disable contribute to freedTokens (not flag/memory). + async function makeFixture5(claudeRoot: string) { + await mk(path.join(claudeRoot, 'agents'), { recursive: true }); + await mk(path.join(claudeRoot, 'skills'), { recursive: true }); + await wf(path.join(claudeRoot, 'agents', 'agent1.md'), '# agent1', 'utf8'); + await wf(path.join(claudeRoot, 'agents', 'agent2.md'), '# agent2', 'utf8'); + await wf(path.join(claudeRoot, 'skills', 'skill1.md'), '# skill1', 'utf8'); + await wf(path.join(claudeRoot, 'CLAUDE.md'), '# memory', 'utf8'); + const configPath = path.join(tmp, '.claude.json'); + await wf(configPath, JSON.stringify({ mcpServers: { mcp1: { command: 'x' } } }), 'utf8'); + + const enriched: TokenCostResult[] = [ + { + item: { + name: 'agent1', + path: path.join(claudeRoot, 'agents', 'agent1.md'), + scope: 'global', + category: 'agent', + projectPath: null, + }, + tier: 'definite-ghost', + lastUsed: null, + invocationCount: 0, + tokenEstimate: { tokens: 100, confidence: 'estimated', source: 'test' }, + }, + { + item: { + name: 'agent2', + path: path.join(claudeRoot, 'agents', 'agent2.md'), + scope: 'global', + category: 'agent', + projectPath: null, + }, + tier: 'definite-ghost', + lastUsed: null, + invocationCount: 0, + tokenEstimate: { tokens: 200, confidence: 'estimated', source: 'test' }, + }, + { + item: { + name: 'skill1', + path: path.join(claudeRoot, 'skills', 'skill1.md'), + scope: 'global', + category: 'skill', + projectPath: null, + }, + tier: 'definite-ghost', + lastUsed: null, + invocationCount: 0, + tokenEstimate: { tokens: 300, confidence: 'estimated', source: 'test' }, + }, + { + item: { + name: 'mcp1', + path: configPath, + scope: 'global', + category: 'mcp-server', + projectPath: null, + }, + tier: 'definite-ghost', + lastUsed: null, + invocationCount: 0, + tokenEstimate: { tokens: 400, confidence: 'estimated', source: 'test' }, + }, + { + item: { + name: 'CLAUDE.md', + path: path.join(claudeRoot, 'CLAUDE.md'), + scope: 'global', + category: 'memory', + projectPath: null, + }, + tier: 'definite-ghost', + lastUsed: null, + invocationCount: 0, + tokenEstimate: { tokens: 50, confidence: 'estimated', source: 'test' }, + }, + ]; + return { enriched, configPath }; + } + + it('Test 1 (INV-S4): full bust — planned_ops sum equals full plan total, freedTokens === totalPlannedTokens', async () => { + const claudeRoot = path.join(tmp, '.claude'); + const { enriched } = await makeFixture5(claudeRoot); + // Checkpoint savings = 100+200+300+400 = 1000 (agents+skills+mcp, no memory) + const deps = makeDeps(tmp, { + scanAndEnrich: async () => enriched, + readCheckpoint: async () => ({ + status: 'ok', + checkpoint: { + checkpoint_version: 1, + ccaudit_version: '0.0.1', + timestamp: '2026-04-05T18:30:00.000Z', + since_window: '7d', + ghost_hash: 'sha256:test', + item_count: { agents: 2, skills: 1, mcp: 1, memory: 1 }, + savings: { tokens: 1000 }, + total_overhead: 5000, + }, + }), + }); + const result = await runBust({ yes: true, deps }); + expect(result.status).toBe('success'); + if (result.status === 'success') { + expect(result.summary.freedTokens).toBe(result.summary.totalPlannedTokens); + } + const manifest = await readManifest(deps.manifestPath()); + const ops = manifest.header!.planned_ops; + expect(ops.archive + ops.disable + ops.flag).toBe(5); + }); + + it('Test 2 (INV-S4): subset bust with 2 of 5 selected — planned_ops sum equals 2', async () => { + const claudeRoot = path.join(tmp, '.claude'); + const { enriched } = await makeFixture5(claudeRoot); + const agent1 = enriched[0]!.item; + const agent2 = enriched[1]!.item; + const selectedItems = new Set([canonicalItemId(agent1), canonicalItemId(agent2)]); + const deps = makeDeps(tmp, { + scanAndEnrich: async () => enriched, + readCheckpoint: async () => ({ + status: 'ok', + checkpoint: { + checkpoint_version: 1, + ccaudit_version: '0.0.1', + timestamp: '2026-04-05T18:30:00.000Z', + since_window: '7d', + ghost_hash: 'sha256:test', + item_count: { agents: 2, skills: 1, mcp: 1, memory: 1 }, + savings: { tokens: 1000 }, + total_overhead: 5000, + }, + }), + }); + const result = await runBust({ yes: true, deps, selectedItems }); + expect(result.status).toBe('success'); + const manifest = await readManifest(deps.manifestPath()); + const ops = manifest.header!.planned_ops; + // 2 agents selected → archive=2, disable=0, flag=0 → sum=2 + expect(ops.archive + ops.disable + ops.flag).toBe(2); + // header + 2 ops + footer = 4 lines + expect(manifest.ops).toHaveLength(2); + }); + + it('Test 3 (INV-S5): subset bust — freedTokens reflects selected items, totalPlannedTokens reflects full plan', async () => { + const claudeRoot = path.join(tmp, '.claude'); + const { enriched } = await makeFixture5(claudeRoot); + // Select agent1 (100 tokens) and mcp1 (400 tokens) = freedTokens = 500 + const agent1 = enriched[0]!.item; + const mcp1 = enriched[3]!.item; + const selectedItems = new Set([canonicalItemId(agent1), canonicalItemId(mcp1)]); + const deps = makeDeps(tmp, { + scanAndEnrich: async () => enriched, + readCheckpoint: async () => ({ + status: 'ok', + checkpoint: { + checkpoint_version: 1, + ccaudit_version: '0.0.1', + timestamp: '2026-04-05T18:30:00.000Z', + since_window: '7d', + ghost_hash: 'sha256:test', + item_count: { agents: 2, skills: 1, mcp: 1, memory: 1 }, + savings: { tokens: 1000 }, + total_overhead: 5000, + }, + }), + }); + const result = await runBust({ yes: true, deps, selectedItems }); + expect(result.status).toBe('success'); + if (result.status === 'success') { + // freedTokens = agent1(100) + mcp1(400) = 500 (subset) + expect(result.summary.freedTokens).toBe(500); + // totalPlannedTokens = checkpoint.savings.tokens = 1000 (full plan) + expect(result.summary.totalPlannedTokens).toBe(1000); + } + }); + + it('Test 4: selection_filter in manifest header — full bust sets mode=full, subset records only actioned IDs', async () => { + // Full bust: uses default makeDeps (empty scan) to verify mode=full + const depsFull = makeDeps(tmp); + const resultFull = await runBust({ yes: true, deps: depsFull }); + expect(resultFull.status).toBe('success'); + const manifestFull = await readManifest(depsFull.manifestPath()); + expect(manifestFull.header!.selection_filter?.mode).toBe('full'); + + // Subset bust — use a fixture with real items so that actioned IDs can be verified. + // Per MEDIUM-01 fix: sf.ids records only IDs that actually survived the filter + // and were actioned — not the raw requested Set (which may include unmatched IDs). + const tmp2 = await mkdtemp(path.join(tmpdir(), 'bust-sf-sub-')); + try { + const claudeRoot2 = path.join(tmp2, '.claude'); + const { enriched } = await makeFixture5(claudeRoot2); + const agent1 = enriched[0]!.item; + const actionedId = canonicalItemId(agent1); + const unmatchedId = 'agent|global||/nonexistent/ghost.md'; // will not match any plan item + const depsSub = makeDeps(tmp2, { + scanAndEnrich: async () => enriched, + readCheckpoint: async () => ({ + status: 'ok', + checkpoint: { + checkpoint_version: 1, + ccaudit_version: '0.0.1', + timestamp: '2026-04-05T18:30:00.000Z', + since_window: '7d', + ghost_hash: 'sha256:test', + item_count: { agents: 2, skills: 1, mcp: 1, memory: 1 }, + savings: { tokens: 1000 }, + total_overhead: 5000, + }, + }), + }); + const resultSub = await runBust({ + yes: true, + deps: depsSub, + selectedItems: new Set([actionedId, unmatchedId]), + }); + expect(resultSub.status).toBe('success'); + const manifestSub = await readManifest(depsSub.manifestPath()); + const sf = manifestSub.header!.selection_filter; + expect(sf?.mode).toBe('subset'); + if (sf?.mode === 'subset') { + // Only the ID that matched a real plan item should appear + expect(sf.ids).toContain(actionedId); + // The unmatched requested ID must NOT appear in the manifest + expect(sf.ids).not.toContain(unmatchedId); + } + } finally { + await rm(tmp2, { recursive: true, force: true }); + } + }); + + it('Test 5: empty selectedItems — planned_ops all 0, freedTokens=0, totalPlannedTokens from checkpoint', async () => { + const claudeRoot = path.join(tmp, '.claude'); + const { enriched } = await makeFixture5(claudeRoot); + const deps = makeDeps(tmp, { + scanAndEnrich: async () => enriched, + readCheckpoint: async () => ({ + status: 'ok', + checkpoint: { + checkpoint_version: 1, + ccaudit_version: '0.0.1', + timestamp: '2026-04-05T18:30:00.000Z', + since_window: '7d', + ghost_hash: 'sha256:test', + item_count: { agents: 2, skills: 1, mcp: 1, memory: 1 }, + savings: { tokens: 1000 }, + total_overhead: 5000, + }, + }), + }); + const warnSpy: string[] = []; + const origWarn = console.warn; + console.warn = (...args: unknown[]) => { + warnSpy.push(String(args[0])); + }; + try { + const result = await runBust({ yes: true, deps, selectedItems: new Set() }); + expect(result.status).toBe('success'); + if (result.status === 'success') { + expect(result.summary.freedTokens).toBe(0); + expect(result.summary.totalPlannedTokens).toBe(1000); + } + const manifest = await readManifest(deps.manifestPath()); + const ops = manifest.header!.planned_ops; + expect(ops.archive).toBe(0); + expect(ops.disable).toBe(0); + expect(ops.flag).toBe(0); + // Warning emitted + expect(warnSpy.some((w) => w.includes('empty set'))).toBe(true); + } finally { + console.warn = origWarn; + } + }); + + it('Test 6 (backward-compat): selectedItems=undefined produces same manifest counts as pre-Plan-02', async () => { + const claudeRoot = path.join(tmp, '.claude'); + const { enriched } = await makeFixture5(claudeRoot); + const deps = makeDeps(tmp, { + scanAndEnrich: async () => enriched, + readCheckpoint: async () => ({ + status: 'ok', + checkpoint: { + checkpoint_version: 1, + ccaudit_version: '0.0.1', + timestamp: '2026-04-05T18:30:00.000Z', + since_window: '7d', + ghost_hash: 'sha256:test', + item_count: { agents: 2, skills: 1, mcp: 1, memory: 1 }, + savings: { tokens: 1000 }, + total_overhead: 5000, + }, + }), + }); + // No selectedItems → full bust + const result = await runBust({ yes: true, deps }); + expect(result.status).toBe('success'); + if (result.status === 'success') { + // freedTokens = checkpoint.savings.tokens = 1000 (v1.4.0 behavior) + expect(result.summary.freedTokens).toBe(1000); + expect(result.summary.totalPlannedTokens).toBe(1000); + } + const manifest = await readManifest(deps.manifestPath()); + // 5 total planned ops (2 archive + 1 archive + 1 disable + 1 flag) + const ops = manifest.header!.planned_ops; + expect(ops.archive + ops.disable + ops.flag).toBe(5); + }); + + it('Test 7 (approach A — hash gate): subset bust with mismatched hash returns hash-mismatch, no manifest file', async () => { + const claudeRoot = path.join(tmp, '.claude'); + const { enriched } = await makeFixture5(claudeRoot); + const agent1 = enriched[0]!.item; + const selectedItems = new Set([canonicalItemId(agent1)]); + const deps = makeDeps(tmp, { + scanAndEnrich: async () => enriched, + computeHash: async () => 'sha256:different', // mismatch + }); + const result = await runBust({ yes: true, deps, selectedItems }); + expect(result.status).toBe('hash-mismatch'); + // Manifest must NOT exist (hash gate aborted before any disk write) + expect(existsSync(deps.manifestPath())).toBe(false); + }); + + it('Test 8 (skipCeremony=true): runCeremony is NOT called when skipCeremony is true (D-20)', async () => { + const ceremonySpy = vi.fn(async () => ({ status: 'accepted' as const })); + const deps = makeDeps(tmp, { runCeremony: ceremonySpy }); + const result = await runBust({ yes: true, deps, skipCeremony: true }); + expect(result.status).toBe('success'); + // Ceremony must NOT have been called — the TUI confirmed upstream. + expect(ceremonySpy).not.toHaveBeenCalled(); + }); + + it('Test 9 (skipCeremony=undefined): runCeremony IS called when skipCeremony is absent (regression guard)', async () => { + const ceremonySpy = vi.fn(async () => ({ status: 'accepted' as const })); + const deps = makeDeps(tmp, { runCeremony: ceremonySpy }); + // skipCeremony not passed → default behavior: ceremony runs + const result = await runBust({ yes: true, deps }); + expect(result.status).toBe('success'); + expect(ceremonySpy).toHaveBeenCalledOnce(); + }); + }); + + // ── patchMcpConfigText unit tests ──────────────────────────────── + // Exported for in-source unit testing (see JSDoc on function). + // These are deterministic pure-function tests — no subprocess, no fs. + describe('patchMcpConfigText', () => { + // Helper: one-mutation rename for brevity + function patch( + raw: string, + name: string, + newKey = `ccaudit-disabled:${name}`, + value: unknown = { command: 'npx' }, + ): string | null { + return patchMcpConfigText(raw, [{ name, newKey, value }]); + } + + it('removes a non-last key and inserts disabled key at document root', () => { + const input = `{ + "mcpServers": { + "serverA": { + "command": "npx" + }, + "serverB": { + "command": "node" + } + } +} +`; + const result = patch(input, 'serverA'); + expect(result).not.toBeNull(); + const parsed = JSON.parse(result!); + // serverA removed from mcpServers + expect(parsed.mcpServers?.serverA).toBeUndefined(); + // serverB preserved in mcpServers + expect(parsed.mcpServers?.serverB).toEqual({ command: 'node' }); + // disabled key at root + expect(parsed['ccaudit-disabled:serverA']).toEqual({ command: 'npx' }); + }); + + it('tolerates JSON whitespace between object keys and colons', () => { + const input = `{ + "mcpServers" : { + "serverA" : { + "command": "npx" + }, + "serverB" : { + "command": "node" + } + } +} +`; + const result = patch(input, 'serverA'); + expect(result).not.toBeNull(); + const parsed = JSON.parse(result!); + expect(parsed.mcpServers?.serverA).toBeUndefined(); + expect(parsed.mcpServers?.serverB).toEqual({ command: 'node' }); + expect(parsed['ccaudit-disabled:serverA']).toEqual({ command: 'npx' }); + }); + + it('removes the last key (no trailing comma) using preceding-comma branch', () => { + const input = `{ + "mcpServers": { + "only": { + "command": "npx" + } + } +} +`; + const result = patch(input, 'only'); + expect(result).not.toBeNull(); + const parsed = JSON.parse(result!); + expect(parsed.mcpServers?.only).toBeUndefined(); + expect(parsed['ccaudit-disabled:only']).toEqual({ command: 'npx' }); + }); + + it('returns null for malformed document (value object never closes)', () => { + // The `broken` value object opens a `{` but never closes — depth never reaches 0. + // patchMcpConfigText must return null rather than crash or produce corrupt output. + const input = `{ + "mcpServers": { + "broken": { + "command": "npx" +`; + // Walker runs off the end of the string without depth reaching 0 → valueEnd === -1 → null + const result = patch(input, 'broken'); + expect(result).toBeNull(); + }); + + it('handles value object with string literals containing braces (WR-01 regression)', () => { + // This fixture would have broken the old brace-depth walker: the '}' inside + // "{FOO=bar}" would decrement depth prematurely, causing valueEnd to land + // inside the string and making the patcher return null. + const input = `{ + "mcpServers": { + "serverA": { + "command": "docker", + "args": ["run", "--env", "{FOO=bar}"] + }, + "serverB": { + "command": "node" + } + } +} +`; + const result = patch(input, 'serverA', 'ccaudit-disabled:serverA', { + command: 'docker', + args: ['run', '--env', '{FOO=bar}'], + }); + // Must NOT return null — the brace inside the string literal must be ignored + expect(result).not.toBeNull(); + const parsed = JSON.parse(result!); + expect(parsed.mcpServers?.serverA).toBeUndefined(); + expect(parsed.mcpServers?.serverB).toEqual({ command: 'node' }); + expect(parsed['ccaudit-disabled:serverA']).toMatchObject({ command: 'docker' }); + }); + + it('handles value object with nested inner objects (e.g. env map)', () => { + const input = `{ + "mcpServers": { + "serverA": { + "command": "npx", + "env": { + "DEBUG": "1", + "FOO": "bar" + } + } + } +} +`; + const nestedValue = { command: 'npx', env: { DEBUG: '1', FOO: 'bar' } }; + const result = patch(input, 'serverA', 'ccaudit-disabled:serverA', nestedValue); + expect(result).not.toBeNull(); + const parsed = JSON.parse(result!); + expect(parsed.mcpServers?.serverA).toBeUndefined(); + expect(parsed['ccaudit-disabled:serverA']).toEqual(nestedValue); + }); + + it('returns null when key is not present in the document', () => { + const input = `{ + "mcpServers": { + "serverA": { + "command": "npx" + } + } +} +`; + expect(patch(input, 'nonExistent')).toBeNull(); + }); + + // ── C2 regression tests: scope rename to global mcpServers block ── + + it('C2: scopes rename to global mcpServers when project-level entry shadows the same key', () => { + // Fixture: projects.

.mcpServers.foo appears BEFORE root mcpServers.foo. + // The patcher must rename the ROOT entry, not the project-level one. + const input = `{ + "projects": { + "/p": { + "mcpServers": { + "foo": { + "token": 1 + } + } + } + }, + "mcpServers": { + "foo": { + "token": 2 + } + } +} +`; + const result = patchMcpConfigText(input, [ + { name: 'foo', newKey: '_disabled_foo', value: { token: 2 } }, + ]); + expect(result).not.toBeNull(); + const parsed = JSON.parse(result!) as Record; + // Root mcpServers.foo removed + const mcpServers = parsed.mcpServers as Record | undefined; + expect(mcpServers?.foo).toBeUndefined(); + // _disabled_foo added at root with the root value + expect(parsed['_disabled_foo']).toEqual({ token: 2 }); + // Project block byte-untouched: projects./p.mcpServers.foo still present with token:1 + const projects = parsed.projects as Record> | undefined; + const projMcp = projects?.['/p']?.mcpServers as Record | undefined; + expect(projMcp?.foo).toEqual({ token: 1 }); + }); + + it('C2: returns null when only a project-level mcpServers.foo exists (no global entry)', () => { + // No root mcpServers.foo — the block search must fail to find the key in scope. + const input = `{ + "projects": { + "/p": { + "mcpServers": { + "foo": { + "token": 1 + } + } + } + } +} +`; + // No global mcpServers block → findTopLevelMcpServersBlock returns null → patcher returns null + const result = patchMcpConfigText(input, [ + { name: 'foo', newKey: '_disabled_foo', value: { token: 1 } }, + ]); + expect(result).toBeNull(); + }); + }); } diff --git a/packages/internal/src/remediation/change-plan.ts b/packages/internal/src/remediation/change-plan.ts index 757e107..39a9093 100644 --- a/packages/internal/src/remediation/change-plan.ts +++ b/packages/internal/src/remediation/change-plan.ts @@ -1,10 +1,11 @@ import type { TokenCostResult } from '../token/types.ts'; import type { ItemCategory, ItemScope } from '../types.ts'; import { calculateDryRunSavings } from './savings.ts'; +import { canonicalItemId } from './checkpoint.ts'; /** * Action verbs grouping items in the change plan. - * - archive: agents + skills (definite-ghost) -> moved to ccaudit/archived/ in Phase 8 + * - archive: agents + skills + commands (definite-ghost) -> moved to ccaudit/archived/ in Phase 8 * - disable: MCP servers (definite-ghost OR likely-ghost per D-11a) -> key-renamed in Phase 8 * - flag: memory files (any stale tier) -> frontmatter added in Phase 8 */ @@ -39,6 +40,7 @@ export interface ChangePlan { skills: number; mcp: number; memory: number; + commands: number; }; savings: { tokens: number; @@ -77,6 +79,12 @@ export function buildChangePlan(enriched: TokenCostResult[]): ChangePlan { } continue; } + if (r.item.category === 'command') { + if (r.tier === 'definite-ghost') { + archive.push({ action: 'archive', ...base }); + } + continue; + } if (r.item.category === 'mcp-server') { if (r.tier !== 'used') { disable.push({ action: 'disable', ...base }); @@ -91,12 +99,7 @@ export function buildChangePlan(enriched: TokenCostResult[]): ChangePlan { } } - const counts = { - agents: archive.filter((i) => i.category === 'agent').length, - skills: archive.filter((i) => i.category === 'skill').length, - mcp: disable.length, - memory: flag.length, - }; + const counts = recomputeCounts(archive, disable, flag); // Compute savings AFTER lists are built -- delegates to savings.ts const partial: ChangePlan = { archive, disable, flag, counts, savings: { tokens: 0 } }; @@ -104,6 +107,57 @@ export function buildChangePlan(enriched: TokenCostResult[]): ChangePlan { return partial; } +/** + * Apply an optional subset filter to a ChangePlan (Phase 1 / D-03). + * + * - `selectedItems === undefined` → return the plan unchanged + * (full-inventory bust; v1.4.0 contract preserved byte-for-byte). + * - `selectedItems === new Set([...])` → return a new ChangePlan whose + * archive/disable/flag arrays contain only items whose canonicalItemId + * is in the set. Counts + savings are recomputed from the filtered + * lists. Unknown ids in the set are silently ignored. + * + * The filter uses `canonicalItemId` (checkpoint.ts) for identity so the + * ids produced by `computeGhostHash`'s key derivation, the TUI picker, + * and the integration-test CCAUDIT_SELECT_IDS parser all line up. + */ +export function filterChangePlan( + plan: ChangePlan, + selectedItems: Set | undefined, +): ChangePlan { + if (selectedItems === undefined) { + return plan; + } + + // canonicalItemId now accepts CanonicalItemInput (a Pick of InventoryItem), + // so ChangePlanItem satisfies it directly — no intermediate cast needed. + const keep = (i: ChangePlanItem): boolean => selectedItems.has(canonicalItemId(i)); + + const archive = plan.archive.filter(keep); + const disable = plan.disable.filter(keep); + const flag = plan.flag.filter(keep); + + const counts = recomputeCounts(archive, disable, flag); + + const filtered: ChangePlan = { archive, disable, flag, counts, savings: { tokens: 0 } }; + filtered.savings.tokens = calculateDryRunSavings(filtered); + return filtered; +} + +function recomputeCounts( + archive: ChangePlanItem[], + disable: ChangePlanItem[], + flag: ChangePlanItem[], +): ChangePlan['counts'] { + return { + agents: archive.filter((i) => i.category === 'agent').length, + skills: archive.filter((i) => i.category === 'skill').length, + mcp: disable.length, + memory: flag.length, + commands: archive.filter((i) => i.category === 'command').length, + }; +} + if (import.meta.vitest) { const { describe, it, expect } = import.meta.vitest; @@ -149,6 +203,20 @@ if (import.meta.vitest) { expect(plan.counts.skills).toBe(1); }); + it('archives definite-ghost commands', () => { + const plan = buildChangePlan([makeResult({ category: 'command', tier: 'definite-ghost' })]); + expect(plan.archive).toHaveLength(1); + expect(plan.counts.commands).toBe(1); + expect(plan.archive[0]!.action).toBe('archive'); + expect(plan.archive[0]!.category).toBe('command'); + }); + + it('excludes likely-ghost commands', () => { + const plan = buildChangePlan([makeResult({ category: 'command', tier: 'likely-ghost' })]); + expect(plan.archive).toHaveLength(0); + expect(plan.counts.commands).toBe(0); + }); + it('excludes likely-ghost agents (Phase 5 D-12 monitor-only)', () => { const plan = buildChangePlan([makeResult({ category: 'agent', tier: 'likely-ghost' })]); expect(plan.archive).toHaveLength(0); @@ -219,8 +287,76 @@ if (import.meta.vitest) { makeResult({ category: 'mcp-server', tier: 'definite-ghost', name: 'm1' }), makeResult({ category: 'mcp-server', tier: 'definite-ghost', name: 'm2' }), makeResult({ category: 'memory', tier: 'likely-ghost', name: 'mem1' }), + makeResult({ category: 'command', tier: 'definite-ghost', name: 'cmd1' }), + ]); + expect(plan.counts).toEqual({ agents: 2, skills: 0, mcp: 2, memory: 1, commands: 1 }); + }); + }); + + describe('filterChangePlan', () => { + // Build a plan with 3 items: agent A, agent B, mcp C + function makePlan3() { + return buildChangePlan([ + makeResult({ category: 'agent', tier: 'definite-ghost', name: 'agentA' }), + makeResult({ category: 'agent', tier: 'definite-ghost', name: 'agentB' }), + makeResult({ category: 'mcp-server', tier: 'definite-ghost', name: 'mcpC' }), ]); - expect(plan.counts).toEqual({ agents: 2, skills: 0, mcp: 2, memory: 1 }); + } + + it('Test 1: undefined selectedItems returns the plan unchanged (reference-equal)', () => { + const plan = makePlan3(); + const result = filterChangePlan(plan, undefined); + expect(result).toBe(plan); + }); + + it('Test 2: Set with 2 of 3 ids returns only those items', () => { + const plan = makePlan3(); + // Build canonical ids for agentA and agentB (category|scope|projectPath|path) + const idA = `agent|global||/tmp/agentA`; + const idB = `agent|global||/tmp/agentB`; + const result = filterChangePlan(plan, new Set([idA, idB])); + expect(result.archive).toHaveLength(2); + expect(result.disable).toHaveLength(0); + expect(result.archive.map((i) => i.name).sort()).toEqual(['agentA', 'agentB']); + }); + + it('Test 3: filtered plan has recomputed counts and savings', () => { + const plan = buildChangePlan([ + makeResult({ category: 'agent', tier: 'definite-ghost', name: 'a1', tokens: 100 }), + makeResult({ category: 'agent', tier: 'definite-ghost', name: 'a2', tokens: 200 }), + makeResult({ category: 'mcp-server', tier: 'definite-ghost', name: 'm1', tokens: 500 }), + ]); + const idA1 = `agent|global||/tmp/a1`; + // mcp-server canonical id: mcp-server|scope|projectPath|name|path + // makeResult produces: name='m1', path='/tmp/m1', scope='global', projectPath=null + const idM1 = `mcp-server|global||m1|/tmp/m1`; + const result = filterChangePlan(plan, new Set([idA1, idM1])); + // counts: 1 agent + 1 mcp + expect(result.counts.agents).toBe(1); + expect(result.counts.mcp).toBe(1); + expect(result.counts.agents + result.counts.mcp).toBe(2); // == set.size + // savings = archive(100) + disable(500) = 600 + expect(result.savings.tokens).toBe(600); + }); + + it('Test 4: empty Set returns zero-item plan with counts=0 and savings=0', () => { + const plan = makePlan3(); + const result = filterChangePlan(plan, new Set()); + expect(result.archive).toHaveLength(0); + expect(result.disable).toHaveLength(0); + expect(result.flag).toHaveLength(0); + expect(result.counts).toEqual({ agents: 0, skills: 0, mcp: 0, memory: 0, commands: 0 }); + expect(result.savings.tokens).toBe(0); + }); + + it('Test 5: unknown ids in the set are silently ignored (no throw)', () => { + const plan = makePlan3(); + const unknownId = 'agent|global||/nonexistent/path'; + // Should not throw; the unknown id just matches nothing + expect(() => filterChangePlan(plan, new Set([unknownId]))).not.toThrow(); + const result = filterChangePlan(plan, new Set([unknownId])); + expect(result.archive).toHaveLength(0); + expect(result.disable).toHaveLength(0); }); }); } diff --git a/packages/internal/src/remediation/checkpoint.ts b/packages/internal/src/remediation/checkpoint.ts index 4d9283c..6e8d25c 100644 --- a/packages/internal/src/remediation/checkpoint.ts +++ b/packages/internal/src/remediation/checkpoint.ts @@ -2,6 +2,7 @@ import { createHash } from 'node:crypto'; import { mkdir, writeFile, rename, unlink, readFile, stat } from 'node:fs/promises'; import { homedir } from 'node:os'; import path from 'node:path'; +import type { InventoryItem } from '../scanner/types.ts'; import type { TokenCostResult } from '../token/types.ts'; // -- Checkpoint schema (D-17) ------------------------------------ @@ -61,6 +62,44 @@ interface MemoryHashRecord { } type HashRecord = AgentSkillHashRecord | McpHashRecord | MemoryHashRecord; +// -- Canonical item identifier (Plan 01 extraction) --------------- + +/** + * Minimal structural type for what canonicalItemId actually needs. + * Using a Pick keeps the signature honest: any struct that provides + * these five fields (e.g. ChangePlanItem) can be passed directly + * without an intermediate cast to the full InventoryItem shape. + */ +export type CanonicalItemInput = Pick< + InventoryItem, + 'name' | 'path' | 'scope' | 'category' | 'projectPath' +>; + +/** + * Canonical item identifier — the single source of truth for how an + * InventoryItem is keyed inside computeGhostHash AND inside any + * subset-selection Set. This identifier is intentionally + * INDEPENDENT of mtimeMs (mtime tracks "this version"; the id tracks + * "this item"). Plan 02's selectedItems filter and Phase 2's TUI + * picker both consume this function so their ids are identical to + * the hash's internal keys. + * + * Format is an opaque internal contract; callers must NEVER parse it. + * Stability guarantee: the byte sequence computeGhostHash produces + * for any given inventory is frozen by __fixtures__/ghost-hash-golden.json. + */ +export function canonicalItemId(item: CanonicalItemInput): string { + switch (item.category) { + case 'mcp-server': + return `mcp-server|${item.scope}|${item.projectPath ?? ''}|${item.name}|${item.path}`; + case 'memory': + return `memory|${item.scope}|${item.path}`; + default: + // agent | skill | command | hook + return `${item.category}|${item.scope}|${item.projectPath ?? ''}|${item.path}`; + } +} + // -- Hash computation (D-10 through D-16) ------------------------ /** @@ -551,6 +590,62 @@ if (import.meta.vitest) { }); }); + // -- Golden fixture: freeze computeGhostHash output bytes ----------- + + describe('computeGhostHash — golden fixture', () => { + it('Test 1: produces exactly the frozen expectedHash (byte-identical)', async () => { + const fixtureRaw = await readFile( + new URL('./__fixtures__/ghost-hash-golden.json', import.meta.url), + 'utf8', + ); + const fixture = JSON.parse(fixtureRaw) as { + input: TokenCostResult[]; + expectedHash: string; + }; + + // Injected StatFn: returns fixed mtimeMs for the shared MCP sourcePath + const MCP_MTIME = 1700000002000; + const deterministicStat: StatFn = async () => ({ mtimeMs: MCP_MTIME }); + + const actual = await computeGhostHash(fixture.input, deterministicStat); + expect(actual).toBe(fixture.expectedHash); + }); + + it('Test 2 (sanity): mutating any mtime in fixture input changes the hash', async () => { + const fixtureRaw = await readFile( + new URL('./__fixtures__/ghost-hash-golden.json', import.meta.url), + 'utf8', + ); + const fixture = JSON.parse(fixtureRaw) as { input: TokenCostResult[]; expectedHash: string }; + const MCP_MTIME = 1700000002000; + const deterministicStat: StatFn = async () => ({ mtimeMs: MCP_MTIME }); + + // Mutate the memory item's mtimeMs + const mutated = fixture.input.map((r) => { + if (r.item.category === 'memory') { + return { ...r, item: { ...r.item, mtimeMs: 9999999999999 } }; + } + return r; + }); + const mutatedHash = await computeGhostHash(mutated, deterministicStat); + expect(mutatedHash).not.toBe(fixture.expectedHash); + }); + + it('Test 3 (sanity): reordering fixture input items does NOT change the hash', async () => { + const fixtureRaw = await readFile( + new URL('./__fixtures__/ghost-hash-golden.json', import.meta.url), + 'utf8', + ); + const fixture = JSON.parse(fixtureRaw) as { input: TokenCostResult[]; expectedHash: string }; + const MCP_MTIME = 1700000002000; + const deterministicStat: StatFn = async () => ({ mtimeMs: MCP_MTIME }); + + const reversed = [...fixture.input].reverse(); + const reversedHash = await computeGhostHash(reversed, deterministicStat); + expect(reversedHash).toBe(fixture.expectedHash); + }); + }); + // -- writeCheckpoint / readCheckpoint tests ----------------------- describe('resolveCheckpointPath', () => { @@ -725,4 +820,78 @@ if (import.meta.vitest) { } }); }); + + describe('canonicalItemId', () => { + function makeItem( + overrides: Partial & { category: InventoryItem['category'] }, + ): InventoryItem { + return { + name: 'test-item', + path: '/synthetic/path/item.md', + scope: 'global', + projectPath: null, + ...overrides, + }; + } + + it('Test 1: returns a deterministic string — same result across 10 calls on the same item', () => { + const item = makeItem({ category: 'agent' }); + const first = canonicalItemId(item); + for (let i = 0; i < 10; i++) { + expect(canonicalItemId(item)).toBe(first); + } + expect(typeof first).toBe('string'); + expect(first.length).toBeGreaterThan(0); + }); + + it('Test 2: is independent of item.mtimeMs — two items differing only in mtimeMs produce the same id', () => { + const base = makeItem({ category: 'agent', mtimeMs: 1000000 }); + const bumped = makeItem({ category: 'agent', mtimeMs: 9999999 }); + expect(canonicalItemId(base)).toBe(canonicalItemId(bumped)); + }); + + it('Test 3: differs across category even when other fields are identical', () => { + const sharedProps = { + name: 'x', + path: '/synth/x', + scope: 'global' as const, + projectPath: null, + }; + const agent = canonicalItemId(makeItem({ category: 'agent', ...sharedProps })); + const skill = canonicalItemId(makeItem({ category: 'skill', ...sharedProps })); + const memory = canonicalItemId(makeItem({ category: 'memory', ...sharedProps })); + expect(agent).not.toBe(skill); + expect(agent).not.toBe(memory); + expect(skill).not.toBe(memory); + }); + + it('Test 4: differs across scope even with same name/path', () => { + const globalItem = makeItem({ category: 'agent', scope: 'global', projectPath: null }); + const projectItem = makeItem({ + category: 'agent', + scope: 'project', + projectPath: '/some/project', + }); + expect(canonicalItemId(globalItem)).not.toBe(canonicalItemId(projectItem)); + }); + + it('mcp-server id includes name (serverName) and path (sourcePath)', () => { + const mcp1 = makeItem({ + category: 'mcp-server', + name: 'server-a', + path: '/synth/.claude.json', + scope: 'global', + projectPath: null, + }); + const mcp2 = makeItem({ + category: 'mcp-server', + name: 'server-b', + path: '/synth/.claude.json', + scope: 'global', + projectPath: null, + }); + // Same sourcePath, different names => different ids + expect(canonicalItemId(mcp1)).not.toBe(canonicalItemId(mcp2)); + }); + }); } diff --git a/packages/internal/src/remediation/index.ts b/packages/internal/src/remediation/index.ts index ca65081..991a02f 100644 --- a/packages/internal/src/remediation/index.ts +++ b/packages/internal/src/remediation/index.ts @@ -1,21 +1,22 @@ // @ccaudit/internal -- remediation module (Phase 7 + Phase 8) // Pure functions + checkpoint I/O for --dry-run and --dangerously-bust-ghosts. -export { buildChangePlan } from './change-plan.ts'; +export { buildChangePlan, filterChangePlan } from './change-plan.ts'; export type { ChangePlan, ChangePlanItem, ChangePlanAction } from './change-plan.ts'; export { calculateDryRunSavings } from './savings.ts'; export { + canonicalItemId, computeGhostHash, resolveCheckpointPath, writeCheckpoint, readCheckpoint, } from './checkpoint.ts'; -export type { Checkpoint, ReadCheckpointResult, StatFn } from './checkpoint.ts'; +export type { Checkpoint, ReadCheckpointResult, StatFn, CanonicalItemInput } from './checkpoint.ts'; // Phase 8: atomic write primitive (D-18 extraction, reused by bust orchestrator) -export { atomicWriteJson, renameWithRetry } from './atomic-write.ts'; +export { atomicWriteJson, atomicWriteText, renameWithRetry } from './atomic-write.ts'; export type { AtomicWriteOptions } from './atomic-write.ts'; // Phase 8: collision helpers (D-05, D-06) + nested-path-preserving archive builder @@ -65,6 +66,12 @@ export { buildFlagOp, buildRefreshOp, buildSkippedOp, + buildArchivePurgeOp, + buildPurgeManifestHeader, + resolvePurgeManifestPath, + writePurgeManifest, + openPurgeManifestWriter, + closePurgeManifestWriter, MANIFEST_VERSION, } from './manifest.ts'; export type { @@ -77,11 +84,20 @@ export type { FlagOp, RefreshOp, SkippedOp, + ArchivePurgeOp, ReadManifestResult, + SelectionFilter, } from './manifest.ts'; // Phase 8: bust orchestrator -- the Wave 1 pipeline that wires Wave 0 // primitives into the full --dangerously-bust-ghosts flow (D-01..D-18). +// +// NOTE: `patchMcpConfigText` is exported from bust.ts with `export function` +// solely to enable in-source unit tests (the vitest block at the bottom of +// bust.ts) — it is intentionally NOT re-exported here. External callers +// (e.g. restore.ts) should NOT import it directly from the subpath; if a +// future use case requires it, promote it to this barrel with a clear API +// contract first. export { runBust, runConfirmationCeremony } from './bust.ts'; export type { BustResult, BustDeps, BustCounts, CeremonyResult, CeremonyIO } from './bust.ts'; @@ -97,6 +113,11 @@ export { reEnableMcpTransactional, restoreFlagOp, restoreRefreshOp, + dedupManifestOps, + collectRestoreableItems, + matchByName, + isStaleArchiveOp, + filterRestoreableItems, } from './restore.ts'; export type { RestoreDeps, @@ -104,10 +125,28 @@ export type { RestoreCounts, RestoreMode, ManifestListEntry, + RestoreableOp, } from './restore.ts'; export { discoverManifests, resolveManifestDir } from './manifest.ts'; export type { ManifestEntry, DiscoverManifestsDeps } from './manifest.ts'; +// Phase 9 SC6: purge-archive domain core (classifier + executor). +// CLI subcommand is wired in Plan 09-04; this barrel re-export covers the +// pure-domain surface consumed by that wrapper. +export { classifyArchiveOps, executePurge } from './purge.ts'; +export type { + PurgePlan, + PurgeResult, + PurgeSummary, + PurgeFailure, + DropReason, + ExecutePurgeDeps, +} from './purge.ts'; + +// Shared archive→source move helper (lifted from reclaim.ts for purge reuse). +export { moveArchiveToSource } from './archive-move.ts'; +export type { ArchiveMoveDeps, MoveArchiveInput, MoveArchiveFailure } from './archive-move.ts'; + // Phase 4: orphan reclaim command export { reclaim } from './reclaim.ts'; export type { diff --git a/packages/internal/src/remediation/manifest.ts b/packages/internal/src/remediation/manifest.ts index 70c0444..7efd54f 100644 --- a/packages/internal/src/remediation/manifest.ts +++ b/packages/internal/src/remediation/manifest.ts @@ -24,12 +24,23 @@ import { open, mkdir, chmod, readFile, type FileHandle } from 'node:fs/promises' import path from 'node:path'; import { homedir } from 'node:os'; import { createHash, randomUUID } from 'node:crypto'; -import { timestampSuffixForFilename } from './collisions.ts'; // -- Header type (D-12) ------------------------------------------ export const MANIFEST_VERSION = 1 as const; +/** + * Discriminated union describing the selection scope of a bust manifest. + * Written by buildHeader into every manifest; read by restore (Phase 8) and + * auditors to distinguish full-inventory busts from subset busts. + * + * - `{ mode: 'full' }` — full-inventory bust (selectedItems === undefined) + * - `{ mode: 'subset', ids: string[] }` — subset bust; ids are the + * canonicalItemId values of the selected items, sorted ascending so + * manifests diff deterministically. + */ +export type SelectionFilter = { mode: 'full' } | { mode: 'subset'; ids: string[] }; + export interface ManifestHeader { record_type: 'header'; manifest_version: typeof MANIFEST_VERSION; @@ -40,6 +51,11 @@ export interface ManifestHeader { os: NodeJS.Platform; node_version: string; planned_ops: { archive: number; disable: number; flag: number }; + /** + * Optional on reads (old manifests lack this field; default to { mode: 'full' }). + * Always present on writes — buildHeader always sets it. + */ + selection_filter?: SelectionFilter; } // -- Op types (D-11) --------------------------------------------- @@ -50,7 +66,7 @@ export interface ArchiveOp { timestamp: string; status: 'completed' | 'failed'; error?: string; - category: 'agent' | 'skill'; + category: 'agent' | 'skill' | 'command'; scope: 'global' | 'project'; source_path: string; archive_path: string; @@ -106,7 +122,35 @@ export interface SkippedOp { reason: string; } -export type ManifestOp = ArchiveOp | DisableOp | FlagOp | RefreshOp | SkippedOp; +/** + * Append-only follow-up op recorded by `ccaudit purge-archive` (Phase 9 SC6). + * + * An ArchivePurgeOp does NOT rewrite the prior ArchiveOp — the original + * manifest entry stays intact for audit. The purge op references the + * original via `original_op_id` and records the disposition: + * + * - 'reclaimed' — source was free; archive moved back to source. + * - 'source_occupied' — source reappeared; archive unlinked from disk. + * - 'stale_archive_missing' — archive already gone + source back (Phase 8.2 + * staleness shape); archive_path was already + * absent so no unlink ran, but the follow-up + * op is still written so `restore` dedup + * suppresses the stale entry. + * + * Every purge op is always status:'completed' and purged:true — failures + * are surfaced via the PurgeResult.failures[] channel, not as manifest ops. + */ +export interface ArchivePurgeOp { + op_id: string; + op_type: 'archive_purge'; + timestamp: string; + status: 'completed'; + original_op_id: string; + purged: true; + reason: 'reclaimed' | 'source_occupied' | 'stale_archive_missing'; +} + +export type ManifestOp = ArchiveOp | DisableOp | FlagOp | RefreshOp | SkippedOp | ArchivePurgeOp; // -- Footer type (D-12) ------------------------------------------ @@ -138,18 +182,26 @@ export interface ReadManifestResult { /** * Resolve the canonical manifest path for a new bust (D-10). * - * Per-bust file keyed by UTC ISO timestamp with colons replaced by dashes for - * cross-platform filesystem safety (NTFS forbids `:` in filenames). Milliseconds - * are stripped to keep the suffix human-readable and to match the Phase 8 archive - * filename suffix format from collisions.ts. + * Per-bust file keyed by UTC ISO timestamp (millisecond precision) with colons + * and periods replaced by dashes for cross-platform filesystem safety (NTFS + * forbids `:` in filenames), plus a 4-character random base-36 suffix to make + * same-millisecond collisions vanishingly unlikely. + * + * Format: bust--.jsonl + * + * The `bust-` prefix and `.jsonl` suffix are unchanged so `discoverManifests` + * continues to find both old-format (second-granularity) and new-format + * manifests transparently — the filter only checks `bust-*.jsonl`. * * @example - * resolveManifestPath(new Date('2026-04-05T18:30:00Z')) - * // -> '~/.claude/ccaudit/manifests/bust-2026-04-05T18-30-00Z.jsonl' + * resolveManifestPath(new Date('2026-04-05T18:30:00.123Z')) + * // -> '~/.claude/ccaudit/manifests/bust-2026-04-05T18-30-00-123Z-.jsonl' */ export function resolveManifestPath(now: Date = new Date()): string { - const stamp = timestampSuffixForFilename(now); - return path.join(homedir(), '.claude', 'ccaudit', 'manifests', `bust-${stamp}.jsonl`); + // Millisecond-precision timestamp: keep ms digits, replace `:` and `.` with `-` + const stamp = now.toISOString().replace(/:/g, '-').replace(/\./g, '-'); + const rand = Math.random().toString(36).slice(2, 6); + return path.join(homedir(), '.claude', 'ccaudit', 'manifests', `bust-${stamp}-${rand}.jsonl`); } // -- Manifest discovery (Phase 9) -------------------------------- @@ -201,7 +253,12 @@ export async function discoverManifests(deps: DiscoverManifestsDeps): Promise e.startsWith('bust-') && e.endsWith('.jsonl')); + // Phase 9 SC6: include purge-*.jsonl manifests alongside bust-*.jsonl so + // restore dedup picks up archive_purge follow-up ops written by + // `ccaudit purge-archive`. Both prefixes live in the same directory. + const jsonlFiles = entries.filter( + (e) => (e.startsWith('bust-') || e.startsWith('purge-')) && e.endsWith('.jsonl'), + ); const statted = await Promise.all( jsonlFiles.map(async (name) => { const p = path.join(dir, name); @@ -215,12 +272,24 @@ export async function discoverManifests(deps: DiscoverManifestsDeps): Promise, + input: Omit & { + selection_filter?: SelectionFilter; + }, ): ManifestHeader { + const sf = input.selection_filter ?? { mode: 'full' }; + const normalized: SelectionFilter = + sf.mode === 'subset' ? { mode: 'subset', ids: [...sf.ids].sort() } : { mode: 'full' }; return { record_type: 'header', manifest_version: MANIFEST_VERSION, - ...input, + ccaudit_version: input.ccaudit_version, + checkpoint_ghost_hash: input.checkpoint_ghost_hash, + checkpoint_timestamp: input.checkpoint_timestamp, + since_window: input.since_window, + os: input.os, + node_version: input.node_version, + planned_ops: input.planned_ops, + selection_filter: normalized, }; } @@ -239,7 +308,7 @@ function sha256Hex(content: Buffer | string): string { } export function buildArchiveOp(input: { - category: 'agent' | 'skill'; + category: 'agent' | 'skill' | 'command'; scope: 'global' | 'project'; source_path: string; archive_path: string; @@ -346,6 +415,194 @@ export function buildSkippedOp(input: { }; } +/** + * Build an ArchivePurgeOp follow-up record (Phase 9 SC6). Append-only — + * callers write it to a fresh purge-*.jsonl manifest; the original + * ArchiveOp in its bust-*.jsonl manifest is never rewritten. + */ +export function buildArchivePurgeOp(input: { + original_op_id: string; + reason: 'reclaimed' | 'source_occupied' | 'stale_archive_missing'; +}): ArchivePurgeOp { + return { + op_id: randomUUID(), + op_type: 'archive_purge', + timestamp: new Date().toISOString(), + status: 'completed', + original_op_id: input.original_op_id, + purged: true, + reason: input.reason, + }; +} + +/** + * Resolve the canonical path for a new purge manifest (Phase 9 SC6). + * + * Sibling to {@link resolveManifestPath} — uses the `purge-` prefix so + * discoverManifests can distinguish bust vs purge manifests while still + * loading both for restore dedup. + * + * Format: purge--.jsonl + */ +export function resolvePurgeManifestPath(now: Date = new Date()): string { + const stamp = now.toISOString().replace(/:/g, '-').replace(/\./g, '-'); + const rand = Math.random().toString(36).slice(2, 6); + return path.join(homedir(), '.claude', 'ccaudit', 'manifests', `purge-${stamp}-${rand}.jsonl`); +} + +/** + * Build a minimal header for a purge manifest. + * + * Purge manifests reuse the existing {@link ManifestHeader} shape with + * `planned_ops: {archive:0, disable:0, flag:0}` to satisfy restore's + * header-present check (D-07) — they never plan bust-style ops. The + * `checkpoint_ghost_hash` / `checkpoint_timestamp` fields carry the purge + * invocation's moment-in-time so audit-minded consumers can correlate + * purge manifests to the bust manifests they follow up on. + */ +export function buildPurgeManifestHeader(input: { + ccaudit_version: string; + purge_timestamp: string; +}): ManifestHeader { + return buildHeader({ + ccaudit_version: input.ccaudit_version, + checkpoint_ghost_hash: `purge:${input.purge_timestamp}`, + checkpoint_timestamp: input.purge_timestamp, + since_window: 'n/a', + os: process.platform, + node_version: process.version, + planned_ops: { archive: 0, disable: 0, flag: 0 }, + selection_filter: { mode: 'full' }, + }); +} + +/** + * Open a purge manifest writer (NEW-C1 per-op fsync primitive). + * + * Creates a fresh `purge--.jsonl`, writes + fsyncs the header, + * and returns the open writer together with the resolved path. The caller + * is responsible for writing ops via `writer.writeOp(op)` after each + * successful mutation and then calling {@link closePurgeManifestWriter}. + * + * This split allows the executor to interleave manifest writes with + * filesystem mutations so that every successful mutation has a durable + * audit entry even if a later mutation or the close call fails. + * + * @param manifestsDir Optional override for the manifests directory (tests). + * @param header Version + timestamp for the purge header. + * @returns `{ writer, path }` — the open writer and the resolved manifest path. + */ +export async function openPurgeManifestWriter( + header: { ccaudit_version: string; purge_timestamp: string }, + manifestsDir?: string, +): Promise<{ writer: ManifestWriter; path: string }> { + const ts = header.purge_timestamp; + const baseDir = manifestsDir ?? path.join(homedir(), '.claude', 'ccaudit', 'manifests'); + const stamp = new Date(ts).toISOString().replace(/:/g, '-').replace(/\./g, '-'); + const rand = Math.random().toString(36).slice(2, 6); + const manifestPath = path.join(baseDir, `purge-${stamp}-${rand}.jsonl`); + const writer = new ManifestWriter(manifestPath); + await writer.open( + buildPurgeManifestHeader({ + ccaudit_version: header.ccaudit_version, + purge_timestamp: ts, + }), + ); + return { writer, path: manifestPath }; +} + +/** + * Close a purge manifest writer opened by {@link openPurgeManifestWriter}. + * + * Writes a success footer (fsynced) and closes the file. Pass `null` as + * `durationMs` to omit the footer (crash-signature path — header present, + * footer absent). On write failure the error is swallowed after a best-effort + * close so callers can treat close failures as non-fatal. + */ +export async function closePurgeManifestWriter( + writer: ManifestWriter, + opts: { durationMs: number } | null, +): Promise { + if (opts !== null) { + await writer.close( + buildFooter({ + status: 'completed', + actual_ops: { + archive: { completed: 0, failed: 0 }, + disable: { completed: 0, failed: 0 }, + flag: { completed: 0, failed: 0, refreshed: 0, skipped: 0 }, + }, + duration_ms: opts.durationMs, + exit_code: 0, + }), + ); + } else { + await writer.close(null); + } +} + +/** + * Append-only purge manifest writer (Phase 9 SC6) — thin wrapper. + * + * Writes a fresh `purge--.jsonl` containing: + * line 1 purge header (reuses ManifestHeader shape) + * lines 2..N one ArchivePurgeOp per line + * line N+1 footer with exit_code=0 + * + * Fsync per line via the underlying {@link ManifestWriter}. Never rewrites + * any prior manifest file — strict append-only contract (CLAUDE.md §Safety). + * + * Kept for backward compatibility — existing in-source tests and any callers + * that want a single-call write-all interface can continue using this. + * For per-op durability use {@link openPurgeManifestWriter} + + * {@link closePurgeManifestWriter} directly. + */ +export async function writePurgeManifest( + ops: readonly ArchivePurgeOp[], + input: { ccaudit_version: string; purge_timestamp?: string; manifestPath?: string }, +): Promise<{ path: string; opCount: number }> { + const ts = input.purge_timestamp ?? new Date().toISOString(); + // If a specific manifestPath is provided (tests), honour it; otherwise let + // openPurgeManifestWriter generate a fresh path via resolvePurgeManifestPath. + const manifestsDir = input.manifestPath ? path.dirname(input.manifestPath) : undefined; + // Override: when manifestPath is given we need to reconstruct the exact name. + // Simplest approach: if manifestPath supplied, use legacy direct construction. + if (input.manifestPath) { + const writer = new ManifestWriter(input.manifestPath); + await writer.open( + buildPurgeManifestHeader({ + ccaudit_version: input.ccaudit_version, + purge_timestamp: ts, + }), + ); + try { + for (const op of ops) { + await writer.writeOp(op); + } + await closePurgeManifestWriter(writer, { durationMs: writer.elapsedMs }); + } catch (err) { + await writer.close(null); + throw err; + } + return { path: input.manifestPath, opCount: ops.length }; + } + + const { writer, path: manifestPath } = await openPurgeManifestWriter( + { ccaudit_version: input.ccaudit_version, purge_timestamp: ts }, + manifestsDir, + ); + try { + for (const op of ops) { + await writer.writeOp(op); + } + await closePurgeManifestWriter(writer, { durationMs: writer.elapsedMs }); + } catch (err) { + await writer.close(null); + throw err; + } + return { path: manifestPath, opCount: ops.length }; +} + // -- ManifestWriter (D-09) ---------------------------------------- /** @@ -485,13 +742,31 @@ if (import.meta.vitest) { const path = (await import('node:path')).default; describe('resolveManifestPath', () => { - it('returns ~/.claude/ccaudit/manifests/bust-.jsonl', () => { - const d = new Date('2026-04-05T18:30:00.000Z'); + it('returns bust--.jsonl with ms precision and random suffix', () => { + const d = new Date('2026-04-05T18:30:00.123Z'); const p = resolveManifestPath(d); + // Timestamp part: 2026-04-05T18-30-00-123Z (colons and dot replaced with dash) + // Suffix: 4 base-36 characters expect(p).toMatch( - /[/\\]\.claude[/\\]ccaudit[/\\]manifests[/\\]bust-2026-04-05T18-30-00Z\.jsonl$/, + /[/\\]\.claude[/\\]ccaudit[/\\]manifests[/\\]bust-2026-04-05T18-30-00-123Z-[a-z0-9]{4}\.jsonl$/, ); }); + + it('two calls with the same Date produce different paths (random suffix)', () => { + const d = new Date('2026-04-05T18:30:00.000Z'); + const p1 = resolveManifestPath(d); + const p2 = resolveManifestPath(d); + // With a 4-char base-36 suffix (36^4 = 1,679,616 combinations) collision + // probability per call pair is ~1/1.7M — safe to assert inequality in tests. + expect(p1).not.toBe(p2); + }); + + it('result starts with bust- and ends with .jsonl (discoverManifests compat)', () => { + const p = resolveManifestPath(new Date()); + const filename = p.split(/[/\\]/).at(-1)!; + expect(filename.startsWith('bust-')).toBe(true); + expect(filename.endsWith('.jsonl')).toBe(true); + }); }); describe('buildArchiveOp', () => { @@ -543,6 +818,19 @@ if (import.meta.vitest) { expect(op.status).toBe('failed'); expect(op.error).toBe('EPERM'); }); + + it('accepts category=command', () => { + const op = buildArchiveOp({ + category: 'command', + scope: 'global', + source_path: '/home/u/.claude/commands/sc/build.md', + archive_path: '/home/u/.claude/ccaudit/archived/commands/sc/build.md', + content: '# cmd\n', + }); + expect(op.op_type).toBe('archive'); + expect(op.category).toBe('command'); + expect(op.status).toBe('completed'); + }); }); describe('buildDisableOp / buildFlagOp / buildRefreshOp / buildSkippedOp', () => { @@ -915,4 +1203,139 @@ if (import.meta.vitest) { expect(entry.mtime.getTime()).toBe(fakeMtime.getTime()); }); }); + + describe('buildHeader — selection_filter', () => { + function baseInput() { + return { + ccaudit_version: '0.0.1', + checkpoint_ghost_hash: 'sha256:abc', + checkpoint_timestamp: '2026-04-05T18:30:00.000Z', + since_window: '7d', + os: 'darwin' as NodeJS.Platform, + node_version: 'v22.20.0', + planned_ops: { archive: 1, disable: 0, flag: 0 }, + }; + } + + it('Test 6: { mode: full } is stored as-is', () => { + const header = buildHeader({ ...baseInput(), selection_filter: { mode: 'full' } }); + // buildHeader always sets selection_filter; non-null assertion is safe here + expect(header.selection_filter!.mode).toBe('full'); + }); + + it('Test 7: { mode: subset, ids } sorts ids ascending', () => { + const header = buildHeader({ + ...baseInput(), + selection_filter: { mode: 'subset', ids: ['b', 'a', 'c'] }, + }); + const sf = header.selection_filter!; + expect(sf.mode).toBe('subset'); + if (sf.mode === 'subset') { + expect(sf.ids).toEqual(['a', 'b', 'c']); + } + }); + + it('Test 8: omitting selection_filter defaults to { mode: full }', () => { + const header = buildHeader(baseInput()); + expect(header.selection_filter).toEqual({ mode: 'full' }); + }); + }); + + // -- Phase 9 SC6: archive_purge op + purge manifest --------------- + + describe('buildArchivePurgeOp', () => { + it('builds a follow-up op with uuid + ISO ts + purged:true', () => { + const op = buildArchivePurgeOp({ original_op_id: 'orig-uuid', reason: 'reclaimed' }); + expect(op.op_type).toBe('archive_purge'); + expect(op.op_id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/); + expect(op.timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T/); + expect(op.status).toBe('completed'); + expect(op.original_op_id).toBe('orig-uuid'); + expect(op.purged).toBe(true); + expect(op.reason).toBe('reclaimed'); + }); + + it('accepts each permitted reason', () => { + for (const reason of ['reclaimed', 'source_occupied', 'stale_archive_missing'] as const) { + const op = buildArchivePurgeOp({ original_op_id: 'o', reason }); + expect(op.reason).toBe(reason); + } + }); + }); + + describe('resolvePurgeManifestPath', () => { + it('returns purge--.jsonl under manifests/', () => { + const d = new Date('2026-04-22T12:00:00.000Z'); + const p = resolvePurgeManifestPath(d); + expect(p).toMatch( + /[/\\]\.claude[/\\]ccaudit[/\\]manifests[/\\]purge-2026-04-22T12-00-00-000Z-[a-z0-9]{4}\.jsonl$/, + ); + }); + }); + + describe('writePurgeManifest', () => { + let tmp: string; + beforeEach(async () => { + tmp = await mkdtemp(path.join(tmpdir(), 'purge-manifest-')); + }); + afterEach(async () => { + await rm(tmp, { recursive: true, force: true }); + }); + + it('writes header + ops + footer; round-trips via readManifest', async () => { + const manifestPath = path.join(tmp, 'purge-test.jsonl'); + const ops = [ + buildArchivePurgeOp({ original_op_id: 'a', reason: 'reclaimed' }), + buildArchivePurgeOp({ original_op_id: 'b', reason: 'source_occupied' }), + ]; + const result = await writePurgeManifest(ops, { + ccaudit_version: '1.5.0', + purge_timestamp: '2026-04-22T12:00:00.000Z', + manifestPath, + }); + expect(result.opCount).toBe(2); + const read = await readManifest(manifestPath); + expect(read.header).toBeTruthy(); + expect(read.header!.checkpoint_ghost_hash).toBe('purge:2026-04-22T12:00:00.000Z'); + expect(read.ops).toHaveLength(2); + expect(read.ops[0]!.op_type).toBe('archive_purge'); + expect(read.footer).toBeTruthy(); + }); + + it('writes header + empty-ops + footer when ops list is empty', async () => { + const manifestPath = path.join(tmp, 'purge-empty.jsonl'); + await writePurgeManifest([], { + ccaudit_version: '1.5.0', + purge_timestamp: '2026-04-22T12:00:00.000Z', + manifestPath, + }); + const read = await readManifest(manifestPath); + expect(read.header).toBeTruthy(); + expect(read.ops).toHaveLength(0); + expect(read.footer).toBeTruthy(); + }); + }); + + describe('discoverManifests — includes purge-*.jsonl', () => { + it('surfaces both bust-*.jsonl and purge-*.jsonl; filters unrelated', async () => { + const fakeMtime = new Date('2026-04-22T12:00:00Z'); + const deps: DiscoverManifestsDeps = { + manifestsDir: '/fake/manifests', + readdir: async () => [ + 'bust-2026-04-20T10-00-00Z.jsonl', + 'purge-2026-04-22T12-00-00Z.jsonl', + 'other.txt', + 'ccaudit-log.jsonl', + ], + stat: async () => ({ mtime: fakeMtime }), + }; + const result = await discoverManifests(deps); + expect(result).toHaveLength(2); + const names = result.map((r) => path.basename(r.path)).sort(); + expect(names).toEqual([ + 'bust-2026-04-20T10-00-00Z.jsonl', + 'purge-2026-04-22T12-00-00Z.jsonl', + ]); + }); + }); } diff --git a/packages/internal/src/remediation/purge.ts b/packages/internal/src/remediation/purge.ts new file mode 100644 index 0000000..79c07a4 --- /dev/null +++ b/packages/internal/src/remediation/purge.ts @@ -0,0 +1,887 @@ +// @ccaudit/internal — purge-archive domain core (Phase 9 SC6) +// +// Pure classifier + executor for `ccaudit purge-archive`. The CLI wrapper +// (Plan 09-04) is intentionally NOT landed here — this module owns only +// the append-only, fs-probe-driven logic that decides what happens to +// each ArchiveOp in the manifest union. +// +// Classification rules (matching D6 in 09-CONTEXT.md): +// - Source FREE + archive exists → reclaim (move back) +// - Source OCCUPIED + archive exists → drop (unlink archive) +// - Archive MISSING + source exists → drop/stale (Phase 8.2 shape) +// - Both missing → skip (preserve for diagnosis) +// +// Safety invariants: +// - Dry-run writes NOTHING (no fs mutation, no manifest op). INV-S2 spirit. +// - Real purge: per-item try/catch — one bad op does not abort the batch. +// - Every successful mutation produces a single append-only archive_purge op. +// - moveArchiveToSource refuses to overwrite existing source (helper +// preserves the reclaim INV). + +import type { ArchiveOp, ArchivePurgeOp, ManifestOp, ManifestWriter } from './manifest.ts'; +import { buildArchivePurgeOp, closePurgeManifestWriter } from './manifest.ts'; +import { isStaleArchiveOp } from './restore.ts'; +import { moveArchiveToSource, type ArchiveMoveDeps } from './archive-move.ts'; +import { Result } from '@praha/byethrow'; + +// -- Public types --------------------------------------------------- + +export type DropReason = 'source_occupied' | 'stale_archive_missing'; + +export interface PurgePlan { + reclaim: Array<{ op: ArchiveOp }>; + drop: Array<{ op: ArchiveOp; reason: DropReason }>; + skip: Array<{ op: ArchiveOp; reason: 'both_missing' }>; +} + +export interface PurgeFailure { + path: string; + op_id: string; + reason: string; +} + +export interface PurgeSummary { + purgedCount: number; + reclaimedCount: number; + skippedOccupiedCount: number; + staleFilteredCount: number; +} + +export interface PurgeResult { + summary: PurgeSummary; + failures: PurgeFailure[]; + /** Path of the follow-up manifest written (real runs only; null on dry-run / zero-mutation). */ + manifestPath: string | null; + /** The archive_purge ops appended, for caller inspection/JSON envelope. */ + appendedOps: ArchivePurgeOp[]; +} + +// -- Executor deps -------------------------------------------------- + +/** + * Dependency surface for {@link executePurge}. All fs + manifest I/O is + * injected so unit tests can assert zero-write behavior on dry-runs and + * verify per-item error paths without touching real disk. + */ +export interface ExecutePurgeDeps extends ArchiveMoveDeps { + /** Unlink a file. Tests typically spy on this to assert archive removal. */ + unlinkFile: (p: string) => Promise; + /** + * Open a per-op purge manifest writer. Called once before any mutations; + * the returned writer is used to fsync each op immediately after its + * mutation succeeds (NEW-C1 audit-trail invariant). + * + * Real callers pass a wrapper around {@link openPurgeManifestWriter}; + * tests inject a spy that returns a mock writer. + */ + createPurgeManifestWriter: (input: { + ccaudit_version: string; + purge_timestamp: string; + }) => Promise<{ writer: ManifestWriter; path: string }>; + /** Runtime version string embedded in the purge manifest header. */ + ccauditVersion: string; + /** Injectable clock (ISO 8601 UTC) — defaults to new Date(). */ + now?: () => Date; +} + +// -- Classifier ----------------------------------------------------- + +/** + * Pure classifier over an already-deduped manifest op list. + * + * Filters to `op_type === 'archive'` — flag/refresh/skipped/disable/ + * archive_purge ops are never touched by purge-archive (09-CONTEXT D6). + * + * Reuses {@link isStaleArchiveOp} from restore.ts for stale detection so + * the predicate does not fork. + */ +export async function classifyArchiveOps( + ops: ReadonlyArray, + fsProbe: (path: string) => Promise, +): Promise { + const reclaim: PurgePlan['reclaim'] = []; + const drop: PurgePlan['drop'] = []; + const skip: PurgePlan['skip'] = []; + const purgedOriginalOpIds = collectPurgedArchiveOpIds(ops); + + for (const op of ops) { + if (op.op_type !== 'archive') continue; + if (purgedOriginalOpIds.has(op.op_id)) continue; + const [archiveExists, sourceExists] = await Promise.all([ + fsProbe(op.archive_path), + fsProbe(op.source_path), + ]); + + if (archiveExists && !sourceExists) { + reclaim.push({ op }); + continue; + } + if (archiveExists && sourceExists) { + drop.push({ op, reason: 'source_occupied' }); + continue; + } + // archive missing branch + if (!archiveExists && sourceExists) { + // Stale-archive shape per Phase 8.2 (archive_missing + source_exists). + // Re-probe via the canonical predicate so we don't fork the check. + const stale = await isStaleArchiveOp(op, fsProbe); + if (stale) { + drop.push({ op, reason: 'stale_archive_missing' }); + continue; + } + } + // Both missing (or pathological mismatch) — preserve for diagnosis. + skip.push({ op, reason: 'both_missing' }); + } + + return { reclaim, drop, skip }; +} + +/** + * Follow-up purge manifests are append-only: the original ArchiveOp remains in + * its bust manifest, while a completed ArchivePurgeOp references it by op_id. + * Suppress those originals before probing disk so a second purge run is a no-op + * instead of reclassifying them as stale_archive_missing. + */ +function collectPurgedArchiveOpIds(ops: ReadonlyArray): Set { + const purged = new Set(); + for (const op of ops) { + if (op.op_type === 'archive_purge' && op.status === 'completed' && op.purged === true) { + purged.add(op.original_op_id); + } + } + return purged; +} + +// -- Executor ------------------------------------------------------- + +/** + * Execute a classified {@link PurgePlan}. + * + * - `dryRun: true` — computes the summary and writes nothing. Safe by + * default when callers forget to opt in (INV-S2 spirit: aborted/dry + * flows leave manifests untouched). + * - `dryRun: false` — for each reclaim item, moves archive → source and + * appends an archive_purge op with reason='reclaimed'. For each drop + * item, unlinks the archive (if present) and appends archive_purge + * with the classifier's drop reason. Per-item try/catch: one failure + * is recorded and the batch continues. + * + * Returns Result.err only when every requested item failed. Otherwise + * returns Result.ok with the partial-summary + failures array so the + * CLI envelope can surface both. + */ +export async function executePurge( + plan: PurgePlan, + deps: ExecutePurgeDeps, + opts: { dryRun: boolean }, +): Promise> { + const summary: PurgeSummary = { + purgedCount: 0, + reclaimedCount: 0, + skippedOccupiedCount: 0, + staleFilteredCount: 0, + }; + const failures: PurgeFailure[] = []; + const appendedOps: ArchivePurgeOp[] = []; + + const totalRequested = plan.reclaim.length + plan.drop.length; + + if (opts.dryRun) { + // Dry-run: compute counters from the plan; NO deps called at all. + summary.reclaimedCount = plan.reclaim.length; + for (const item of plan.drop) { + summary.purgedCount += 1; + if (item.reason === 'source_occupied') summary.skippedOccupiedCount += 1; + else summary.staleFilteredCount += 1; + } + return Result.succeed({ + summary, + failures: [], + manifestPath: null, + appendedOps: [], + }); + } + + // Real purge path --------------------------------------------------- + // NEW-C1: open the manifest writer BEFORE any mutations so that each + // successful mutation is immediately followed by a fsynced journal entry. + // If the open/header-write fails we abort before touching the filesystem. + + // Short-circuit: if the plan is empty there is nothing to do and we must + // NOT create an empty purge-*.jsonl (would litter manifests/ on no-op runs). + if (totalRequested === 0) { + return Result.succeed({ summary, failures, manifestPath: null, appendedOps }); + } + + const nowDate = (deps.now ?? (() => new Date()))(); + let writer: ManifestWriter; + let manifestPath: string; + try { + const opened = await deps.createPurgeManifestWriter({ + ccaudit_version: deps.ccauditVersion, + purge_timestamp: nowDate.toISOString(), + }); + writer = opened.writer; + manifestPath = opened.path; + } catch (err) { + // Header-open failed before any disk mutation — safe to surface as failure. + return Result.fail(err instanceof Error ? err : new Error(String(err))); + } + + for (const { op } of plan.reclaim) { + try { + const moved = await moveArchiveToSource( + { archivePath: op.archive_path, sourcePath: op.source_path }, + deps, + ); + if (Result.isFailure(moved)) { + failures.push({ path: op.archive_path, op_id: op.op_id, reason: moved.error.message }); + continue; + } + const purgeOp = buildArchivePurgeOp({ original_op_id: op.op_id, reason: 'reclaimed' }); + // NEW-C1: fsync the op entry immediately after the mutation succeeds. + try { + await writer.writeOp(purgeOp); + } catch (writeErr) { + failures.push({ + path: op.archive_path, + op_id: op.op_id, + reason: `manifest_write_failed: ${writeErr instanceof Error ? writeErr.message : String(writeErr)}`, + }); + continue; + } + appendedOps.push(purgeOp); + summary.reclaimedCount += 1; + } catch (err) { + failures.push({ + path: op.archive_path, + op_id: op.op_id, + reason: err instanceof Error ? err.message : String(err), + }); + } + } + + for (const { op, reason } of plan.drop) { + try { + // Only unlink when archive is physically present. For + // `stale_archive_missing` the file is already gone by definition — + // we still write the follow-up op to suppress the stale entry + // from future restore listings. + if (reason === 'source_occupied') { + await deps.unlinkFile(op.archive_path); + } + const purgeOp = buildArchivePurgeOp({ original_op_id: op.op_id, reason }); + // NEW-C1: fsync the op entry immediately after the mutation succeeds. + try { + await writer.writeOp(purgeOp); + } catch (writeErr) { + failures.push({ + path: op.archive_path, + op_id: op.op_id, + reason: `manifest_write_failed: ${writeErr instanceof Error ? writeErr.message : String(writeErr)}`, + }); + continue; + } + appendedOps.push(purgeOp); + summary.purgedCount += 1; + if (reason === 'source_occupied') summary.skippedOccupiedCount += 1; + else summary.staleFilteredCount += 1; + } catch (err) { + failures.push({ + path: op.archive_path, + op_id: op.op_id, + reason: err instanceof Error ? err.message : String(err), + }); + } + } + + // If everything we attempted failed, surface it as Result.err so the + // CLI can exit non-zero without having to count failures itself. + if (totalRequested > 0 && failures.length === totalRequested) { + // Best-effort close without footer (crash-signature: header present, footer absent). + try { + await closePurgeManifestWriter(writer, null); + } catch { + // ignore close errors on the all-failed path + } + return Result.fail( + new Error(`all ${totalRequested} purge ops failed; see failures[] for per-item reasons`), + ); + } + + // Close the manifest with a success footer. + try { + await closePurgeManifestWriter(writer, { durationMs: writer.elapsedMs }); + } catch (closeErr) { + // Close failure is non-fatal — mutations + per-op entries already durable. + failures.push({ + path: manifestPath, + op_id: 'manifest-close', + reason: `manifest_close_failed: ${closeErr instanceof Error ? closeErr.message : String(closeErr)}`, + }); + } + + return Result.succeed({ summary, failures, manifestPath, appendedOps }); +} + +// -- In-source unit tests ------------------------------------------ + +if (import.meta.vitest) { + const { describe, it, expect, vi } = import.meta.vitest; + + // Fixture factories ------------------------------------------------ + + const archiveOp = (overrides: Partial = {}): ArchiveOp => ({ + op_id: overrides.op_id ?? 'op-archive-1', + op_type: 'archive', + timestamp: '2026-04-10T00:00:00Z', + status: 'completed', + category: 'skill', + scope: 'global', + source_path: '/h/.claude/skills/foo.md', + archive_path: '/h/.claude/ccaudit/archived/.claude/skills/foo.md', + content_sha256: 'sha256:abc', + ...overrides, + }); + + const flagOp = (): ManifestOp => ({ + op_id: 'op-flag-1', + op_type: 'flag', + timestamp: '2026-04-10T00:00:00Z', + status: 'completed', + file_path: '/h/.claude/CLAUDE.md', + scope: 'global', + had_frontmatter: false, + had_ccaudit_stale: false, + patched_keys: ['ccaudit-stale'], + original_content_sha256: 'sha256:x', + }); + + // classifyArchiveOps ----------------------------------------------- + + describe('classifyArchiveOps', () => { + it('source_free + archive_exists → reclaim', async () => { + const op = archiveOp(); + const fsProbe = async (p: string) => p === op.archive_path; + const plan = await classifyArchiveOps([op], fsProbe); + expect(plan.reclaim).toHaveLength(1); + expect(plan.drop).toHaveLength(0); + expect(plan.skip).toHaveLength(0); + }); + + it('source_exists + archive_exists → drop/source_occupied', async () => { + const op = archiveOp(); + const fsProbe = async () => true; + const plan = await classifyArchiveOps([op], fsProbe); + expect(plan.drop).toHaveLength(1); + expect(plan.drop[0]!.reason).toBe('source_occupied'); + expect(plan.reclaim).toHaveLength(0); + }); + + it('source_exists + archive_missing → drop/stale_archive_missing', async () => { + const op = archiveOp(); + const fsProbe = async (p: string) => p === op.source_path; + const plan = await classifyArchiveOps([op], fsProbe); + expect(plan.drop).toHaveLength(1); + expect(plan.drop[0]!.reason).toBe('stale_archive_missing'); + }); + + it('both_missing → skip (preserved for diagnosis)', async () => { + const op = archiveOp(); + const fsProbe = async () => false; + const plan = await classifyArchiveOps([op], fsProbe); + expect(plan.skip).toHaveLength(1); + expect(plan.skip[0]!.reason).toBe('both_missing'); + }); + + it('flag op is ignored (not touched by purge)', async () => { + const plan = await classifyArchiveOps([flagOp()], async () => true); + expect(plan.reclaim).toHaveLength(0); + expect(plan.drop).toHaveLength(0); + expect(plan.skip).toHaveLength(0); + }); + + it('archive_purge follow-up suppresses original archive op for idempotency', async () => { + const original = archiveOp({ op_id: 'op-already-purged' }); + const plan = await classifyArchiveOps( + [ + original, + { + op_id: 'purge-1', + op_type: 'archive_purge', + timestamp: '2026-04-22T09:01:00.000Z', + status: 'completed', + original_op_id: 'op-already-purged', + purged: true, + reason: 'stale_archive_missing', + }, + ], + async (p) => p === original.source_path, + ); + expect(plan).toEqual({ reclaim: [], drop: [], skip: [] }); + }); + + it('mixed batch classifies each op independently', async () => { + const reclaimable = archiveOp({ + op_id: 'r', + archive_path: '/a/r', + source_path: '/s/r', + }); + const occupied = archiveOp({ + op_id: 'o', + archive_path: '/a/o', + source_path: '/s/o', + }); + const stale = archiveOp({ + op_id: 's', + archive_path: '/a/s', + source_path: '/s/s', + }); + const broken = archiveOp({ + op_id: 'b', + archive_path: '/a/b', + source_path: '/s/b', + }); + // Per-path probe map + const existing = new Set(['/a/r', '/a/o', '/s/o', '/s/s']); + const fsProbe = async (p: string) => existing.has(p); + const plan = await classifyArchiveOps( + [reclaimable, occupied, stale, broken, flagOp()], + fsProbe, + ); + expect(plan.reclaim.map((i) => i.op.op_id)).toEqual(['r']); + expect(plan.drop.map((i) => ({ id: i.op.op_id, reason: i.reason }))).toEqual([ + { id: 'o', reason: 'source_occupied' }, + { id: 's', reason: 'stale_archive_missing' }, + ]); + expect(plan.skip.map((i) => i.op.op_id)).toEqual(['b']); + }); + }); + + // executePurge ----------------------------------------------------- + + /** Build a mock ManifestWriter whose writeOp / close methods are vi.fn(). */ + const makeMockWriter = (writeOpImpl?: (op: ManifestOp) => Promise) => { + const writeOp = vi.fn(writeOpImpl ?? (async () => undefined)); + const close = vi.fn(async () => undefined); + const writer = { + writeOp, + close, + filePath: '/fake/manifests/purge-test.jsonl', + elapsedMs: 0, + } as unknown as ManifestWriter; + return { writer, writeOp, close }; + }; + + const fakeDeps = (overrides: Partial = {}): ExecutePurgeDeps => { + const { writer } = makeMockWriter(); + return { + pathExists: overrides.pathExists ?? (async () => false), + mkdirRecursive: overrides.mkdirRecursive ?? (async () => undefined), + renameFile: overrides.renameFile ?? (async () => undefined), + unlinkFile: overrides.unlinkFile ?? (async () => undefined), + createPurgeManifestWriter: + overrides.createPurgeManifestWriter ?? + (async () => ({ + writer, + path: '/fake/manifests/purge-test.jsonl', + })), + ccauditVersion: overrides.ccauditVersion ?? '1.5.0-test', + now: overrides.now ?? (() => new Date('2026-04-22T12:00:00.000Z')), + }; + }; + + describe('executePurge — dry-run', () => { + it('dry-run produces summary + writes nothing (no deps called)', async () => { + const renameFile = vi.fn(async () => undefined); + const unlinkFile = vi.fn(async () => undefined); + const createPurgeManifestWriter = vi.fn(async () => ({ + writer: makeMockWriter().writer, + path: '/x', + })); + const plan: PurgePlan = { + reclaim: [{ op: archiveOp({ op_id: 'r1' }) }], + drop: [ + { op: archiveOp({ op_id: 'd1' }), reason: 'source_occupied' }, + { op: archiveOp({ op_id: 'd2' }), reason: 'stale_archive_missing' }, + ], + skip: [{ op: archiveOp({ op_id: 's1' }), reason: 'both_missing' }], + }; + const result = await executePurge( + plan, + fakeDeps({ renameFile, unlinkFile, createPurgeManifestWriter }), + { dryRun: true }, + ); + expect(Result.isSuccess(result)).toBe(true); + if (Result.isSuccess(result)) { + expect(result.value.summary.reclaimedCount).toBe(1); + expect(result.value.summary.purgedCount).toBe(2); + expect(result.value.summary.skippedOccupiedCount).toBe(1); + expect(result.value.summary.staleFilteredCount).toBe(1); + expect(result.value.manifestPath).toBeNull(); + expect(result.value.appendedOps).toHaveLength(0); + } + expect(renameFile).not.toHaveBeenCalled(); + expect(unlinkFile).not.toHaveBeenCalled(); + expect(createPurgeManifestWriter).not.toHaveBeenCalled(); + }); + }); + + describe('executePurge — real run happy paths', () => { + it('reclaim: moves archive, appends archive_purge op reason=reclaimed', async () => { + const op = archiveOp({ op_id: 'r1' }); + const renameFile = vi.fn(async () => undefined); + const { writer, writeOp } = makeMockWriter(); + const createPurgeManifestWriter = vi.fn(async () => ({ + writer, + path: '/fake/manifests/purge.jsonl', + })); + const plan: PurgePlan = { reclaim: [{ op }], drop: [], skip: [] }; + const result = await executePurge( + plan, + fakeDeps({ + // helper re-probes: archive exists, source free + pathExists: async (p: string) => p === op.archive_path, + renameFile, + createPurgeManifestWriter, + }), + { dryRun: false }, + ); + expect(Result.isSuccess(result)).toBe(true); + if (Result.isSuccess(result)) { + expect(result.value.summary.reclaimedCount).toBe(1); + expect(result.value.appendedOps).toHaveLength(1); + expect(result.value.appendedOps[0]!.reason).toBe('reclaimed'); + expect(result.value.appendedOps[0]!.original_op_id).toBe('r1'); + expect(result.value.manifestPath).toBe('/fake/manifests/purge.jsonl'); + } + expect(renameFile).toHaveBeenCalledTimes(1); + expect(createPurgeManifestWriter).toHaveBeenCalledTimes(1); + // writeOp called once immediately after the mutation + expect(writeOp).toHaveBeenCalledTimes(1); + }); + + it('drop/source_occupied: unlinks archive, appends archive_purge', async () => { + const op = archiveOp({ op_id: 'd1' }); + const unlinkFile = vi.fn(async () => undefined); + const { writer, writeOp } = makeMockWriter(); + const createPurgeManifestWriter = vi.fn(async () => ({ + writer, + path: '/fake/manifests/purge.jsonl', + })); + const plan: PurgePlan = { + reclaim: [], + drop: [{ op, reason: 'source_occupied' }], + skip: [], + }; + const result = await executePurge(plan, fakeDeps({ unlinkFile, createPurgeManifestWriter }), { + dryRun: false, + }); + expect(Result.isSuccess(result)).toBe(true); + if (Result.isSuccess(result)) { + expect(result.value.summary.purgedCount).toBe(1); + expect(result.value.summary.skippedOccupiedCount).toBe(1); + expect(result.value.summary.staleFilteredCount).toBe(0); + expect(result.value.appendedOps[0]!.reason).toBe('source_occupied'); + } + expect(unlinkFile).toHaveBeenCalledWith(op.archive_path); + expect(writeOp).toHaveBeenCalledTimes(1); + }); + + it('drop/stale_archive_missing: does NOT unlink (already gone); still writes op', async () => { + const op = archiveOp({ op_id: 'd-stale' }); + const unlinkFile = vi.fn(async () => undefined); + const { writer, writeOp } = makeMockWriter(); + const createPurgeManifestWriter = vi.fn(async () => ({ + writer, + path: '/fake/manifests/purge.jsonl', + })); + const plan: PurgePlan = { + reclaim: [], + drop: [{ op, reason: 'stale_archive_missing' }], + skip: [], + }; + const result = await executePurge(plan, fakeDeps({ unlinkFile, createPurgeManifestWriter }), { + dryRun: false, + }); + expect(Result.isSuccess(result)).toBe(true); + if (Result.isSuccess(result)) { + expect(result.value.summary.purgedCount).toBe(1); + expect(result.value.summary.staleFilteredCount).toBe(1); + expect(result.value.summary.skippedOccupiedCount).toBe(0); + } + expect(unlinkFile).not.toHaveBeenCalled(); + // writeOp still called for the stale op so it's suppressed from future listings + expect(writeOp).toHaveBeenCalledTimes(1); + }); + + it('skip items produce no mutations and no manifest open', async () => { + const createPurgeManifestWriter = vi.fn(async () => ({ + writer: makeMockWriter().writer, + path: '/x', + })); + const plan: PurgePlan = { + reclaim: [], + drop: [], + skip: [{ op: archiveOp(), reason: 'both_missing' }], + }; + const result = await executePurge(plan, fakeDeps({ createPurgeManifestWriter }), { + dryRun: false, + }); + expect(Result.isSuccess(result)).toBe(true); + if (Result.isSuccess(result)) { + expect(result.value.summary.purgedCount).toBe(0); + expect(result.value.summary.reclaimedCount).toBe(0); + expect(result.value.manifestPath).toBeNull(); + } + expect(createPurgeManifestWriter).not.toHaveBeenCalled(); + }); + }); + + describe('executePurge — partial failures', () => { + it('one bad unlink does not abort batch; failures[] records path', async () => { + const good = archiveOp({ + op_id: 'good', + archive_path: '/a/good', + source_path: '/s/good', + }); + const bad = archiveOp({ op_id: 'bad', archive_path: '/a/bad', source_path: '/s/bad' }); + const unlinkFile = vi.fn(async (p: string) => { + if (p === '/a/bad') throw new Error('EACCES'); + }); + const { writer } = makeMockWriter(); + const createPurgeManifestWriter = vi.fn(async () => ({ + writer, + path: '/fake/manifests/purge.jsonl', + })); + const plan: PurgePlan = { + reclaim: [], + drop: [ + { op: good, reason: 'source_occupied' }, + { op: bad, reason: 'source_occupied' }, + ], + skip: [], + }; + const result = await executePurge(plan, fakeDeps({ unlinkFile, createPurgeManifestWriter }), { + dryRun: false, + }); + expect(Result.isSuccess(result)).toBe(true); + if (Result.isSuccess(result)) { + expect(result.value.summary.purgedCount).toBe(1); + expect(result.value.failures).toHaveLength(1); + expect(result.value.failures[0]!.path).toBe('/a/bad'); + expect(result.value.failures[0]!.reason).toContain('EACCES'); + // Manifest still opened and written for the successful op + expect(result.value.manifestPath).toBe('/fake/manifests/purge.jsonl'); + } + }); + + it('all items failed → Result.err', async () => { + const unlinkFile = vi.fn(async () => { + throw new Error('boom'); + }); + const plan: PurgePlan = { + reclaim: [], + drop: [{ op: archiveOp({ op_id: 'd1' }), reason: 'source_occupied' }], + skip: [], + }; + const result = await executePurge(plan, fakeDeps({ unlinkFile }), { dryRun: false }); + expect(Result.isFailure(result)).toBe(true); + }); + + it('reclaim helper refuses on source_exists → failure captured, batch continues', async () => { + const op = archiveOp({ op_id: 'r1' }); + const other = archiveOp({ op_id: 'r2', archive_path: '/a/r2', source_path: '/s/r2' }); + // For r1: helper re-probes archive + source → both exist → source_exists failure + // For r2: archive exists, source free → success + const pathExists = async (p: string) => + p === op.archive_path || p === op.source_path || p === other.archive_path; + const { writer } = makeMockWriter(); + const createPurgeManifestWriter = vi.fn(async () => ({ + writer, + path: '/fake/manifests/purge.jsonl', + })); + const plan: PurgePlan = { reclaim: [{ op }, { op: other }], drop: [], skip: [] }; + const result = await executePurge(plan, fakeDeps({ pathExists, createPurgeManifestWriter }), { + dryRun: false, + }); + expect(Result.isSuccess(result)).toBe(true); + if (Result.isSuccess(result)) { + expect(result.value.summary.reclaimedCount).toBe(1); + expect(result.value.failures).toHaveLength(1); + expect(result.value.failures[0]!.reason).toContain('source'); + } + }); + }); + + describe('executePurge — manifest writer not opened when plan is empty', () => { + it('zero successes + zero failures (empty plan): createPurgeManifestWriter not called', async () => { + const createPurgeManifestWriter = vi.fn(async () => ({ + writer: makeMockWriter().writer, + path: '/x', + })); + const plan: PurgePlan = { reclaim: [], drop: [], skip: [] }; + const result = await executePurge(plan, fakeDeps({ createPurgeManifestWriter }), { + dryRun: false, + }); + expect(Result.isSuccess(result)).toBe(true); + expect(createPurgeManifestWriter).not.toHaveBeenCalled(); + }); + }); + + // M6: createPurgeManifestWriter (header-open) throwing must return Result.fail + // before any disk mutation — because the throw happens BEFORE the loops. + describe('executePurge — M6: header-open throw returns Result.fail before any mutation', () => { + it('createPurgeManifestWriter throws → Result.isFailure, zero mutations', async () => { + const op = archiveOp({ op_id: 'd1' }); + const unlinkFile = vi.fn(async () => undefined); + const plan: PurgePlan = { + reclaim: [], + drop: [{ op, reason: 'source_occupied' }], + skip: [], + }; + const createPurgeManifestWriter = vi.fn(async () => { + throw new Error('disk full'); + }); + const result = await executePurge(plan, fakeDeps({ unlinkFile, createPurgeManifestWriter }), { + dryRun: false, + }); + expect(Result.isFailure(result)).toBe(true); + if (Result.isFailure(result)) { + expect(result.error.message).toContain('disk full'); + } + // No mutations because the open failed before the loops + expect(unlinkFile).not.toHaveBeenCalled(); + }); + + it('createPurgeManifestWriter rejects with a non-Error value → Result.fail wraps it', async () => { + const op = archiveOp({ op_id: 'd2' }); + const plan: PurgePlan = { + reclaim: [], + drop: [{ op, reason: 'stale_archive_missing' }], + skip: [], + }; + const createPurgeManifestWriter = vi.fn( + () => + Promise.reject('write error string') as Promise<{ + writer: ManifestWriter; + path: string; + }>, + ); + const result = await executePurge(plan, fakeDeps({ createPurgeManifestWriter }), { + dryRun: false, + }); + expect(Result.isFailure(result)).toBe(true); + if (Result.isFailure(result)) { + expect(result.error.message).toContain('write error string'); + } + }); + }); + + // NEW-C1: per-op manifest fsync semantics + describe('executePurge — NEW-C1: per-op manifest fsync', () => { + it('mid-batch writeOp throw leaves prior successful ops persisted in memory; mutation still completes', async () => { + // 3 drop ops; writeOp throws on the 3rd call + const ops = [ + archiveOp({ op_id: 'drop-1', archive_path: '/a/1', source_path: '/s/1' }), + archiveOp({ op_id: 'drop-2', archive_path: '/a/2', source_path: '/s/2' }), + archiveOp({ op_id: 'drop-3', archive_path: '/a/3', source_path: '/s/3' }), + ]; + const unlinkFile = vi.fn(async () => undefined); + + let writeOpCallCount = 0; + const writeOpFn = vi.fn(async () => { + writeOpCallCount += 1; + if (writeOpCallCount === 3) throw new Error('ENOSPC'); + }); + const closeFn = vi.fn(async () => undefined); + const mockWriter = { + writeOp: writeOpFn, + close: closeFn, + filePath: '/fake/manifests/purge-newc1.jsonl', + elapsedMs: 0, + } as unknown as ManifestWriter; + + const createPurgeManifestWriter = vi.fn(async () => ({ + writer: mockWriter, + path: '/fake/manifests/purge-newc1.jsonl', + })); + + const plan: PurgePlan = { + reclaim: [], + drop: ops.map((op) => ({ op, reason: 'source_occupied' as DropReason })), + skip: [], + }; + + const result = await executePurge(plan, fakeDeps({ unlinkFile, createPurgeManifestWriter }), { + dryRun: false, + }); + + // All 3 unlinks were attempted (mutation loop continues after writeOp failure) + expect(unlinkFile).toHaveBeenCalledTimes(3); + // writeOp was called 3 times (once per successful unlink) + expect(writeOpFn).toHaveBeenCalledTimes(3); + + // Result is success (2 out of 3 succeeded) + expect(Result.isSuccess(result)).toBe(true); + if (Result.isSuccess(result)) { + // In-memory appendedOps only includes the 2 that wrote successfully + expect(result.value.appendedOps).toHaveLength(2); + // The 3rd op failure is recorded in failures[] + expect(result.value.failures).toHaveLength(1); + expect(result.value.failures[0]!.op_id).toBe('drop-3'); + expect(result.value.failures[0]!.reason).toContain('manifest_write_failed'); + } + }); + + it('header-open throw before any mutation leaves disk untouched', async () => { + const unlinkFile = vi.fn(async () => undefined); + const renameFile = vi.fn(async () => undefined); + const createPurgeManifestWriter = vi.fn(async () => { + throw new Error('EACCES: permission denied'); + }); + + const plan: PurgePlan = { + reclaim: [{ op: archiveOp({ op_id: 'r1' }) }], + drop: [{ op: archiveOp({ op_id: 'd1' }), reason: 'source_occupied' }], + skip: [], + }; + + const result = await executePurge( + plan, + fakeDeps({ unlinkFile, renameFile, createPurgeManifestWriter }), + { dryRun: false }, + ); + + // Zero mutations because open failed before any loop + expect(unlinkFile).not.toHaveBeenCalled(); + expect(renameFile).not.toHaveBeenCalled(); + expect(Result.isFailure(result)).toBe(true); + if (Result.isFailure(result)) { + expect(result.error.message).toContain('EACCES'); + } + }); + + it('empty plan does not open the writer (no orphan empty manifest file)', async () => { + const createPurgeManifestWriter = vi.fn(async () => ({ + writer: makeMockWriter().writer, + path: '/fake/manifests/purge-should-not-exist.jsonl', + })); + + const plan: PurgePlan = { reclaim: [], drop: [], skip: [] }; + + const result = await executePurge(plan, fakeDeps({ createPurgeManifestWriter }), { + dryRun: false, + }); + + expect(Result.isSuccess(result)).toBe(true); + // createPurgeManifestWriter must NOT have been called + expect(createPurgeManifestWriter).not.toHaveBeenCalled(); + if (Result.isSuccess(result)) { + expect(result.value.manifestPath).toBeNull(); + expect(result.value.appendedOps).toHaveLength(0); + } + }); + }); +} diff --git a/packages/internal/src/remediation/reclaim.ts b/packages/internal/src/remediation/reclaim.ts index 7bab554..8958a5d 100644 --- a/packages/internal/src/remediation/reclaim.ts +++ b/packages/internal/src/remediation/reclaim.ts @@ -24,10 +24,12 @@ // 5. Non-regular files (directories) in archived/: skip with warning. import path from 'node:path'; -import { homedir } from 'node:os'; +import { homedir, tmpdir } from 'node:os'; +import { Result } from '@praha/byethrow'; import type { ManifestEntry } from './manifest.ts'; import { readManifest } from './manifest.ts'; +import { moveArchiveToSource } from './archive-move.ts'; // -- Deps interface --------------------------------------------------- @@ -250,16 +252,26 @@ export async function reclaim(opts: ReclaimOptions): Promise { continue; } - try { - // Ensure parent directory exists before rename. - await deps.mkdirRecursive(path.dirname(orphan.inferredSource)); - await deps.renameFile(orphan.archivePath, orphan.inferredSource); + // Delegate the actual move to the shared helper. Archive existence was + // already confirmed via readDirRecursive; pass a pathExists that vouches + // for the archive and reports the (already-verified-free) source state. + const moveResult = await moveArchiveToSource( + { archivePath: orphan.archivePath, sourcePath: orphan.inferredSource }, + { + pathExists: async (p) => (p === orphan.archivePath ? true : deps.pathExists(p)), + mkdirRecursive: deps.mkdirRecursive, + renameFile: deps.renameFile, + }, + ); + + if (Result.isSuccess(moveResult)) { reclaimed++; - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - warn(`reclaim: failed to move ${orphan.archivePath} → ${orphan.inferredSource}: ${message}`); - failed.push({ archivePath: orphan.archivePath, error: message }); + continue; } + + const message = moveResult.error.message; + warn(`reclaim: failed to move ${orphan.archivePath} → ${orphan.inferredSource}: ${message}`); + failed.push({ archivePath: orphan.archivePath, error: message }); } return { orphans, reclaimed, skippedSourceExists, failed }; @@ -360,7 +372,7 @@ if (import.meta.vitest) { describe('reclaim unit tests', () => { it('returns empty result when archived root does not exist (ENOENT)', async () => { const deps: Partial = { - homeDir: '/home/user', + homeDir: path.join(tmpdir(), 'fake-home'), discoverManifests: async () => [], readManifest, readDirRecursive: async () => { @@ -379,7 +391,7 @@ if (import.meta.vitest) { it('rethrows non-ENOENT errors from readDirRecursive', async () => { const eacces = Object.assign(new Error('EACCES: permission denied'), { code: 'EACCES' }); const deps: Partial = { - homeDir: '/home/user', + homeDir: path.join(tmpdir(), 'fake-home'), discoverManifests: async () => [], readManifest, readDirRecursive: async () => { @@ -395,9 +407,9 @@ if (import.meta.vitest) { it('rethrows non-ENOENT errors from discoverManifests', async () => { const eio = Object.assign(new Error('EIO: i/o error'), { code: 'EIO' }); - const home = '/home/user'; - const archivedRoot = `${home}/.claude/ccaudit/archived`; - const archivePath = `${archivedRoot}/.claude/agents/foo.md`; + const home = path.join(tmpdir(), 'fake-home'); + const archivedRoot = path.join(home, '.claude', 'ccaudit', 'archived'); + const archivePath = path.join(archivedRoot, '.claude', 'agents', 'foo.md'); const deps: Partial = { homeDir: home, discoverManifests: async () => { @@ -414,9 +426,9 @@ if (import.meta.vitest) { }); it('treats all files as orphans when manifests dir is empty', async () => { - const home = '/home/user'; - const archivedRoot = `${home}/.claude/ccaudit/archived`; - const archivePath = `${archivedRoot}/.claude/agents/foo.md`; + const home = path.join(tmpdir(), 'fake-home'); + const archivedRoot = path.join(home, '.claude', 'ccaudit', 'archived'); + const archivePath = path.join(archivedRoot, '.claude', 'agents', 'foo.md'); const deps: Partial = { homeDir: home, @@ -437,9 +449,9 @@ if (import.meta.vitest) { }); it('symlinks are skipped with a warning (never followed)', async () => { - const home = '/home/user'; - const archivedRoot = `${home}/.claude/ccaudit/archived`; - const symlinkPath = `${archivedRoot}/.claude/agents/link.md`; + const home = path.join(tmpdir(), 'fake-home'); + const archivedRoot = path.join(home, '.claude', 'ccaudit', 'archived'); + const symlinkPath = path.join(archivedRoot, '.claude', 'agents', 'link.md'); const warnings: string[] = []; const deps: Partial = { @@ -462,9 +474,9 @@ if (import.meta.vitest) { }); it('directories in archived/ are silently skipped', async () => { - const home = '/home/user'; - const archivedRoot = `${home}/.claude/ccaudit/archived`; - const dirEntry = makeDir(`${archivedRoot}/.claude/agents`); + const home = path.join(tmpdir(), 'fake-home'); + const archivedRoot = path.join(home, '.claude', 'ccaudit', 'archived'); + const dirEntry = makeDir(path.join(archivedRoot, '.claude', 'agents')); const deps: Partial = { homeDir: home, @@ -481,9 +493,9 @@ if (import.meta.vitest) { }); it('SAFETY: never overwrites existing source file', async () => { - const home = '/home/user'; - const archivedRoot = `${home}/.claude/ccaudit/archived`; - const archivePath = `${archivedRoot}/.claude/agents/bar.md`; + const home = path.join(tmpdir(), 'fake-home'); + const archivedRoot = path.join(home, '.claude', 'ccaudit', 'archived'); + const archivePath = path.join(archivedRoot, '.claude', 'agents', 'bar.md'); const renameFile = vi.fn(async () => undefined); const deps: Partial = { @@ -504,9 +516,9 @@ if (import.meta.vitest) { }); it('dry-run: detects orphans but never calls renameFile', async () => { - const home = '/home/user'; - const archivedRoot = `${home}/.claude/ccaudit/archived`; - const archivePath = `${archivedRoot}/.claude/agents/baz.md`; + const home = path.join(tmpdir(), 'fake-home'); + const archivedRoot = path.join(home, '.claude', 'ccaudit', 'archived'); + const archivePath = path.join(archivedRoot, '.claude', 'agents', 'baz.md'); const renameFile = vi.fn(async () => undefined); const deps: Partial = { @@ -525,11 +537,60 @@ if (import.meta.vitest) { expect(renameFile).not.toHaveBeenCalled(); }); + it('TOCTOU (M7): source appearing after scan but before move is caught by deps.pathExists', async () => { + // Simulate a race: during the orphan-scan phase, the inferred source does NOT + // exist (pathExists returns false for the source → orphan.sourceExists = false, + // so the early-exit at line ~246 is skipped). Between scan and move a file + // materialises at the source path. The forwarded deps.pathExists now returns + // true, moveArchiveToSource's INVARIANT check fires, and the move is refused. + // Expected: reclaimed === 0, failed contains one entry, renameFile never called. + const home = path.join(tmpdir(), 'fake-home'); + const archivedRoot = path.join(home, '.claude', 'ccaudit', 'archived'); + const archivePath = path.join(archivedRoot, '.claude', 'agents', 'toctou.md'); + const inferredSource = path.join(home, '.claude', 'agents', 'toctou.md'); + const renameFile = vi.fn(async () => undefined); + let callCount = 0; + + const deps: Partial = { + homeDir: home, + discoverManifests: async () => [], + readManifest, + readDirRecursive: async () => [makeFile(archivePath)], + // First call (during orphan scan): source does NOT exist → sourceExists=false. + // Subsequent calls (forwarded from moveArchiveToSource): source NOW exists. + // Use path.resolve() for comparison so the test is correct on both POSIX + // and Windows regardless of separator differences. + pathExists: async (p: string) => { + if (path.resolve(p) === path.resolve(archivePath)) return true; // archive always present + if (path.resolve(p) === path.resolve(inferredSource)) { + callCount++; + return callCount > 1; // scan-time: false; move-time: true + } + return false; + }, + renameFile, + mkdirRecursive: async () => undefined, + }; + + const result = await reclaim({ dryRun: false, deps }); + expect(result.reclaimed).toBe(0); + expect(result.failed).toHaveLength(1); + expect(result.failed[0]?.archivePath).toBe(archivePath); + // renameFile must NOT have been called (overwrite prevented) + expect(renameFile).not.toHaveBeenCalled(); + }); + it('manifest-referenced file is NOT an orphan', async () => { - const home = '/home/user'; - const archivedRoot = `${home}/.claude/ccaudit/archived`; - const archivePath = `${archivedRoot}/.claude/agents/referenced.md`; - const manifestPath = `${home}/.claude/ccaudit/manifests/bust-2026-01-01T00-00-00Z.jsonl`; + const home = path.join(tmpdir(), 'fake-home'); + const archivedRoot = path.join(home, '.claude', 'ccaudit', 'archived'); + const archivePath = path.join(archivedRoot, '.claude', 'agents', 'referenced.md'); + const manifestPath = path.join( + home, + '.claude', + 'ccaudit', + 'manifests', + 'bust-2026-01-01T00-00-00Z.jsonl', + ); const fakeManifestResult = { header: { @@ -551,7 +612,7 @@ if (import.meta.vitest) { status: 'completed' as const, category: 'agent' as const, scope: 'global' as const, - source_path: `${home}/.claude/agents/referenced.md`, + source_path: path.join(home, '.claude', 'agents', 'referenced.md'), archive_path: archivePath, content_sha256: 'deadbeef', }, diff --git a/packages/internal/src/remediation/restore.ts b/packages/internal/src/remediation/restore.ts index c9b71d6..364ae32 100644 --- a/packages/internal/src/remediation/restore.ts +++ b/packages/internal/src/remediation/restore.ts @@ -129,6 +129,20 @@ export type RestoreResult = manifestPath: string; manifestPaths: string[]; duration_ms: number; + /** + * D8-16: null for full restore, { mode: 'subset', ids } for + * interactive / all-matching subset restore. Optional to keep + * existing call sites type-compatible; treat `undefined` as `null`. + */ + selectionFilter?: { mode: 'subset'; ids: string[] } | null; + /** + * D8-14: per-item source-exists skips aggregated across all + * manifests touched by this restore. Empty when nothing skipped. + */ + skipped: Array<{ reason: 'source_exists'; path: string; canonical_id: string }>; + // Phase 8.2 / SC6: additive. Count of archive ops suppressed + // (archive_missing + source_exists). Callers treat undefined as 0. + filteredStaleCount?: number; } | { status: 'partial-success'; @@ -137,11 +151,14 @@ export type RestoreResult = manifestPath: string; manifestPaths: string[]; duration_ms: number; + selectionFilter?: { mode: 'subset'; ids: string[] } | null; + skipped: Array<{ reason: 'source_exists'; path: string; canonical_id: string }>; + filteredStaleCount?: number; } | { status: 'no-manifests' } | { status: 'name-not-found'; name: string } | { status: 'manifest-corrupt'; path: string } - | { status: 'list'; entries: ManifestListEntry[] } + | { status: 'list'; entries: ManifestListEntry[]; filteredStaleCount: number } | { status: 'running-process'; pids: number[]; selfInvocation: boolean; message: string } | { status: 'process-detection-failed'; error: string } | { status: 'config-parse-error'; path: string; error: string } @@ -153,7 +170,12 @@ export type RestoreResult = * - single: restore ops matching a specific item name * - list: read-only listing of all manifests (skips process gate) */ -export type RestoreMode = { kind: 'full' } | { kind: 'single'; name: string } | { kind: 'list' }; +export type RestoreMode = + | { kind: 'full' } + | { kind: 'single'; name: string } + | { kind: 'list' } + | { kind: 'interactive'; ids: string[] } + | { kind: 'all-matching'; pattern: string }; // -- Helpers ------------------------------------------------------ @@ -189,6 +211,193 @@ export async function findManifestsForRestore(deps: RestoreDeps): Promise, +): Array<{ entry: ManifestEntry; op: ArchiveOp | DisableOp; canonical_id: string }> { + // Phase 9 SC6: archive_purge follow-up ops suppress their originals from + // restore candidates. Pass 1 collects every `original_op_id` that was + // purged across all manifests; pass 2 runs the existing dedup, skipping + // archive ops whose op_id appears in the purged set. + const purgedOriginalOpIds = collectPurgedOpIds(entries); + + // Keyed by canonical_id; first-seen wins (newest-first input ⇒ newer wins). + const seen = new Map< + string, + { entry: ManifestEntry; op: ArchiveOp | DisableOp; canonical_id: string } + >(); + for (const { entry, ops } of entries) { + for (const op of ops) { + let canonical_id: string; + if (op.op_type === 'archive') { + if (purgedOriginalOpIds.has(op.op_id)) continue; + canonical_id = `${op.category}:${op.archive_path}`; + } else if (op.op_type === 'disable') { + canonical_id = `mcp:${op.config_path}:${op.new_key}`; + } else { + continue; // flag / refresh / skipped / archive_purge ops are not dedup targets + } + if (!seen.has(canonical_id)) { + seen.set(canonical_id, { entry, op, canonical_id }); + } + } + } + return Array.from(seen.values()); +} + +/** + * Scan every op across all manifests for `archive_purge` follow-ups and + * return the set of `original_op_id` values they reference. A later + * archive_purge op suppresses its originating ArchiveOp from restore + * candidates (the archive has been drained). + */ +function collectPurgedOpIds( + entries: Array<{ entry: ManifestEntry; ops: readonly ManifestOp[] }>, +): Set { + const purged = new Set(); + for (const { ops } of entries) { + for (const op of ops) { + if (op.op_type === 'archive_purge') { + purged.add(op.original_op_id); + } + } + } + return purged; +} + +/** + * Collect restoreable ops including memory ops (FlagOp / RefreshOp) for the + * interactive restore picker (D81-01 / Phase 8.1 C1a). + * + * Sibling to `dedupManifestOps` — that function drops flag/refresh/skipped ops + * because `executeRestore`'s `--name` / `--all-matching` paths need the strict + * `ArchiveOp | DisableOp` shape. The interactive picker, however, must surface + * memory items so users can undo frontmatter flags from the TUI. + * + * canonical_id derivation: + * - archive op → `${category}:${archive_path}` + * - disable op → `mcp:${config_path}:${new_key}` + * - flag / refresh op → `memory:${file_path}` + * + * Skipped ops are omitted — by construction they represent items the bust + * could not operate on, so they are not restoreable. + * + * Input invariant: `entries` is newest-first (as returned by + * `findManifestsForRestore`). First-seen wins → newer manifest wins. + */ +export type RestoreableOp = ArchiveOp | DisableOp | FlagOp | RefreshOp; + +export function collectRestoreableItems( + entries: Array<{ entry: ManifestEntry; ops: readonly ManifestOp[] }>, +): Array<{ entry: ManifestEntry; op: RestoreableOp; canonical_id: string }> { + // Phase 9 SC6: archive_purge follow-ups suppress their originals here too. + const purgedOriginalOpIds = collectPurgedOpIds(entries); + const seen = new Map(); + for (const { entry, ops } of entries) { + for (const op of ops) { + let canonical_id: string; + if (op.op_type === 'archive') { + if (purgedOriginalOpIds.has(op.op_id)) continue; + canonical_id = `${op.category}:${op.archive_path}`; + } else if (op.op_type === 'disable') { + canonical_id = `mcp:${op.config_path}:${op.new_key}`; + } else if (op.op_type === 'flag' || op.op_type === 'refresh') { + // INV-S3: distinct flag/refresh ops on the same file MUST remain individually + // restoreable. Including op_type and op_id (uuid) guarantees uniqueness. + canonical_id = `memory:${op.op_type}:${op.file_path}:${op.op_id}`; + } else { + continue; // skipped / archive_purge ops are not restoreable + } + if (!seen.has(canonical_id)) { + seen.set(canonical_id, { entry, op, canonical_id }); + } + } + } + return Array.from(seen.values()); +} + +// Phase 8.2: Stale-archive predicate for restore listing hygiene. An +// archive op is stale iff archive_path is gone AND source_path is back +// — the already-restored / test-residue shape. Both-paths-missing is +// kept listed (D-02) so the executor can surface the fail-loud signal. +export async function isStaleArchiveOp( + op: ArchiveOp, + pathExists: (p: string) => Promise, +): Promise { + const [archiveStillThere, sourceBack] = await Promise.all([ + pathExists(op.archive_path), + pathExists(op.source_path), + ]); + return !archiveStillThere && sourceBack; +} + +// Phase 8.2: Filter a collected restoreable-items list, dropping only +// stale ARCHIVE ops. Flag / disable / refresh pass through (D-03 / SC2). +export async function filterRestoreableItems( + items: ReadonlyArray<{ entry: ManifestEntry; op: RestoreableOp; canonical_id: string }>, + pathExists: (p: string) => Promise, +): Promise<{ + kept: Array<{ entry: ManifestEntry; op: RestoreableOp; canonical_id: string }>; + filteredStaleCount: number; +}> { + const kept: Array<{ entry: ManifestEntry; op: RestoreableOp; canonical_id: string }> = []; + let filteredStaleCount = 0; + for (const item of items) { + if (item.op.op_type === 'archive' && (await isStaleArchiveOp(item.op, pathExists))) { + filteredStaleCount += 1; + continue; + } + kept.push(item); + } + return { kept, filteredStaleCount }; +} + +/** + * Case-insensitive substring matcher over a pre-deduped op list (D8-08). + * + * Matching rules: + * - archive op → basename of archive_path (without extension) + * - disable op → extractServerName(original_key) + * - pattern → lowercase, substring `includes` over the display name + * - empty / whitespace-only pattern → [] (guards against accidental match-all) + * + * Output is sorted lex ASC by canonical_id for deterministic tiebreaks. No + * ranking, no scoring — pure inclusion/exclusion. + */ +export function matchByName( + items: Array<{ canonical_id: string; op: ArchiveOp | DisableOp }>, + pattern: string, +): Array<{ canonical_id: string; op: ArchiveOp | DisableOp }> { + if (!pattern || pattern.trim().length === 0) return []; + const needle = pattern.toLowerCase(); + const matches = items.filter((item) => { + let displayName: string; + if (item.op.op_type === 'archive') { + displayName = path.basename(item.op.archive_path, path.extname(item.op.archive_path)); + } else { + displayName = extractServerName(item.op.original_key); + } + return displayName.toLowerCase().includes(needle); + }); + matches.sort((a, b) => + a.canonical_id < b.canonical_id ? -1 : a.canonical_id > b.canonical_id ? 1 : 0, + ); + return matches; +} + /** * Extract the server name from an original_key field that may use either * flat (mcpServers.) or nested (projects..mcpServers.) schema. @@ -264,18 +473,76 @@ function buildProcessGateMessage(processes: Array<{ pid: number; command?: strin async function executeListMode(deps: RestoreDeps): Promise { const entries = await deps.discoverManifests(); const listEntries: ManifestListEntry[] = []; + let filteredStaleCount = 0; + + // Two-pass list mode: read purge manifests first to collect archive_purge + // follow-ups, then suppress the referenced archive ops from bust manifests. + // This keeps `restore --list` consistent with full/subset restore after + // `purge-archive` has drained archived files. + const manifestCache = new Map< + string, + { manifest: Awaited>; isPurge: boolean } + >(); + const purgedOriginalOpIds = new Set(); + for (const entry of entries) { - const manifest = await deps.readManifest(entry.path); + let manifest: Awaited>; + try { + manifest = await deps.readManifest(entry.path); + } catch (err) { + deps.onWarning?.( + `⚠️ Skipping unreadable manifest ${path.basename(entry.path)}: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + continue; + } + const basename = path.basename(entry.path); + const isPurge = + basename.startsWith('purge-') || + (manifest.ops.length > 0 && manifest.ops.every((op) => op.op_type === 'archive_purge')); + manifestCache.set(entry.path, { manifest, isPurge }); + if (isPurge) { + for (const op of manifest.ops) { + if (op.op_type === 'archive_purge') purgedOriginalOpIds.add(op.original_op_id); + } + } + } + + for (const entry of entries) { + const cached = manifestCache.get(entry.path); + if (cached === undefined) continue; + + // M9: skip purge manifests — their archive_purge records are not restoreable + // items and should not appear in --list output as additional "busts". + if (cached.isPurge) continue; + + const { manifest } = cached; if (manifest.header === null) continue; // corrupt: silently skip in list mode + + // Phase 8.2: drop stale archive ops (archive_missing + source_exists) + // from per-entry op lists so `--list` mirrors the listing hygiene + // applied in the interactive picker and full-restore paths. + const kept: ManifestOp[] = []; + for (const op of manifest.ops) { + if (op.op_type === 'archive') { + if (purgedOriginalOpIds.has(op.op_id)) continue; + if (await isStaleArchiveOp(op, deps.pathExists)) { + filteredStaleCount += 1; + continue; + } + } + kept.push(op); + } listEntries.push({ path: entry.path, mtime: entry.mtime, isPartial: manifest.footer === null, - opCount: manifest.ops.length, - ops: manifest.ops, + opCount: kept.length, + ops: kept, }); } - return { status: 'list', entries: listEntries }; + return { status: 'list', entries: listEntries, filteredStaleCount }; } // -- Internal result type for MCP re-enable ---------------------- @@ -569,7 +836,17 @@ async function executeOpsOnManifest( deps: RestoreDeps, start: number, allEntryPaths?: string[], + /** + * Optional map from ArchiveOp.archive_path → canonical_id so that the + * subset-restore path can populate skipped[] entries with stable ids. + * When provided, archive ops whose source_path already exists are + * recorded in the returned result's skipped[] array. + */ + canonicalIdByArchivePath?: Map, + // Phase 8.2: stale ops suppressed upstream (threaded to CLI envelope) + filteredStaleCount = 0, ): Promise { + const skipped: Array<{ reason: 'source_exists'; path: string; canonical_id: string }> = []; const counts: RestoreCounts = { unarchived: { moved: 0, alreadyAtSource: 0, failed: 0 }, reenabled: { completed: 0, failed: 0 }, @@ -615,8 +892,13 @@ async function executeOpsOnManifest( for (const op of skillOps) { const outcome = await restoreArchiveOp(op, deps); if (outcome === 'moved') counts.unarchived.moved++; - else if (outcome === 'already-at-source') counts.unarchived.alreadyAtSource++; - else counts.unarchived.failed++; + else if (outcome === 'already-at-source') { + counts.unarchived.alreadyAtSource++; + const cid = canonicalIdByArchivePath?.get(op.archive_path); + if (cid !== undefined) { + skipped.push({ reason: 'source_exists', path: op.source_path, canonical_id: cid }); + } + } else counts.unarchived.failed++; } // Step 5: Unarchive agents @@ -624,15 +906,43 @@ async function executeOpsOnManifest( for (const op of agentOps) { const outcome = await restoreArchiveOp(op, deps); if (outcome === 'moved') counts.unarchived.moved++; - else if (outcome === 'already-at-source') counts.unarchived.alreadyAtSource++; - else counts.unarchived.failed++; + else if (outcome === 'already-at-source') { + counts.unarchived.alreadyAtSource++; + const cid = canonicalIdByArchivePath?.get(op.archive_path); + if (cid !== undefined) { + skipped.push({ reason: 'source_exists', path: op.source_path, canonical_id: cid }); + } + } else counts.unarchived.failed++; + } + + // Step 5.1: Unarchive commands (after agents, before totals) + const commandOps = archiveOps.filter((o) => o.category === 'command'); + for (const op of commandOps) { + const outcome = await restoreArchiveOp(op, deps); + if (outcome === 'moved') counts.unarchived.moved++; + else if (outcome === 'already-at-source') { + counts.unarchived.alreadyAtSource++; + const cid = canonicalIdByArchivePath?.get(op.archive_path); + if (cid !== undefined) { + skipped.push({ reason: 'source_exists', path: op.source_path, canonical_id: cid }); + } + } else counts.unarchived.failed++; } const totalFailed = counts.unarchived.failed + counts.reenabled.failed + counts.stripped.failed; const duration_ms = Date.now() - start; const manifestPaths = allEntryPaths ?? [entry.path]; if (totalFailed === 0) { - return { status: 'success', counts, manifestPath: entry.path, manifestPaths, duration_ms }; + return { + status: 'success', + counts, + manifestPath: entry.path, + manifestPaths, + duration_ms, + selectionFilter: null, + skipped, + filteredStaleCount, + }; } return { status: 'partial-success', @@ -641,6 +951,170 @@ async function executeOpsOnManifest( manifestPath: entry.path, manifestPaths, duration_ms, + selectionFilter: null, + skipped, + filteredStaleCount, + }; +} + +/** + * Group-selected ops by manifest, dispatch through executeOpsOnManifest + * once per manifest, aggregate counts + skipped[] + manifestPaths. + * + * Shared between `{ kind: 'interactive' }` and `{ kind: 'all-matching' }` + * (both are subset restores driven by a list of canonical_ids). + * + * Returns `{ status: 'no-manifests' }` when there is no bust history and + * `{ status: 'name-not-found', name: '' }` when the selected ids + * match nothing in the restoreable op pool. Otherwise returns success / + * partial-success with `selectionFilter: { mode: 'subset', ids }` and + * the aggregated `skipped[]` array. No manifest is synthesized on disk. + */ +async function executeInteractiveOps( + ids: string[], + deps: RestoreDeps, + start: number, +): Promise { + const allEntries = await findManifestsForRestore(deps); + if (allEntries.length === 0) return { status: 'no-manifests' }; + + // Zip (entry, ops) for each valid manifest; skip unreadable or corrupt ones + // with a warning — mirrors the full-restore / --list tolerant pattern so a + // single bad manifest never hard-fails a subset restore. + const zipped: Array<{ entry: ManifestEntry; ops: readonly ManifestOp[] }> = []; + for (const e of allEntries) { + let m: Awaited>; + try { + m = await deps.readManifest(e.path); + } catch (err) { + deps.onWarning?.( + `⚠️ Skipping unreadable manifest ${path.basename(e.path)}: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + continue; + } + if (m.header === null) { + deps.onWarning?.(`⚠️ Skipping corrupt manifest ${path.basename(e.path)} (no header record)`); + continue; + } + zipped.push({ entry: e, ops: m.ops }); + } + + // Phase 8.2: strip stale archive ops before resolving ids. + const { kept: collected, filteredStaleCount: interactiveFilteredStale } = + await filterRestoreableItems(collectRestoreableItems(zipped), deps.pathExists); + const availableById = new Map(collected.map((item) => [item.canonical_id, item])); + const resolvedSelectedIds: string[] = []; + const resolvedIdSet = new Set(); + for (const id of ids) { + if (resolvedIdSet.has(id)) continue; + if (!availableById.has(id)) continue; + resolvedIdSet.add(id); + resolvedSelectedIds.push(id); + } + const selected = collected.filter((item) => resolvedIdSet.has(item.canonical_id)); + + if (selected.length === 0) { + // No id from the selection resolved to an op — mirror name-not-found + // semantics (exit code 0, informational). + return { status: 'name-not-found', name: ids[0] ?? '' }; + } + + // Group by originating manifest (reference equality on ManifestEntry). + const byEntry = new Map< + ManifestEntry, + { entry: ManifestEntry; ops: ManifestOp[]; canonIds: Map } + >(); + for (const { entry, op, canonical_id } of selected) { + let bucket = byEntry.get(entry); + if (bucket === undefined) { + bucket = { entry, ops: [], canonIds: new Map() }; + byEntry.set(entry, bucket); + } + bucket.ops.push(op); + if (op.op_type === 'archive') { + bucket.canonIds.set(op.archive_path, canonical_id); + } + } + + // Aggregate across all per-manifest dispatches. + const agg: RestoreCounts = { + unarchived: { moved: 0, alreadyAtSource: 0, failed: 0 }, + reenabled: { completed: 0, failed: 0 }, + stripped: { completed: 0, failed: 0 }, + }; + const aggSkipped: Array<{ + reason: 'source_exists'; + path: string; + canonical_id: string; + }> = []; + const aggManifestPaths: string[] = []; + let totalFailed = 0; + let firstManifestPath: string | null = null; + + for (const bucket of byEntry.values()) { + const perResult = await executeOpsOnManifest( + bucket.entry, + bucket.ops, + deps, + start, + [bucket.entry.path], + bucket.canonIds, + ); + // Propagate hard-failure variants from MCP re-enable path unchanged. + if ( + perResult.status === 'config-parse-error' || + perResult.status === 'config-write-error' || + perResult.status === 'manifest-corrupt' + ) { + return perResult; + } + if (perResult.status !== 'success' && perResult.status !== 'partial-success') { + // Unexpected short-circuit variant — shouldn't happen for op execution, + // but surface it rather than silently swallowing. + return perResult; + } + agg.unarchived.moved += perResult.counts.unarchived.moved; + agg.unarchived.alreadyAtSource += perResult.counts.unarchived.alreadyAtSource; + agg.unarchived.failed += perResult.counts.unarchived.failed; + agg.reenabled.completed += perResult.counts.reenabled.completed; + agg.reenabled.failed += perResult.counts.reenabled.failed; + agg.stripped.completed += perResult.counts.stripped.completed; + agg.stripped.failed += perResult.counts.stripped.failed; + aggSkipped.push(...perResult.skipped); + aggManifestPaths.push(...perResult.manifestPaths); + if (perResult.status === 'partial-success') { + totalFailed += perResult.failed; + } + if (firstManifestPath === null) firstManifestPath = perResult.manifestPath; + } + + const duration_ms = Date.now() - start; + const manifestPath = firstManifestPath ?? ''; + const selectionFilter = { mode: 'subset' as const, ids: resolvedSelectedIds }; + if (totalFailed === 0) { + return { + status: 'success', + counts: agg, + manifestPath, + manifestPaths: aggManifestPaths, + duration_ms, + selectionFilter, + skipped: aggSkipped, + filteredStaleCount: interactiveFilteredStale, + }; + } + return { + status: 'partial-success', + counts: agg, + failed: totalFailed, + manifestPath, + manifestPaths: aggManifestPaths, + duration_ms, + selectionFilter, + skipped: aggSkipped, + filteredStaleCount: interactiveFilteredStale, }; } @@ -709,9 +1183,11 @@ export async function executeRestore(mode: RestoreMode, deps: RestoreDeps): Prom const allEntries = await findManifestsForRestore(deps); if (allEntries.length === 0) return { status: 'no-manifests' }; - // Validate the newest manifest's header (D-07). If the newest is corrupt, - // refuse entirely rather than silently falling back to an older one. - const newestEntry = allEntries[0]!; + // Validate the newest NON-PURGE manifest's header (D-07). `purge-*` + // manifests are follow-up audit records used only for archive_purge + // suppression; they must not become the full-restore integrity anchor. + const newestEntry = allEntries.find((entry) => !path.basename(entry.path).startsWith('purge-')); + if (newestEntry === undefined) return { status: 'no-manifests' }; const newestManifest = await deps.readManifest(newestEntry.path); if (newestManifest.header === null) { return { status: 'manifest-corrupt', path: newestEntry.path }; @@ -723,14 +1199,61 @@ export async function executeRestore(mode: RestoreMode, deps: RestoreDeps): Prom } // Collect ops from ALL manifests newest-first, deduplicating by archive_path. - // Ops from older manifests are silently skipped if the same archive_path was - // already seen in a newer manifest. + // Phase 8.2: also suppress stale archive ops (archive_missing + + // source_exists) — surfaced via `filtered_stale_count` (SC6). + // RE-M9: also suppress archive ops whose op_id was referenced by an + // archive_purge op in a purge manifest — mirrors dedupManifestOps() and + // collectRestoreableItems(). Two-pass approach: + // Pass 1 — read all manifests, build a cached map + purgedOriginalOpIds set. + // Pass 2 — iterate the cache, skip purge manifests and purged archive ops. + const manifestCache = new Map< + string, + { manifest: Awaited>; isPurge: boolean } + >(); + for (const entry of allEntries) { + const isPurge = path.basename(entry.path).startsWith('purge-'); + let manifest: Awaited>; + if (entry.path === newestEntry.path) { + manifest = newestManifest; + } else { + try { + manifest = await deps.readManifest(entry.path); + } catch (err) { + deps.onWarning?.( + `⚠️ Skipping unreadable manifest ${path.basename(entry.path)}: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + continue; + } + } + manifestCache.set(entry.path, { manifest, isPurge }); + } + + // Build purged-op-id set from archive_purge ops across all manifests. + const purgedOriginalOpIds = new Set(); + for (const { manifest, isPurge } of manifestCache.values()) { + if (!isPurge) continue; // archive_purge ops only live in purge manifests + for (const op of manifest.ops) { + if (op.op_type === 'archive_purge') { + purgedOriginalOpIds.add(op.original_op_id); + } + } + } + const seenArchivePaths = new Set(); const collectedOps: ManifestOp[] = []; + let filteredStaleCount = 0; for (const entry of allEntries) { - // Re-use the already-read newest manifest; read others fresh. - const manifest = entry === newestEntry ? newestManifest : await deps.readManifest(entry.path); + const cached = manifestCache.get(entry.path); + if (cached === undefined) continue; + + // RE-M9: skip purge manifests entirely — their archive_purge records are + // not restoreable ops; we already harvested their original_op_ids above. + if (cached.isPurge) continue; + + const { manifest } = cached; // Skip corrupt manifests in the middle of the list — only the newest // triggers a hard failure (validated above). @@ -743,11 +1266,14 @@ export async function executeRestore(mode: RestoreMode, deps: RestoreDeps): Prom for (const op of manifest.ops) { if (op.op_type === 'archive') { - if (seenArchivePaths.has(op.archive_path)) { - // Duplicate archive_path across manifests — newer already recorded, skip. + // RE-M9: skip archive ops whose archive has since been purged. + if (purgedOriginalOpIds.has(op.op_id)) continue; + if (seenArchivePaths.has(op.archive_path)) continue; + seenArchivePaths.add(op.archive_path); + if (await isStaleArchiveOp(op, deps.pathExists)) { + filteredStaleCount += 1; continue; } - seenArchivePaths.add(op.archive_path); collectedOps.push(op); } else { // Non-archive ops (disable, flag, refresh): include from all manifests. @@ -768,6 +1294,50 @@ export async function executeRestore(mode: RestoreMode, deps: RestoreDeps): Prom deps, start, allEntries.map((e) => e.path), + undefined, + filteredStaleCount, + ); + } + + // Subset restore via interactive picker output (D8-13). + if (mode.kind === 'interactive') { + return executeInteractiveOps(mode.ids, deps, start); + } + + // Subset restore via fuzzy pattern: match every candidate, restore each. + // Ambiguity is NOT a special case here (all-matching restores every match + // by design; see D8-09 for --name ambiguity which is enforced CLI-side). + if (mode.kind === 'all-matching') { + const allEntries = await findManifestsForRestore(deps); + if (allEntries.length === 0) return { status: 'no-manifests' }; + const zipped: Array<{ entry: ManifestEntry; ops: readonly ManifestOp[] }> = []; + for (const e of allEntries) { + let m: Awaited>; + try { + m = await deps.readManifest(e.path); + } catch (err) { + deps.onWarning?.( + `⚠️ Skipping unreadable manifest ${path.basename(e.path)}: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + continue; + } + if (m.header === null) { + deps.onWarning?.( + `⚠️ Skipping corrupt manifest ${path.basename(e.path)} (no header record)`, + ); + continue; + } + zipped.push({ entry: e, ops: m.ops }); + } + const deduped = dedupManifestOps(zipped); + const matched = matchByName(deduped, mode.pattern); + if (matched.length === 0) return { status: 'name-not-found', name: mode.pattern }; + return executeInteractiveOps( + matched.map((m) => m.canonical_id), + deps, + start, ); } @@ -1110,9 +1680,9 @@ if (import.meta.vitest) { expect(result.status).toBe('success'); }); - it('Test 7c: corrupt newest manifest hard-fails even when older manifests are valid (regression)', async () => { - // Counterpart regression: a corrupt *newest* manifest must produce - // manifest-corrupt, not silently fall back to older ones. + it('Test 7c: corrupt newest non-purge manifest hard-fails even when older manifests are valid (regression)', async () => { + // Counterpart regression: a corrupt *newest non-purge* manifest must + // produce manifest-corrupt, not silently fall back to older ones. const newerEntry: ManifestEntry = { path: '/fake/.claude/ccaudit/manifests/bust-2026-04-10T10-00-00Z.jsonl', mtime: new Date('2026-04-10T10:00:00Z'), @@ -1141,12 +1711,49 @@ if (import.meta.vitest) { const result = await executeRestore({ kind: 'full' }, deps); - // Hard failure on corrupt newest -- must not silently continue. + // Hard failure on corrupt newest non-purge -- must not silently continue. expect(result.status).toBe('manifest-corrupt'); if (result.status === 'manifest-corrupt') { expect(result.path).toBe(newerEntry.path); } }); + + it('Test 7d: newest purge manifest is not used as the full-restore integrity anchor', async () => { + const purgeEntry: ManifestEntry = { + path: '/fake/.claude/ccaudit/manifests/purge-2026-04-11T10-00-00Z.jsonl', + mtime: new Date('2026-04-11T10:00:00Z'), + }; + const bustEntry: ManifestEntry = { + path: '/fake/.claude/ccaudit/manifests/bust-2026-04-10T10-00-00Z.jsonl', + mtime: new Date('2026-04-10T10:00:00Z'), + }; + const warnings: string[] = []; + const deps = makeFakeDeps({ + discoverManifests: async () => [purgeEntry, bustEntry], + readManifest: async (p) => { + if (p === purgeEntry.path) { + return { header: null, ops: [], footer: null, truncated: true }; + } + return { header: fakeHeader, ops: [], footer: fakeFooter, truncated: false }; + }, + processDetector: { + runCommand: async () => '', + getParentPid: async () => null, + platform: 'linux', + }, + onWarning: (msg) => { + warnings.push(msg); + }, + }); + + const result = await executeRestore({ kind: 'full' }, deps); + + expect(result.status).toBe('success'); + expect(warnings).toEqual([]); + if (result.status === 'success') { + expect(result.manifestPath).toBe(bustEntry.path); + } + }); }); describe('findManifestForName', () => { @@ -2418,11 +3025,691 @@ if (import.meta.vitest) { const result = await executeRestore({ kind: 'full' }, deps); expect(result.status).toBe('success'); if (result.status === 'success') { - // The already-at-source op must NOT inflate the moved counter + // The already-at-source op must NOT inflate the moved counter. + // Phase 8.2: such ops (archive_missing + source_exists) are now + // suppressed at collection time via isStaleArchiveOp — they no + // longer reach the executor so alreadyAtSource stays 0 and the + // suppression is reported via filteredStaleCount instead. expect(result.counts.unarchived.moved).toBe(0); - expect(result.counts.unarchived.alreadyAtSource).toBe(1); + expect(result.counts.unarchived.alreadyAtSource).toBe(0); expect(result.counts.unarchived.failed).toBe(0); + expect(result.filteredStaleCount).toBe(1); + } + }); + }); + + // -- dedupManifestOps (Phase 08-01) ----------------------------- + + describe('dedupManifestOps', () => { + const entryNew: ManifestEntry = { + path: '/m/bust-2026-04-10T00-00-00Z.jsonl', + mtime: new Date('2026-04-10T00:00:00Z'), + }; + const entryOld: ManifestEntry = { + path: '/m/bust-2026-04-01T00-00-00Z.jsonl', + mtime: new Date('2026-04-01T00:00:00Z'), + }; + + const archiveOp = ( + archive_path: string, + category: ArchiveOp['category'] = 'skill', + ): ArchiveOp => ({ + op_id: `op-${archive_path}`, + op_type: 'archive', + timestamp: '2026-04-10T00:00:00Z', + status: 'completed', + category, + scope: 'global', + source_path: archive_path.replace('/archived/', '/'), + archive_path, + content_sha256: 'sha256:abc', + }); + + const disableOp = (new_key: string, config_path = '/home/u/.claude.json'): DisableOp => ({ + op_id: `op-${new_key}`, + op_type: 'disable', + timestamp: '2026-04-10T00:00:00Z', + status: 'completed', + config_path, + scope: 'global', + project_path: null, + original_key: `mcpServers.${new_key.replace('ccaudit-disabled:', '')}`, + new_key, + original_value: { command: 'x' }, + }); + + const flagOp: FlagOp = { + op_id: 'op-flag', + op_type: 'flag', + timestamp: '2026-04-10T00:00:00Z', + status: 'completed', + file_path: '/home/u/CLAUDE.md', + scope: 'global', + had_frontmatter: false, + had_ccaudit_stale: false, + patched_keys: ['ccaudit-stale'], + original_content_sha256: 'sha256:x', + }; + + it('empty input returns empty output', () => { + expect(dedupManifestOps([])).toEqual([]); + }); + + it('single manifest with no duplicates passes through', () => { + const ops = [archiveOp('/a/skills/foo'), archiveOp('/a/skills/bar')]; + const out = dedupManifestOps([{ entry: entryNew, ops }]); + expect(out.map((o) => o.canonical_id)).toEqual([ + 'skill:/a/skills/foo', + 'skill:/a/skills/bar', + ]); + expect(out.every((o) => o.entry === entryNew)).toBe(true); + }); + + it('duplicate archive_path across manifests: newer wins (first-seen)', () => { + const op1 = archiveOp('/a/skills/foo'); + const op2 = archiveOp('/a/skills/foo'); + const out = dedupManifestOps([ + { entry: entryNew, ops: [op1] }, + { entry: entryOld, ops: [op2] }, + ]); + expect(out).toHaveLength(1); + expect(out[0].entry).toBe(entryNew); + expect(out[0].op).toBe(op1); + }); + + it('flag and refresh ops are filtered out', () => { + const out = dedupManifestOps([ + { entry: entryNew, ops: [flagOp, archiveOp('/a/skills/foo')] }, + ]); + expect(out).toHaveLength(1); + expect(out[0].canonical_id).toBe('skill:/a/skills/foo'); + }); + + it('mixed archive + disable dedup by distinct canonical_id', () => { + const a = archiveOp('/a/skills/foo'); + const d = disableOp('ccaudit-disabled:pencil'); + const out = dedupManifestOps([{ entry: entryNew, ops: [a, d] }]); + expect(out.map((o) => o.canonical_id).sort()).toEqual( + ['mcp:/home/u/.claude.json:ccaudit-disabled:pencil', 'skill:/a/skills/foo'].sort(), + ); + }); + + it('Phase 9 SC6: archive_purge follow-up suppresses its original ArchiveOp', () => { + const orig = archiveOp('/a/skills/foo'); + const purgeOp: ManifestOp = { + op_id: 'purge-1', + op_type: 'archive_purge', + timestamp: '2026-04-22T12:00:00Z', + status: 'completed', + original_op_id: orig.op_id, + purged: true, + reason: 'source_occupied', + }; + // Purge manifest is newer; bust manifest is older. Both present to dedup. + const entryPurge: ManifestEntry = { + path: '/m/purge-2026-04-22T12-00-00Z.jsonl', + mtime: new Date('2026-04-22T12:00:00Z'), + }; + const out = dedupManifestOps([ + { entry: entryPurge, ops: [purgeOp] }, + { entry: entryNew, ops: [orig] }, + ]); + expect(out).toHaveLength(0); + }); + + it('archive_purge only suppresses the referenced op, not siblings', () => { + const a = archiveOp('/a/skills/foo'); + const b = archiveOp('/a/skills/bar'); + const purgeOp: ManifestOp = { + op_id: 'purge-1', + op_type: 'archive_purge', + timestamp: '2026-04-22T12:00:00Z', + status: 'completed', + original_op_id: a.op_id, + purged: true, + reason: 'reclaimed', + }; + const entryPurge: ManifestEntry = { + path: '/m/purge-2026-04-22T12-00-00Z.jsonl', + mtime: new Date('2026-04-22T12:00:00Z'), + }; + const out = dedupManifestOps([ + { entry: entryPurge, ops: [purgeOp] }, + { entry: entryNew, ops: [a, b] }, + ]); + expect(out).toHaveLength(1); + expect(out[0]!.canonical_id).toBe('skill:/a/skills/bar'); + }); + }); + + // -- collectRestoreableItems (Phase 8.1 — D81-01 C1a) ----------- + + describe('collectRestoreableItems', () => { + const entryNew: ManifestEntry = { + path: '/m/bust-2026-04-10T00-00-00Z.jsonl', + mtime: new Date('2026-04-10T00:00:00Z'), + }; + const entryOld: ManifestEntry = { + path: '/m/bust-2026-04-01T00-00-00Z.jsonl', + mtime: new Date('2026-04-01T00:00:00Z'), + }; + + const archiveOp = (archive_path: string): ArchiveOp => ({ + op_id: `op-${archive_path}`, + op_type: 'archive', + timestamp: '2026-04-10T00:00:00Z', + status: 'completed', + category: 'skill', + scope: 'global', + source_path: archive_path.replace('/archived/', '/'), + archive_path, + content_sha256: 'sha256:abc', + }); + + const disableOp = (new_key: string): DisableOp => ({ + op_id: `op-${new_key}`, + op_type: 'disable', + timestamp: '2026-04-10T00:00:00Z', + status: 'completed', + config_path: '/home/u/.claude.json', + scope: 'global', + project_path: null, + original_key: `mcpServers.${new_key.replace('ccaudit-disabled:', '')}`, + new_key, + original_value: { command: 'x' }, + }); + + const mkFlagOp = (file_path: string, timestamp = '2026-04-10T00:00:00Z'): FlagOp => ({ + op_id: `op-flag-${file_path}`, + op_type: 'flag', + timestamp, + status: 'completed', + file_path, + scope: 'global', + had_frontmatter: false, + had_ccaudit_stale: false, + patched_keys: ['ccaudit-stale'], + original_content_sha256: 'sha256:x', + }); + + const mkRefreshOp = (file_path: string): RefreshOp => ({ + op_id: `op-refresh-${file_path}`, + op_type: 'refresh', + timestamp: '2026-04-10T00:00:00Z', + status: 'completed', + file_path, + scope: 'global', + previous_flagged_at: '2026-04-01T00:00:00Z', + }); + + it('empty input returns empty output', () => { + expect(collectRestoreableItems([])).toEqual([]); + }); + + it('flag op is preserved (not dropped)', () => { + const f = mkFlagOp('/home/u/CLAUDE.md'); + const out = collectRestoreableItems([{ entry: entryNew, ops: [f] }]); + expect(out).toHaveLength(1); + // M8: canonical_id includes op_type + op_id to keep distinct ops distinct + expect(out[0]!.canonical_id).toBe(`memory:flag:/home/u/CLAUDE.md:${f.op_id}`); + expect(out[0]!.op).toBe(f); + }); + + it('refresh op is preserved under its own memory: key (distinct from flag)', () => { + const r = mkRefreshOp('/home/u/rules/style.md'); + const out = collectRestoreableItems([{ entry: entryNew, ops: [r] }]); + expect(out).toHaveLength(1); + expect(out[0]!.canonical_id).toBe(`memory:refresh:/home/u/rules/style.md:${r.op_id}`); + }); + + it('M8: two flag ops on the same file_path get DISTINCT canonical_ids (INV-S3)', () => { + // Same file_path, different op_ids — must both be individually restoreable. + const f1 = mkFlagOp('/home/u/CLAUDE.md', '2026-04-10T00:00:00Z'); + const f2: FlagOp = { + ...mkFlagOp('/home/u/CLAUDE.md', '2026-04-01T00:00:00Z'), + op_id: 'op-flag-different-id', + }; + const out = collectRestoreableItems([ + { entry: entryNew, ops: [f1] }, + { entry: entryOld, ops: [f2] }, + ]); + // Both ops are individually restoreable — NO dedup collapse + expect(out).toHaveLength(2); + const ids = out.map((o) => o.canonical_id); + expect(ids[0]).toBe(`memory:flag:/home/u/CLAUDE.md:${f1.op_id}`); + expect(ids[1]).toBe(`memory:flag:/home/u/CLAUDE.md:${f2.op_id}`); + // Both canonical_ids are distinct + expect(new Set(ids).size).toBe(2); + }); + + it('M8: flag + refresh on same file_path are both individually restoreable', () => { + const f = mkFlagOp('/home/u/CLAUDE.md'); + const r: RefreshOp = { + op_id: 'op-refresh-different', + op_type: 'refresh', + timestamp: '2026-04-11T00:00:00Z', + status: 'completed', + file_path: '/home/u/CLAUDE.md', + scope: 'global', + previous_flagged_at: '2026-04-10T00:00:00Z', + }; + const out = collectRestoreableItems([{ entry: entryNew, ops: [f, r] }]); + expect(out).toHaveLength(2); + const ids = out.map((o) => o.canonical_id); + expect(ids).toContain(`memory:flag:/home/u/CLAUDE.md:${f.op_id}`); + expect(ids).toContain(`memory:refresh:/home/u/CLAUDE.md:${r.op_id}`); + }); + + it('mixed archive + disable + flag: all three returned with distinct keys', () => { + const a = archiveOp('/a/skills/foo'); + const d = disableOp('ccaudit-disabled:pencil'); + const f = mkFlagOp('/home/u/CLAUDE.md'); + const out = collectRestoreableItems([{ entry: entryNew, ops: [a, d, f] }]); + expect(out.map((o) => o.canonical_id).sort()).toEqual( + [ + `memory:flag:/home/u/CLAUDE.md:${f.op_id}`, + 'mcp:/home/u/.claude.json:ccaudit-disabled:pencil', + 'skill:/a/skills/foo', + ].sort(), + ); + }); + + it('skipped ops are omitted (not restoreable)', () => { + const skipped: ManifestOp = { + op_id: 'op-skipped', + op_type: 'skipped', + timestamp: '2026-04-10T00:00:00Z', + status: 'completed', + file_path: '/home/u/weird.md', + category: 'memory', + reason: 'unreadable', + }; + const out = collectRestoreableItems([{ entry: entryNew, ops: [skipped] }]); + expect(out).toEqual([]); + }); + }); + + // -- matchByName (Phase 08-01) ---------------------------------- + + describe('matchByName', () => { + const mkArchive = (archive_path: string): ArchiveOp => ({ + op_id: `op-${archive_path}`, + op_type: 'archive', + timestamp: '2026-04-10T00:00:00Z', + status: 'completed', + category: 'skill', + scope: 'global', + source_path: archive_path, + archive_path, + content_sha256: 'sha256:x', + }); + const mkDisable = (name: string): DisableOp => ({ + op_id: `op-${name}`, + op_type: 'disable', + timestamp: '2026-04-10T00:00:00Z', + status: 'completed', + config_path: '/home/u/.claude.json', + scope: 'global', + project_path: null, + original_key: `mcpServers.${name}`, + new_key: `ccaudit-disabled:${name}`, + original_value: {}, + }); + + const items = [ + { canonical_id: 'skill:/a/skills/pencil-dev', op: mkArchive('/a/skills/pencil-dev') }, + { canonical_id: 'skill:/a/skills/scanner', op: mkArchive('/a/skills/scanner') }, + { canonical_id: 'mcp:/home/u/.claude.json:ccaudit-disabled:pencil', op: mkDisable('pencil') }, + ]; + + it('0 matches returns empty array', () => { + expect(matchByName(items, 'nonexistent')).toEqual([]); + }); + + it('1 match returns the single item', () => { + const out = matchByName(items, 'scanner'); + expect(out).toHaveLength(1); + expect(out[0].canonical_id).toBe('skill:/a/skills/scanner'); + }); + + it('multiple matches returned sorted by canonical_id ASC', () => { + const out = matchByName(items, 'pencil'); + expect(out.map((i) => i.canonical_id)).toEqual([ + 'mcp:/home/u/.claude.json:ccaudit-disabled:pencil', + 'skill:/a/skills/pencil-dev', + ]); + }); + + it('case-insensitive: uppercase pattern matches lowercase name', () => { + const out = matchByName(items, 'PENCIL'); + expect(out.map((i) => i.canonical_id)).toContain('skill:/a/skills/pencil-dev'); + }); + + it('disable op matches by extracted server name', () => { + // mcpServers.pencil → 'pencil' via extractServerName + const out = matchByName([items[2]], 'pencil'); + expect(out).toHaveLength(1); + expect(out[0].op.op_type).toBe('disable'); + }); + + it('empty or whitespace-only pattern returns []', () => { + expect(matchByName(items, '')).toEqual([]); + expect(matchByName(items, ' ')).toEqual([]); + }); + }); + + // -- executeRestore (subset) — Phase 08-02 ---------------------- + // + // Covers { kind: 'interactive'; ids } and { kind: 'all-matching'; pattern } + // dispatch: group-by-manifest, skipped[] on source-exists, name-not-found + // when no pattern matches. + + describe('executeRestore (subset)', () => { + const entryA: ManifestEntry = { + path: '/fake/.claude/ccaudit/manifests/bust-2026-04-10T10-00-00Z.jsonl', + mtime: new Date('2026-04-10T10:00:00Z'), + }; + const entryB: ManifestEntry = { + path: '/fake/.claude/ccaudit/manifests/bust-2026-04-05T08-00-00Z.jsonl', + mtime: new Date('2026-04-05T08:00:00Z'), + }; + + const mkArchive = (source_path: string, archive_path: string): ArchiveOp => ({ + op_id: `op-${archive_path}`, + op_type: 'archive', + timestamp: '2026-04-10T00:00:00Z', + status: 'completed', + category: 'skill', + scope: 'global', + source_path, + archive_path, + content_sha256: 'sha256:x', + }); + + // Paths must be within homedir() to satisfy assertWithinHomedir. + const home = homedir(); + const opA1 = mkArchive( + path.join(home, '.claude/skills/pencil-dev.md'), + path.join(home, '.claude/skills/_archived/pencil-dev.md'), + ); + const opB1 = mkArchive( + path.join(home, '.claude/skills/scanner.md'), + path.join(home, '.claude/skills/_archived/scanner.md'), + ); + + const cidA1 = `skill:${opA1.archive_path}`; + const cidB1 = `skill:${opB1.archive_path}`; + + const mkFlag = (file_path: string): FlagOp => ({ + op_id: `op-flag-${file_path}`, + op_type: 'flag', + timestamp: '2026-04-10T00:00:00Z', + status: 'completed', + file_path, + scope: 'global', + had_frontmatter: true, + had_ccaudit_stale: true, + patched_keys: ['ccaudit-stale', 'ccaudit-flagged'], + original_content_sha256: 'sha256:flag', + }); + + function subsetDeps(overrides: Partial = {}): RestoreDeps { + return makeFakeDeps({ + discoverManifests: async () => [entryA, entryB], + readManifest: async (p) => { + if (p === entryA.path) { + return { header: fakeHeader, ops: [opA1], footer: fakeFooter, truncated: false }; + } + return { header: fakeHeader, ops: [opB1], footer: fakeFooter, truncated: false }; + }, + processDetector: { + runCommand: async () => '', + getParentPid: async () => null, + platform: 'linux', + }, + // Default: source does NOT exist → every restore succeeds as a no-op rename. + pathExists: async () => false, + renameFile: async () => {}, + mkdirRecursive: async () => {}, + readFileBytes: async () => Buffer.from(''), + ...overrides, + }); + } + + it('{ kind: interactive } with ids from two manifests visits both', async () => { + const deps = subsetDeps(); + const result = await executeRestore({ kind: 'interactive', ids: [cidA1, cidB1] }, deps); + expect(result.status).toBe('success'); + if (result.status === 'success') { + expect(result.counts.unarchived.moved).toBe(2); + expect(result.manifestPaths).toEqual(expect.arrayContaining([entryA.path, entryB.path])); + expect(result.selectionFilter).toEqual({ mode: 'subset', ids: [cidA1, cidB1] }); + expect(result.skipped).toEqual([]); + } + }); + + it('interactive subset executes a selected memory flag op', async () => { + const flagOp = mkFlag(path.join(home, '.claude/CLAUDE.md')); + // M8: canonical_id now includes op_type + op_id for per-op uniqueness (INV-S3) + const flagId = `memory:flag:${flagOp.file_path}:${flagOp.op_id}`; + const deps = subsetDeps({ + readManifest: async (p) => { + if (p === entryA.path) { + return { header: fakeHeader, ops: [flagOp], footer: fakeFooter, truncated: false }; + } + return { header: fakeHeader, ops: [], footer: fakeFooter, truncated: false }; + }, + }); + + const result = await executeRestore({ kind: 'interactive', ids: [flagId] }, deps); + expect(result.status).toBe('success'); + if (result.status === 'success') { + expect(result.counts.unarchived.moved).toBe(0); + expect(result.counts.stripped.completed).toBe(1); + expect(result.selectionFilter).toEqual({ mode: 'subset', ids: [flagId] }); } }); + + it('interactive subset executes archive + memory ops and drops unresolved ids from selectionFilter', async () => { + const flagOp = mkFlag(path.join(home, '.claude/CLAUDE.md')); + // M8: canonical_id now includes op_type + op_id for per-op uniqueness (INV-S3) + const flagId = `memory:flag:${flagOp.file_path}:${flagOp.op_id}`; + const deps = subsetDeps({ + readManifest: async (p) => { + if (p === entryA.path) { + return { + header: fakeHeader, + ops: [opA1, flagOp], + footer: fakeFooter, + truncated: false, + }; + } + return { header: fakeHeader, ops: [opB1], footer: fakeFooter, truncated: false }; + }, + }); + + const result = await executeRestore( + { kind: 'interactive', ids: [flagId, 'memory:flag:/does/not/exist:op-gone', cidA1] }, + deps, + ); + expect(result.status).toBe('success'); + if (result.status === 'success') { + expect(result.counts.unarchived.moved).toBe(1); + expect(result.counts.stripped.completed).toBe(1); + expect(result.selectionFilter).toEqual({ mode: 'subset', ids: [flagId, cidA1] }); + } + }); + + it('{ kind: all-matching } restores every matching candidate', async () => { + const deps = subsetDeps(); + // Pattern "n" matches both 'pencil-dev' and 'scanner' + const result = await executeRestore({ kind: 'all-matching', pattern: 'n' }, deps); + expect(result.status).toBe('success'); + if (result.status === 'success') { + expect(result.counts.unarchived.moved).toBe(2); + expect(result.selectionFilter?.mode).toBe('subset'); + expect(result.selectionFilter?.ids.length).toBe(2); + } + }); + + it('{ kind: all-matching } with 0 matches returns name-not-found', async () => { + const deps = subsetDeps(); + const result = await executeRestore( + { kind: 'all-matching', pattern: 'nothing-matches-xyz' }, + deps, + ); + expect(result.status).toBe('name-not-found'); + if (result.status === 'name-not-found') { + expect(result.name).toBe('nothing-matches-xyz'); + } + }); + + it('source-exists → skipped[] populated and item excluded from unarchived.moved', async () => { + // Simulate source_path for opA1 already existing AND archive_path also + // present on disk (e.g. user manually copied source back without + // restoring) → restoreArchiveOp classifies as already-at-source and + // records skipped[]. Archive_path presence keeps the op out of the + // Phase 8.2 stale-filter (which requires archive_missing + + // source_exists). opB1 proceeds as a normal move. + const deps = subsetDeps({ + pathExists: async (p) => p === opA1.source_path || p === opA1.archive_path, + }); + const result = await executeRestore({ kind: 'interactive', ids: [cidA1, cidB1] }, deps); + expect(result.status).toBe('success'); + if (result.status === 'success') { + expect(result.counts.unarchived.moved).toBe(1); // opB1 only + expect(result.counts.unarchived.alreadyAtSource).toBe(1); // opA1 + expect(result.skipped).toEqual([ + { reason: 'source_exists', path: opA1.source_path, canonical_id: cidA1 }, + ]); + } + }); + + it('{ kind: interactive } with ids matching nothing returns name-not-found', async () => { + const deps = subsetDeps(); + const result = await executeRestore( + { kind: 'interactive', ids: ['skill:/does/not/exist'] }, + deps, + ); + expect(result.status).toBe('name-not-found'); + }); + + it('zero manifests on disk returns no-manifests for subset modes', async () => { + const deps = makeFakeDeps({ + discoverManifests: async () => [], + processDetector: { + runCommand: async () => '', + getParentPid: async () => null, + platform: 'linux', + }, + }); + const r1 = await executeRestore({ kind: 'interactive', ids: [cidA1] }, deps); + expect(r1.status).toBe('no-manifests'); + const r2 = await executeRestore({ kind: 'all-matching', pattern: 'pencil' }, deps); + expect(r2.status).toBe('no-manifests'); + }); + }); + + // -- Phase 8.2: stale-archive listing hygiene ----------------------- + + describe('isStaleArchiveOp + filterRestoreableItems (Phase 8.2)', () => { + const mkArchive = (archive_path: string, source_path: string): ArchiveOp => ({ + op_id: `op-${archive_path}`, + op_type: 'archive', + timestamp: '2026-04-22T00:00:00.000Z', + status: 'completed', + category: 'agent', + scope: 'global', + source_path, + archive_path, + content_sha256: 'sha256:stub', + }); + + // Build a pathExists fake from a set of paths that "exist" on disk. + const fakeExists = (existing: Set) => async (p: string) => existing.has(p); + + const fakeEntryS: ManifestEntry = { + path: '/fake/.claude/ccaudit/manifests/bust-stale.jsonl', + mtime: new Date('2026-04-22T00:00:00Z'), + }; + + it('T1: archive_missing + source_exists → isStale=true (D-01 / SC1)', async () => { + const op = mkArchive('/h/.claude/ccaudit/archived/agents/a.md', '/h/.claude/agents/a.md'); + const exists = fakeExists(new Set(['/h/.claude/agents/a.md'])); // source back, archive gone + expect(await isStaleArchiveOp(op, exists)).toBe(true); + }); + + it('T2: archive_exists (regardless of source) → isStale=false (kept; SC1 inverse)', async () => { + const op = mkArchive('/h/.claude/ccaudit/archived/agents/b.md', '/h/.claude/agents/b.md'); + const existsArchiveOnly = fakeExists(new Set([op.archive_path])); + expect(await isStaleArchiveOp(op, existsArchiveOnly)).toBe(false); + const existsBoth = fakeExists(new Set([op.archive_path, op.source_path])); + expect(await isStaleArchiveOp(op, existsBoth)).toBe(false); + }); + + it('T3: both_missing → isStale=false (kept; fail-loud preserved per D-02 / SC5)', async () => { + const op = mkArchive('/h/.claude/ccaudit/archived/agents/c.md', '/h/.claude/agents/c.md'); + const existsNone = fakeExists(new Set()); + expect(await isStaleArchiveOp(op, existsNone)).toBe(false); + }); + + it('T4: flag op and disable op pass through filterRestoreableItems regardless of fs state (D-03 / SC2)', async () => { + const flagOp: FlagOp = { + op_id: 'op-flag', + op_type: 'flag', + timestamp: '2026-04-22T00:00:00Z', + status: 'completed', + file_path: '/h/.claude/CLAUDE.md', + scope: 'global', + had_frontmatter: true, + had_ccaudit_stale: true, + patched_keys: ['ccaudit-flagged'], + original_content_sha256: 'sha256:flag', + }; + const disableOp: DisableOp = { + op_id: 'op-dis', + op_type: 'disable', + timestamp: '2026-04-22T00:00:00Z', + status: 'completed', + config_path: '/h/.claude.json', + scope: 'global', + project_path: null, + original_key: 'mcpServers.playwright', + new_key: 'mcpServers.ccaudit-disabled:playwright', + original_value: {}, + }; + // Include one stale archive op to prove the archive filter fires + // while flag/disable are untouched. + const staleArchive = mkArchive( + '/h/.claude/ccaudit/archived/agents/stale.md', + '/h/.claude/agents/stale.md', + ); + const exists = fakeExists(new Set([staleArchive.source_path])); + + const items = [ + { + entry: fakeEntryS, + op: flagOp as RestoreableOp, + canonical_id: `memory:${flagOp.file_path}`, + }, + { + entry: fakeEntryS, + op: disableOp as RestoreableOp, + canonical_id: `mcp:${disableOp.config_path}:${disableOp.new_key}`, + }, + { + entry: fakeEntryS, + op: staleArchive as RestoreableOp, + canonical_id: `agent:${staleArchive.archive_path}`, + }, + ]; + + const { kept, filteredStaleCount } = await filterRestoreableItems(items, exists); + + expect(filteredStaleCount).toBe(1); + expect(kept).toHaveLength(2); + const keptTypes = kept.map((k) => k.op.op_type).sort(); + expect(keptTypes).toEqual(['disable', 'flag']); + }); }); } diff --git a/packages/internal/src/remediation/savings.ts b/packages/internal/src/remediation/savings.ts index 4ffbc5e..6157cce 100644 --- a/packages/internal/src/remediation/savings.ts +++ b/packages/internal/src/remediation/savings.ts @@ -28,7 +28,7 @@ if (import.meta.vitest) { archive: [], disable: [], flag: [], - counts: { agents: 0, skills: 0, mcp: 0, memory: 0 }, + counts: { agents: 0, skills: 0, mcp: 0, memory: 0, commands: 0 }, savings: { tokens: 0 }, ...parts, }; diff --git a/packages/internal/src/scanner/_config-refs.ts b/packages/internal/src/scanner/_config-refs.ts new file mode 100644 index 0000000..0ea606a --- /dev/null +++ b/packages/internal/src/scanner/_config-refs.ts @@ -0,0 +1,191 @@ +/** + * Pure helpers for grouping MCP server occurrences across config files + * (Phase 6, D6-02 / D6-17 / D6-19) and for querying framework-as-unit + * protection on a canonical inventory item (D6-01). + * + * Deliberately free of `fs`, `os`, and any non-stdlib import: callers + * render `configPath` via `presentPath` BEFORE handing servers here so + * the bucketing rule is purely textual. + */ + +/** + * Minimal shape the grouping helper needs from a scanned MCP server. + * Additional fields on the actual scanner record are ignored (structural + * typing). + */ +export interface ScannedMcpServer { + /** The MCP server key (post canonical-ID normalization). */ + key: string; + /** Rendered config path (output of `presentPath`). */ + configPath: string; +} + +/** + * Group scanned MCP servers by key, producing the ordered list of config + * files that reference each key. + * + * Contract (D6-02): + * - Every key that appears in the input gets an entry in the returned Map. + * - `string[]` length is always >= 1 (no empty arrays, never undefined). + * - Duplicate (key, configPath) pairs dedupe via Set semantics. + * - Sort order per `compareConfigRef`: project-local → ~user → system. + * + * @param servers Scanned MCP servers (one per (key, config) occurrence). + * @returns Map from key to ordered, deduplicated configPath list. + */ +export function computeConfigRefs(servers: ReadonlyArray): Map { + // Collect dedup sets per key, preserving first-seen order within each bucket. + const sets = new Map>(); + for (const s of servers) { + const existing = sets.get(s.key); + if (existing) { + existing.add(s.configPath); + } else { + sets.set(s.key, new Set([s.configPath])); + } + } + + const out = new Map(); + for (const [key, set] of sets) { + // Array.prototype.sort is stable on Node 20+ → first-seen order is + // preserved within each bucket. + out.set(key, [...set].sort(compareConfigRef)); + } + return out; +} + +/** + * Stable sort comparator for rendered config paths (D6-19). + * + * Bucket 0 — project-local: does not start with `~/` and does not start with `/`. + * Bucket 1 — user-scope: starts with `~/`. + * Bucket 2 — system: starts with `/` (absolute non-home; home was + * already compressed to `~/` upstream via presentPath). + * + * Within a bucket, the comparator returns 0 so the caller's Array.prototype.sort + * (stable on Node 20+) preserves input order. + */ +export function compareConfigRef(a: string, b: string): number { + return bucketOf(a) - bucketOf(b); +} + +function bucketOf(p: string): number { + if (p.startsWith('~/')) return 1; + // POSIX absolute (`/etc/...`) or Windows drive-letter absolute (`C:/Users/...`) + // — the latter shape comes through after presentPath normalizes backslashes. + if (p.startsWith('/') || /^[a-zA-Z]:\//.test(p)) return 2; + return 0; +} + +/** + * Advisory predicate: true iff the item carries a Phase 6 `protection` + * object (D6-01). Server-side INV-S6 enforcement in `runBust` remains the + * actual gate — this helper is for the picker row render / toggle guard. + */ +export function isProtected(item: { protection?: unknown }): boolean { + return item.protection !== undefined; +} + +// ─────────────────────────── In-source tests ─────────────────────────── + +if (import.meta.vitest) { + const { describe, it, expect } = import.meta.vitest; + + describe('computeConfigRefs', () => { + it('yields [path] for a single-key single-ref input (no tri-state)', () => { + const result = computeConfigRefs([{ key: 'foo', configPath: '.mcp.json' }]); + expect(result.get('foo')).toEqual(['.mcp.json']); + expect(result.size).toBe(1); + }); + + it('orders project-local before user-scope for the same key', () => { + const result = computeConfigRefs([ + { key: 'foo', configPath: '~/.claude/settings.json' }, + { key: 'foo', configPath: '.mcp.json' }, + ]); + expect(result.get('foo')).toEqual(['.mcp.json', '~/.claude/settings.json']); + }); + + it('isolates keys from each other', () => { + const result = computeConfigRefs([ + { key: 'foo', configPath: '.mcp.json' }, + { key: 'foo', configPath: '~/.claude/settings.json' }, + { key: 'bar', configPath: '.mcp.json' }, + ]); + expect(result.get('foo')).toEqual(['.mcp.json', '~/.claude/settings.json']); + expect(result.get('bar')).toEqual(['.mcp.json']); + }); + + it('deduplicates repeated (key, configPath) pairs', () => { + const result = computeConfigRefs([ + { key: 'foo', configPath: '.mcp.json' }, + { key: 'foo', configPath: '.mcp.json' }, + { key: 'foo', configPath: '~/.claude/settings.json' }, + ]); + expect(result.get('foo')).toEqual(['.mcp.json', '~/.claude/settings.json']); + }); + + it('orders all three buckets: project-local → ~user → system', () => { + const result = computeConfigRefs([ + { key: 'foo', configPath: '/etc/global-mcp.json' }, + { key: 'foo', configPath: '~/.claude/settings.json' }, + { key: 'foo', configPath: '.mcp.json' }, + ]); + expect(result.get('foo')).toEqual([ + '.mcp.json', + '~/.claude/settings.json', + '/etc/global-mcp.json', + ]); + }); + + it('preserves first-seen order within a bucket (stable sort)', () => { + const result = computeConfigRefs([ + { key: 'foo', configPath: 'apps/a/.mcp.json' }, + { key: 'foo', configPath: '.mcp.json' }, + { key: 'foo', configPath: 'apps/b/.mcp.json' }, + ]); + expect(result.get('foo')).toEqual(['apps/a/.mcp.json', '.mcp.json', 'apps/b/.mcp.json']); + }); + + it('returns an empty Map for empty input', () => { + expect(computeConfigRefs([])).toEqual(new Map()); + }); + }); + + describe('compareConfigRef', () => { + it('ranks project-local < user-scope < system', () => { + expect(compareConfigRef('.mcp.json', '~/x.json')).toBeLessThan(0); + expect(compareConfigRef('~/x.json', '/etc/x.json')).toBeLessThan(0); + expect(compareConfigRef('.mcp.json', '/etc/x.json')).toBeLessThan(0); + }); + + it('treats Windows drive-letter paths as system (bucket 2)', () => { + // After presentPath normalizes backslashes, Windows absolute paths + // look like `C:/Users/...`. They must sort AFTER project-local refs. + expect(compareConfigRef('.mcp.json', 'C:/Users/foo/claude.json')).toBeLessThan(0); + expect(compareConfigRef('~/x.json', 'C:/Users/foo/claude.json')).toBeLessThan(0); + expect(compareConfigRef('D:/projects/.mcp.json', '.mcp.json')).toBeGreaterThan(0); + }); + + it('returns 0 within a bucket (stable sort via Array.sort)', () => { + expect(compareConfigRef('a.json', 'b.json')).toBe(0); + expect(compareConfigRef('~/a.json', '~/b.json')).toBe(0); + expect(compareConfigRef('/etc/a.json', '/etc/b.json')).toBe(0); + }); + }); + + describe('isProtected', () => { + it('returns true when protection is present', () => { + expect( + isProtected({ + protection: { framework: 'gsd', total: 5, ghostCount: 2, reason: 'x' }, + }), + ).toBe(true); + }); + + it('returns false when protection is undefined', () => { + expect(isProtected({})).toBe(false); + expect(isProtected({ protection: undefined })).toBe(false); + }); + }); +} diff --git a/packages/internal/src/scanner/_present-path.ts b/packages/internal/src/scanner/_present-path.ts new file mode 100644 index 0000000..9eadfa0 --- /dev/null +++ b/packages/internal/src/scanner/_present-path.ts @@ -0,0 +1,105 @@ +/** + * Pure path presentation helper (Phase 6, D6-18). + * + * Converts an absolute path into a user-friendly form for display in the + * TUI and in the manifest JSON envelope: + * + * 1. If `projectRoot` is provided AND `absPath` lives under it, return the + * project-relative remainder (project-root precedence over home). + * 2. Else if `absPath` lives under `homeDir`, return `~/`. + * 3. Else return the forward-slash-normalized input (non-home absolute, + * already relative, or the `/` root edge). + * + * The function is pure: no `fs`, no `os`. Callers must pass `homeDir` and + * (optionally) `projectRoot` explicitly — this keeps `presentPath` snapshot- + * testable without mocking `os.homedir()`. + * + * Forward slashes only (tinyglobby / CLAUDE.md convention). Windows + * backslashes are normalized before prefix matching and in the passthrough + * return value. + */ + +/** + * Render an absolute path for display. Pure function — see module doc. + * + * @param absPath Path to render. May be absolute, `~`-prefixed, or relative. + * @param homeDir Absolute path to the user's home directory (no trailing slash). + * @param projectRoot Optional absolute path to the current project root (no trailing slash). + * @returns `"~/..."`, `""`, or normalized `absPath`. + */ +export function presentPath(absPath: string, homeDir: string, projectRoot?: string): string { + // Normalize Windows backslashes to forward slashes before prefix-matching. + const abs = absPath.replace(/\\/g, '/'); + const home = homeDir.replace(/\\/g, '/'); + const proj = projectRoot ? projectRoot.replace(/\\/g, '/') : projectRoot; + + // Step 1 — project-root wins over home (D6-18). + if (proj && proj.length > 0 && abs.startsWith(proj + '/')) { + return abs.slice(proj.length + 1); + } + // Step 2 — home compression. + if (home && home.length > 0 && abs.startsWith(home + '/')) { + return '~/' + abs.slice(home.length + 1); + } + // Step 3 — passthrough (non-home absolute, already relative, `/` root). + return abs; +} + +// ─────────────────────────── In-source tests ─────────────────────────── + +if (import.meta.vitest) { + const { describe, it, expect } = import.meta.vitest; + + describe('presentPath', () => { + it('compresses $HOME to ~/ (D6-18)', () => { + expect(presentPath('/Users/foo/.claude/settings.json', '/Users/foo')).toBe( + '~/.claude/settings.json', + ); + }); + + it('project-root precedence over home', () => { + expect(presentPath('/Users/foo/proj/.mcp.json', '/Users/foo', '/Users/foo/proj')).toBe( + '.mcp.json', + ); + }); + + it('leaves non-home absolute paths unchanged', () => { + expect(presentPath('/etc/system.json', '/Users/foo')).toBe('/etc/system.json'); + }); + + it('is idempotent on already-relative paths', () => { + expect(presentPath('relative/already.json', '/Users/foo')).toBe('relative/already.json'); + }); + + it('handles the root edge "/" without mangling', () => { + expect(presentPath('/', '/Users/foo')).toBe('/'); + }); + + it('compresses home on Windows-style forward-slashed paths', () => { + expect(presentPath('C:/Users/foo/.claude/x.json', 'C:/Users/foo')).toBe('~/.claude/x.json'); + }); + + it('does NOT compress a path that only shares a prefix substring with home', () => { + // '/Users/foobar/...' must NOT be rewritten when home is '/Users/foo'. + expect(presentPath('/Users/foobar/x.json', '/Users/foo')).toBe('/Users/foobar/x.json'); + }); + + it('does NOT compress a path that only shares a prefix substring with projectRoot', () => { + expect(presentPath('/Users/foo/project-b/x.json', '/Users/foo', '/Users/foo/project-a')).toBe( + '~/project-b/x.json', + ); + }); + + it('compresses project-root on Windows backslash inputs', () => { + expect( + presentPath('C:\\Users\\foo\\proj\\.mcp.json', 'C:\\Users\\foo', 'C:\\Users\\foo\\proj'), + ).toBe('.mcp.json'); + }); + + it('compresses home on Windows backslash inputs', () => { + expect(presentPath('C:\\Users\\foo\\.claude\\x.json', 'C:\\Users\\foo')).toBe( + '~/.claude/x.json', + ); + }); + }); +} diff --git a/packages/internal/src/scanner/annotate.ts b/packages/internal/src/scanner/annotate.ts index 23fb7cd..4f5bdc9 100644 --- a/packages/internal/src/scanner/annotate.ts +++ b/packages/internal/src/scanner/annotate.ts @@ -1,9 +1,10 @@ import type { Framework, DetectableItem } from '../framework/types.ts'; import type { TokenCostResult } from '../token/types.ts'; import type { GhostItem } from '../types.ts'; -import type { InventoryItem } from './types.ts'; +import type { InventoryItem, FrameworkProtection } from './types.ts'; import { detectFramework } from '../framework/detect.ts'; import { KNOWN_FRAMEWORKS } from '../framework/known-frameworks.ts'; +import { groupByFramework } from '../framework/group.ts'; /** * Annotates agent and skill items with their `framework` field using the @@ -98,8 +99,11 @@ export function annotateFrameworks( * @returns Newly constructed GhostItem array. Pure function. */ export function toGhostItems(results: TokenCostResult[]): GhostItem[] { - return results.map( - (r): GhostItem => ({ + // First pass: materialize plain GhostItems (no protection yet). Framework + // grouping needs the tiered items, so we compute protection in a second + // pass below. + const base: GhostItem[] = results.map((r): GhostItem => { + const item: GhostItem = { name: r.item.name, path: r.item.path, scope: r.item.scope, @@ -111,8 +115,50 @@ export function toGhostItems(results: TokenCostResult[]): GhostItem[] { r.lastUsed === null ? null : Math.floor((Date.now() - r.lastUsed.getTime()) / 86_400_000), framework: r.item.framework ?? null, tokenEstimate: r.tokenEstimate, - }), - ); + }; + // Phase 6 (D6-02): propagate configRefs from the InventoryItem to the + // GhostItem so downstream TUI layers can read it directly. Only set + // when present — keeps JSON output byte-identical for non-MCP items. + if (r.item.configRefs !== undefined) { + item.configRefs = r.item.configRefs; + } + return item; + }); + + // Second pass (Phase 6 / D6-01): compute framework-as-unit protection. + // A member of a `partially-used` framework group (has BOTH used and + // ghost members) carries `protection` — its ghost subset is what INV-S6 + // would block under a partial bust. `fully-used` / `ghost-all` groups + // do NOT get the annotation because partial split is not possible (no + // ghost members) or already implied (no used members to protect). + // + // Rationale for applying to all members of a protected framework (not + // only ghosts): the picker needs to dim the whole framework row group + // when `--force-partial` is off; the toggle guard rejects any toggle on + // a protected item. Used members are effectively "locked in" too. + const grouped = groupByFramework(base); + const protectionByPath = new Map(); + for (const fw of grouped.frameworks) { + if (fw.status !== 'partially-used') continue; + const ghostCount = fw.totals.likelyGhost + fw.totals.definiteGhost; + const protection: FrameworkProtection = { + framework: fw.id, + total: fw.totals.defined, + ghostCount, + // Canonical reason string — the picker row (plan 02) reads this + // verbatim. Wording mirrors D6-05. + reason: `Part of ${fw.displayName} (${fw.totals.used} used, ${ghostCount} ghost). --force-partial to override.`, + }; + for (const m of fw.members) { + protectionByPath.set(m.path, protection); + } + } + + if (protectionByPath.size === 0) return base; + return base.map((g) => { + const p = protectionByPath.get(g.path); + return p === undefined ? g : { ...g, protection: p }; + }); } // ─────────────────────────── In-source tests ─────────────────────────── @@ -364,6 +410,123 @@ if (import.meta.vitest) { expect(toGhostItems([])).toEqual([]); }); + it('attaches protection to members of a partially-used framework (Phase 6, D6-01)', () => { + // gsd framework: 1 used + 1 ghost member → partially-used → protection. + const results: TokenCostResult[] = [ + makeResult('gsd-planner', 'used', { + item: { + name: 'gsd-planner', + path: '/mock/agent/gsd-planner', + scope: 'global', + category: 'agent', + projectPath: null, + framework: 'gsd', + }, + }), + makeResult('gsd-executor', 'definite-ghost', { + item: { + name: 'gsd-executor', + path: '/mock/agent/gsd-executor', + scope: 'global', + category: 'agent', + projectPath: null, + framework: 'gsd', + }, + }), + ]; + const ghostItems = toGhostItems(results); + expect(ghostItems).toHaveLength(2); + for (const g of ghostItems) { + expect(g.protection).toBeDefined(); + expect(g.protection?.framework).toBe('gsd'); + expect(g.protection?.total).toBe(2); + expect(g.protection?.ghostCount).toBe(1); + expect(g.protection?.reason).toBe( + 'Part of GSD (Get Shit Done) (1 used, 1 ghost). --force-partial to override.', + ); + } + }); + + it('does NOT attach protection when every member is ghost (ghost-all)', () => { + const results: TokenCostResult[] = [ + makeResult('gsd-a', 'definite-ghost', { + item: { + name: 'gsd-a', + path: '/mock/agent/gsd-a', + scope: 'global', + category: 'agent', + projectPath: null, + framework: 'gsd', + }, + }), + makeResult('gsd-b', 'definite-ghost', { + item: { + name: 'gsd-b', + path: '/mock/agent/gsd-b', + scope: 'global', + category: 'agent', + projectPath: null, + framework: 'gsd', + }, + }), + ]; + const ghostItems = toGhostItems(results); + for (const g of ghostItems) { + expect(g.protection).toBeUndefined(); + } + }); + + it('does NOT attach protection when every member is used (fully-used)', () => { + const used = new Date(); + const results: TokenCostResult[] = [ + makeResult('gsd-a', 'used', { + item: { + name: 'gsd-a', + path: '/mock/agent/gsd-a', + scope: 'global', + category: 'agent', + projectPath: null, + framework: 'gsd', + }, + lastUsed: used, + }), + makeResult('gsd-b', 'used', { + item: { + name: 'gsd-b', + path: '/mock/agent/gsd-b', + scope: 'global', + category: 'agent', + projectPath: null, + framework: 'gsd', + }, + lastUsed: used, + }), + ]; + const ghostItems = toGhostItems(results); + for (const g of ghostItems) { + expect(g.protection).toBeUndefined(); + } + }); + + it('propagates configRefs from InventoryItem onto the GhostItem (Phase 6, D6-02)', () => { + const r: TokenCostResult = { + item: { + name: 'foo', + path: '/mock/mcp/foo', + scope: 'global', + category: 'mcp-server', + projectPath: null, + configRefs: ['.mcp.json', '~/.claude.json'], + }, + tier: 'definite-ghost', + lastUsed: null, + invocationCount: 0, + tokenEstimate: { tokens: 100, confidence: 'estimated', source: 'default' }, + }; + const [g] = toGhostItems([r]); + expect(g?.configRefs).toEqual(['.mcp.json', '~/.claude.json']); + }); + it('drops projectPath silently (GhostItem has no such field)', () => { const r = makeResult('proj-agent', 'definite-ghost', { item: { diff --git a/packages/internal/src/scanner/index.ts b/packages/internal/src/scanner/index.ts index 3ca38b2..b240c37 100644 --- a/packages/internal/src/scanner/index.ts +++ b/packages/internal/src/scanner/index.ts @@ -1,5 +1,16 @@ // Scanner types -export type { InventoryItem, ScanResult, ScannerOptions, InvocationSummary } from './types.ts'; +export type { + InventoryItem, + ScanResult, + ScannerOptions, + InvocationSummary, + FrameworkProtection, +} from './types.ts'; + +// Phase 6 pure helpers (D6-02 / D6-18 / D6-19) +export { presentPath } from './_present-path.ts'; +export { computeConfigRefs, compareConfigRef, isProtected } from './_config-refs.ts'; +export type { ScannedMcpServer } from './_config-refs.ts'; // Classification export { classifyGhost, LIKELY_GHOST_MS, DEFINITE_GHOST_MS } from './classify.ts'; diff --git a/packages/internal/src/scanner/scan-hooks.ts b/packages/internal/src/scanner/scan-hooks.ts index 706eaee..97b7e8d 100644 --- a/packages/internal/src/scanner/scan-hooks.ts +++ b/packages/internal/src/scanner/scan-hooks.ts @@ -127,18 +127,32 @@ function extractHookItems( if (typeof leaf !== 'object' || leaf === null) continue; const leafObj = leaf as Record; - // Only handle 'command' type hooks; skip unknown types - if (leafObj['type'] !== 'command') continue; - - const command = leafObj['command']; - if (typeof command !== 'string') continue; - - // Privacy-critical: hash the command, never expose the raw string - const hash = shortHash(command); - - // Build item name: event:matcher-or-wildcard:shortHash + // Accept 'command' (legacy) and 'mcp_tool' (cc 2.1.118+) leaf types; + // future types are silently skipped (forward-compat — no log spam). + const leafType = leafObj['type']; + if (leafType !== 'command' && leafType !== 'mcp_tool') continue; + + // Privacy-critical: hash the identifier, never expose the raw string. + let payload: string; + if (leafType === 'command') { + const command = leafObj['command']; + if (typeof command !== 'string') continue; + payload = command; + } else { + const tool = leafObj['tool']; + if (typeof tool !== 'string') continue; + payload = tool; + } + const hash = shortHash(payload); + + // Build item name. Legacy command hooks keep the historical + // event:matcher:hash format. mcp_tool hooks add a 'tool:' segment + // to prevent collision with command hooks on the same event/matcher slot. const matcherPart = matcher ?? '*'; - const name = `${event}:${matcherPart}:${hash}`; + const name = + leafType === 'command' + ? `${event}:${matcherPart}:${hash}` + : `${event}:${matcherPart}:tool:${hash}`; items.push({ name, @@ -434,6 +448,118 @@ if (import.meta.vitest) { }); }); + describe('mcp_tool support (cc 2.1.118+)', () => { + let tmpDir: string; + + beforeEach(async () => { + tmpDir = await mkdtemp(path.join(tmpdir(), 'scan-hooks-mcptool-')); + }); + + afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }); + }); + + it('mcp_tool leaf produces an InventoryItem with non-zero token estimate', async () => { + const settingsPath = path.join(tmpDir, 'settings.json'); + const sensitiveTool = 'mcp__server__do_thing'; + await writeFile( + settingsPath, + JSON.stringify({ + hooks: { + PreToolUse: [ + { + matcher: 'Bash', + hooks: [{ type: 'mcp_tool', tool: sensitiveTool }], + }, + ], + }, + }), + ); + + const items = await extractHookItemsFromFile(settingsPath, 'global', null); + expect(items).toHaveLength(1); + expect(items[0].hookEvent).toBe('PreToolUse'); + expect(items[0].injectCapable).toBe(true); + expect(items[0].name).toMatch(/^PreToolUse:Bash:tool:[0-9a-f]{8}$/); + // Privacy invariant: raw tool identifier never leaks + expect(noCommandLeak(items, sensitiveTool)).toBe(true); + + // Token estimate flows through unchanged from existing hook estimator. + const { estimateHookTokens } = await import('../token/hook-estimator.ts'); + const est = estimateHookTokens(items[0].injectCapable!, 0); + expect(est.tokens).toBeGreaterThan(0); + }); + + it('mcp_tool name has type discriminator preventing collision with command on same event/matcher', async () => { + const settingsPath = path.join(tmpDir, 'settings.json'); + await writeFile( + settingsPath, + JSON.stringify({ + hooks: { + PreToolUse: [ + { + matcher: 'Bash', + hooks: [ + { type: 'command', command: 'echo hi' }, + { type: 'mcp_tool', tool: 'mcp__srv__act' }, + ], + }, + ], + }, + }), + ); + + const items = await extractHookItemsFromFile(settingsPath, 'global', null); + expect(items).toHaveLength(2); + const names = items.map((i) => i.name); + expect(new Set(names).size).toBe(2); + // Command hook keeps legacy format (no 'tool:' segment). + expect(names.some((n) => /^PreToolUse:Bash:[0-9a-f]{8}$/.test(n))).toBe(true); + // mcp_tool hook gets the discriminator. + expect(names.some((n) => /^PreToolUse:Bash:tool:[0-9a-f]{8}$/.test(n))).toBe(true); + }); + + it('mcp_tool ignores non-canonical tool_name and name identifier fields', async () => { + const settingsPath = path.join(tmpDir, 'settings.json'); + await writeFile( + settingsPath, + JSON.stringify({ + hooks: { + PostToolUse: [ + { hooks: [{ type: 'mcp_tool', tool_name: 'mcp__a__x' }] }, + { hooks: [{ type: 'mcp_tool', name: 'mcp__b__y' }] }, + { hooks: [{ type: 'mcp_tool', tool: 'mcp__canonical__z' }] }, + ], + }, + }), + ); + + const items = await extractHookItemsFromFile(settingsPath, 'global', null); + expect(items).toHaveLength(1); + expect(items[0].hookEvent).toBe('PostToolUse'); + expect(items[0].injectCapable).toBe(true); + expect(items[0].name).toMatch(/^PostToolUse:\*:tool:[0-9a-f]{8}$/); + expect(noCommandLeak(items, 'mcp__canonical__z')).toBe(true); + expect(noCommandLeak(items, 'mcp__a__x')).toBe(true); + expect(noCommandLeak(items, 'mcp__b__y')).toBe(true); + }); + + it('mcp_tool leaf without identifier is silently skipped', async () => { + const settingsPath = path.join(tmpDir, 'settings.json'); + await writeFile( + settingsPath, + JSON.stringify({ + hooks: { + PreToolUse: [{ hooks: [{ type: 'mcp_tool' }] }], + }, + }), + ); + + const items = await extractHookItemsFromFile(settingsPath, 'global', null); + expect(items).toEqual([]); + }); + }); + describe('scanHooks', () => { let tmpDir: string; diff --git a/packages/internal/src/scanner/scan-mcp.ts b/packages/internal/src/scanner/scan-mcp.ts index 2f348e3..69a24e5 100644 --- a/packages/internal/src/scanner/scan-mcp.ts +++ b/packages/internal/src/scanner/scan-mcp.ts @@ -2,6 +2,8 @@ import { readFile } from 'node:fs/promises'; import path from 'node:path'; import { homedir } from 'node:os'; import type { InventoryItem } from './types.ts'; +import { computeConfigRefs, compareConfigRef } from './_config-refs.ts'; +import { presentPath } from './_present-path.ts'; /** * Shape of ~/.claude.json config relevant to MCP server scanning. @@ -53,14 +55,23 @@ export async function scanMcpServers( projectPaths: string[], ): Promise { const config = await readClaudeConfig(claudeConfigPath); + const home = homedir(); const items: InventoryItem[] = []; const seen = new Set(); - const resolvedConfigPath = claudeConfigPath ?? path.join(homedir(), '.claude.json'); + const resolvedConfigPath = claudeConfigPath ?? path.join(home, '.claude.json'); - // 1. Global mcpServers (root level) + // Collect every (server key, rendered config path) occurrence across all + // discovered configs so computeConfigRefs can group by key regardless of + // project. Rendering happens here via presentPath so the grouping sees + // already-canonicalized paths (D6-17 / D6-18). + const occurrences: { key: string; configPath: string }[] = []; + + // 1. Global mcpServers (root level in ~/.claude.json) + const renderedGlobalConfig = presentPath(resolvedConfigPath, home); for (const serverName of Object.keys(config.mcpServers ?? {})) { const key = `global::${serverName}`; seen.add(key); + occurrences.push({ key: serverName, configPath: renderedGlobalConfig }); items.push({ name: serverName, path: resolvedConfigPath, @@ -70,10 +81,13 @@ export async function scanMcpServers( }); } - // 2. Per-project mcpServers from ~/.claude.json + // 2. Per-project mcpServers from ~/.claude.json (scope still rendered + // against $HOME since that's where the file lives — but the logical + // "project" that owns the entry is the key in config.projects). for (const [projPath, projConfig] of Object.entries(config.projects ?? {})) { for (const serverName of Object.keys(projConfig.mcpServers ?? {})) { const key = `${projPath}::${serverName}`; + occurrences.push({ key: serverName, configPath: renderedGlobalConfig }); if (!seen.has(key)) { seen.add(key); items.push({ @@ -87,14 +101,17 @@ export async function scanMcpServers( } } - // 3. .mcp.json files at project roots + // 3. .mcp.json files at project roots — rendered project-relative when + // the file lives under the project root (D6-18 project precedence). for (const projPath of projectPaths) { const mcpJsonPath = path.join(projPath, '.mcp.json'); try { const raw = await readFile(mcpJsonPath, 'utf-8'); const mcpConfig = JSON.parse(raw) as { mcpServers?: Record }; + const renderedMcpJsonPath = presentPath(mcpJsonPath, home, projPath); for (const serverName of Object.keys(mcpConfig.mcpServers ?? {})) { const key = `${projPath}::${serverName}`; + occurrences.push({ key: serverName, configPath: renderedMcpJsonPath }); if (!seen.has(key)) { seen.add(key); items.push({ @@ -111,7 +128,18 @@ export async function scanMcpServers( } } - return items; + // D6-02: group occurrences by server key, deduplicate, sort via + // compareConfigRef. Every emitted MCP item gets configRefs >= 1. + const refs = computeConfigRefs(occurrences); + return items.map((it) => { + const list = refs.get(it.name); + // Defensive: if an item was emitted we also pushed an occurrence for it, + // so list must exist. Guard against accidental future drift by falling + // back to a single-element list rendered from the item's own config path. + const configRefs = + list ?? [presentPath(it.path, home, it.projectPath ?? undefined)].sort(compareConfigRef); + return { ...it, configRefs }; + }); } if (import.meta.vitest) { @@ -321,4 +349,68 @@ if (import.meta.vitest) { expect(result[0].name).toBe('working-server'); }); }); + + describe('scanMcpServers — configRefs (Phase 6, D6-02)', () => { + let tmpDir: string; + + beforeEach(async () => { + tmpDir = await mkdtemp(path.join(tmpdir(), 'scan-mcp-refs-')); + }); + + afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }); + }); + + it('emits configRefs with length >= 1 for every MCP item (single-config case)', async () => { + const configPath = path.join(tmpDir, 'claude.json'); + await writeFile(configPath, JSON.stringify({ mcpServers: { context7: { type: 'http' } } })); + const result = await scanMcpServers(configPath, []); + expect(result).toHaveLength(1); + expect(result[0].configRefs).toBeDefined(); + expect(result[0].configRefs!.length).toBeGreaterThanOrEqual(1); + }); + + it('collects cross-config references for the same server key', async () => { + const configPath = path.join(tmpDir, 'claude.json'); + const projPath = path.join(tmpDir, 'proj'); + await mkdir(projPath, { recursive: true }); + // Same key 'shared' appears in global (root mcpServers) and in + // project .mcp.json — configRefs on BOTH emitted items must list both. + await writeFile(configPath, JSON.stringify({ mcpServers: { shared: { type: 'http' } } })); + await writeFile( + path.join(projPath, '.mcp.json'), + JSON.stringify({ mcpServers: { shared: { command: 'npx' } } }), + ); + const result = await scanMcpServers(configPath, [projPath]); + // Two items: one global-scope, one project-scope (different seen keys). + expect(result).toHaveLength(2); + for (const it of result) { + expect(it.name).toBe('shared'); + expect(it.configRefs).toBeDefined(); + expect(it.configRefs!.length).toBe(2); + // Project-local ('.mcp.json') must come before a `/`-absolute path + // per compareConfigRef bucket rule. + const refs = it.configRefs!; + expect(refs[0]).toBe('.mcp.json'); + // The second ref is the rendered global config path — may be + // absolute or ~-compressed depending on whether tmpDir is under + // $HOME. Either way it must NOT be bucket 0 (project-local). + expect(refs[1]!.startsWith('.mcp.json')).toBe(false); + } + }); + + it('renders a project-local .mcp.json as project-relative', async () => { + const configPath = path.join(tmpDir, 'claude.json'); + const projPath = path.join(tmpDir, 'p'); + await mkdir(projPath, { recursive: true }); + await writeFile(configPath, '{}'); + await writeFile( + path.join(projPath, '.mcp.json'), + JSON.stringify({ mcpServers: { only: { command: 'npx' } } }), + ); + const result = await scanMcpServers(configPath, [projPath]); + expect(result).toHaveLength(1); + expect(result[0].configRefs).toEqual(['.mcp.json']); + }); + }); } diff --git a/packages/internal/src/scanner/scan-memory.ts b/packages/internal/src/scanner/scan-memory.ts index b8d07cc..9b86fb9 100644 --- a/packages/internal/src/scanner/scan-memory.ts +++ b/packages/internal/src/scanner/scan-memory.ts @@ -85,7 +85,9 @@ async function scanImportChain( ): Promise { const imports = await resolveMarkdownImports(rootPath); return imports.map((imp) => { - const relPath = projRoot ? path.relative(projRoot, imp.path) : path.basename(imp.path); + const relPath = projRoot + ? path.relative(projRoot, imp.path).replace(/\\/g, '/') + : path.basename(imp.path); return { name: `${rootName} @ ${relPath}`, path: imp.path, diff --git a/packages/internal/src/scanner/types.ts b/packages/internal/src/scanner/types.ts index e216801..1ca6321 100644 --- a/packages/internal/src/scanner/types.ts +++ b/packages/internal/src/scanner/types.ts @@ -28,6 +28,39 @@ export interface InventoryItem { importDepth?: number; /** Root path from which @-imports were resolved */ importRoot?: string; + /** + * Phase 6 (D6-02 / D6-17): config files referencing this MCP server key. + * Populated by `scanMcpServers` for every `mcp-server` item (length >= 1). + * Absent for non-MCP categories. Paths rendered via `presentPath` and + * ordered via `compareConfigRef` (project-local → ~user → system). + */ + configRefs?: string[]; + /** + * Phase 6 (D6-01): framework-as-unit protection metadata. Propagated + * downstream onto `GhostItem.protection` by `toGhostItems` when the + * item belongs to a partially-used framework; additionally attached here + * on the underlying InventoryItem so picker paths that read + * `TokenCostResult.item` can consult `isProtected(item)` directly. + * Advisory only — server-side INV-S6 in `runBust` remains the gate. + */ + protection?: FrameworkProtection; +} + +/** + * Phase 6 (D6-01): framework-as-unit protection metadata attached to a + * canonical ghost item when its framework would trip INV-S6 under a + * partial bust. Advisory for the picker — server-side enforcement in + * `runBust` remains the actual gate. + */ +export interface FrameworkProtection { + /** Framework display id (e.g., "gsd", "superclaude"). */ + framework: string; + /** Total members of the framework (used + ghost). */ + total: number; + /** Ghost members (tier !== 'used'). */ + ghostCount: number; + /** Canonical reason string rendered by the picker verbatim. */ + reason: string; } /** diff --git a/packages/internal/src/token/format.ts b/packages/internal/src/token/format.ts index e326772..2b582c8 100644 --- a/packages/internal/src/token/format.ts +++ b/packages/internal/src/token/format.ts @@ -94,6 +94,64 @@ function formatTokensShort(tokens: number): string { return `~${tokens} tokens`; } +/** + * Format a token count for the live picker footer (D4-10). + * + * Rules: + * - n === 0 → '' (caller decides whether to render) + * - 0 < n < 1000 → `{n} tokens` (no approximation glyph) + * - n >= 1000 → `≈ {round(n/1000)}k tokens` + * + * The leading `≈ ` falls back to `~ ` when `opts.ascii === true` (D4-11 / + * shouldUseAscii). + * + * Rounding is `Math.round(n / 1000)` for ALL n >= 1000 (not the 1.5k "toFixed(1)" + * branch used by formatTokenEstimate / fmtK for 1000..9999). This is intentional: + * the picker footer is a running tally that updates on every keystroke, and mixing + * "≈ 1.5k" with "≈ 2k" as the user toggles across the 1500-token boundary reads + * as a visual stutter. A single rounding rule produces a calmer counter. + * + * MH-04 (Phase 4 ↔ bust parity): for n >= 10_000 this helper's human-visible value + * matches `fmtK(n)` in shareable-block.ts exactly, so the picker total at the + * moment of Enter matches the post-bust "Freed: ~Xk" summary within ≤1k rounding + * tolerance. The 1000..9999 band can differ by up to ~0.5k (picker shows "≈ 2k", + * summary shows "~1.5k") — this is inside the tolerance quoted in MH-04. + */ +export function formatTokensApprox(n: number, opts: { ascii?: boolean } = {}): string { + if (n === 0) return ''; + if (n < 1000) return `${n} tokens`; + const glyph = opts.ascii === true ? '~' : '≈'; + return `${glyph} ${Math.round(n / 1000)}k tokens`; +} + +/** + * Sum the token estimate of every catalog item whose canonical id is present in + * the selection Set (D4-12). Items not present in the catalog contribute 0 + * (defensive — the picker's Set should never contain a stale id, but a future + * filter/sort refactor could leave stale entries briefly during a state + * transition). + * + * O(n) per call. At human interaction speeds (<10 render/sec) and realistic + * inventories (≤ 500 items), this is trivially in budget — matches D4-12. + * + * The caller is responsible for computing canonical ids and building the + * catalog map (id → tokens, with null tokenEstimate collapsed to 0). Keeping + * the helper map-based avoids a cross-directory import from token/ into + * scanner/. + */ +export function sumSelectionTokens( + ids: ReadonlySet, + catalog: ReadonlyMap, +): number { + if (ids.size === 0) return 0; + let total = 0; + for (const id of ids) { + const t = catalog.get(id); + if (t !== undefined) total += t; + } + return total; +} + if (import.meta.vitest) { const { describe, it, expect } = import.meta.vitest; @@ -224,4 +282,75 @@ if (import.meta.vitest) { expect(result).toContain('ccaudit --dangerously-bust-ghosts'); }); }); + + describe('formatTokensApprox', () => { + it('returns empty string for 0 tokens', () => { + expect(formatTokensApprox(0)).toBe(''); + }); + + it('returns raw "1 tokens" for n=1 (no approximation glyph)', () => { + expect(formatTokensApprox(1)).toBe('1 tokens'); + }); + + it('returns raw "999 tokens" for n=999', () => { + expect(formatTokensApprox(999)).toBe('999 tokens'); + }); + + it('switches to "≈ 1k tokens" at the 1000 boundary', () => { + expect(formatTokensApprox(1000)).toBe('≈ 1k tokens'); + }); + + it('rounds 1499 down to "≈ 1k tokens"', () => { + expect(formatTokensApprox(1499)).toBe('≈ 1k tokens'); + }); + + it('rounds 1500 up to "≈ 2k tokens"', () => { + expect(formatTokensApprox(1500)).toBe('≈ 2k tokens'); + }); + + it('formats 47123 as "≈ 47k tokens"', () => { + expect(formatTokensApprox(47123)).toBe('≈ 47k tokens'); + }); + + it('ASCII mode swaps ≈ for ~ (D4-11 fallback)', () => { + expect(formatTokensApprox(1500, { ascii: true })).toBe('~ 2k tokens'); + }); + + it('ASCII mode still returns empty for 0', () => { + expect(formatTokensApprox(0, { ascii: true })).toBe(''); + }); + + it('ASCII mode keeps raw count for < 1000 (no glyph either way)', () => { + expect(formatTokensApprox(500, { ascii: true })).toBe('500 tokens'); + }); + }); + + describe('sumSelectionTokens', () => { + it('empty selection sums to 0', () => { + expect(sumSelectionTokens(new Set(), new Map())).toBe(0); + }); + + it("single-id selection returns that id's token value", () => { + const cat = new Map([['id-a', 100]]); + expect(sumSelectionTokens(new Set(['id-a']), cat)).toBe(100); + }); + + it('multi-id selection sums each id exactly once', () => { + const cat = new Map([ + ['id-a', 100], + ['id-b', 250], + ['id-c', 75], + ]); + expect(sumSelectionTokens(new Set(['id-a', 'id-c']), cat)).toBe(175); + }); + + it('id present in set but missing from catalog contributes 0', () => { + const cat = new Map([['id-a', 100]]); + expect(sumSelectionTokens(new Set(['id-a', 'id-missing']), cat)).toBe(100); + }); + + it('empty catalog with non-empty selection returns 0', () => { + expect(sumSelectionTokens(new Set(['id-a', 'id-b']), new Map())).toBe(0); + }); + }); } diff --git a/packages/internal/src/token/index.ts b/packages/internal/src/token/index.ts index 40bea05..2bd4049 100644 --- a/packages/internal/src/token/index.ts +++ b/packages/internal/src/token/index.ts @@ -13,7 +13,13 @@ export { export { estimateFromFileSize, BYTES_PER_TOKEN } from './file-size-estimator.ts'; // Token display formatting -export { formatTokenEstimate, formatTotalOverhead, formatSavingsLine } from './format.ts'; +export { + formatTokenEstimate, + formatTotalOverhead, + formatSavingsLine, + formatTokensApprox, + sumSelectionTokens, +} from './format.ts'; // Enrichment pipeline export { diff --git a/packages/internal/src/types.ts b/packages/internal/src/types.ts index 0f6d2ec..a3336b8 100644 --- a/packages/internal/src/types.ts +++ b/packages/internal/src/types.ts @@ -1,4 +1,5 @@ import type { TokenEstimate } from './token/types.ts'; +import type { FrameworkProtection } from './scanner/types.ts'; /** * Scope of a ghost item -- global (~/.claude/) or project-local (.claude/). @@ -57,6 +58,23 @@ export interface GhostItem { * items when file-size estimation fails (e.g., unreadable file). */ tokenEstimate?: TokenEstimate | null; + /** + * Phase 6 (D6-01): framework-as-unit protection metadata. Populated by + * `toGhostItems` when the item belongs to a framework that has BOTH used + * and ghost members (would trip INV-S6 under a partial bust). `undefined` + * when the item is not protected — the picker row renders normally. + * + * Advisory only: server-side INV-S6 enforcement in `runBust` remains the + * actual gate. This field feeds the picker row dim+glyph and the toggle + * guard (plan 06-02). + */ + protection?: FrameworkProtection; + /** + * Phase 6 (D6-02): config-file references for MCP items. Propagated from + * the underlying `InventoryItem.configRefs` when present. Length >= 1 for + * every MCP-category GhostItem; `undefined` for non-MCP items. + */ + configRefs?: string[]; } /** diff --git a/packages/terminal/package.json b/packages/terminal/package.json index 93300e4..8e399c3 100644 --- a/packages/terminal/package.json +++ b/packages/terminal/package.json @@ -13,6 +13,8 @@ }, "devDependencies": { "@ccaudit/internal": "workspace:*", + "@clack/core": "catalog:", + "@clack/prompts": "catalog:", "@types/node": "catalog:", "@vitest/coverage-v8": "catalog:", "cli-table3": "catalog:", diff --git a/packages/terminal/src/index.ts b/packages/terminal/src/index.ts index a6d04aa..6d41bac 100644 --- a/packages/terminal/src/index.ts +++ b/packages/terminal/src/index.ts @@ -29,3 +29,5 @@ export type { ChangePlanRenderOptions, ProtectedItem } from './tables/index.ts'; export { initColor, isColorEnabled, getTableStyle, colorize } from './color.ts'; export { csvEscape, csvRow, csvTable } from './csv.ts'; export { tsvRow } from './quiet.ts'; + +export * from './tui/index.ts'; diff --git a/packages/terminal/src/tables/change-plan.ts b/packages/terminal/src/tables/change-plan.ts index 7eeef1f..d9f4b4a 100644 --- a/packages/terminal/src/tables/change-plan.ts +++ b/packages/terminal/src/tables/change-plan.ts @@ -69,8 +69,8 @@ export function renderChangePlan(plan: ChangePlan, opts?: ChangePlanRenderOption } // ── EXISTING GROUPS (unchanged) ──────────────────────────────────── - // Group 1: Archive (agents + skills) - if (plan.counts.agents > 0 || plan.counts.skills > 0) { + // Group 1: Archive (agents + skills + commands) + if (plan.counts.agents > 0 || plan.counts.skills > 0 || plan.counts.commands > 0) { lines.push(colorize.bold('Will ARCHIVE (reversible via `ccaudit restore `):')); if (plan.counts.agents > 0) { lines.push( @@ -82,6 +82,11 @@ export function renderChangePlan(plan: ChangePlan, opts?: ChangePlanRenderOption ` ${String(plan.counts.skills).padStart(3)} skills → ~/.claude/ccaudit/archived/skills/`, ); } + if (plan.counts.commands > 0) { + lines.push( + ` ${String(plan.counts.commands).padStart(3)} commands → ~/.claude/ccaudit/archived/commands/`, + ); + } lines.push(''); } @@ -283,7 +288,7 @@ if (import.meta.vitest) { archive: [], disable: [], flag: [], - counts: { agents: 0, skills: 0, mcp: 0, memory: 0 }, + counts: { agents: 0, skills: 0, mcp: 0, memory: 0, commands: 0 }, savings: { tokens: 0 }, ...parts, }; @@ -303,7 +308,7 @@ if (import.meta.vitest) { makeItem({ action: 'disable', category: 'mcp-server', name: 'm3' }), ], flag: [makeItem({ action: 'flag', category: 'memory', name: 'CLAUDE.md' })], - counts: { agents: 2, skills: 1, mcp: 3, memory: 1 }, + counts: { agents: 2, skills: 1, mcp: 3, memory: 1, commands: 0 }, savings: { tokens: 94000 }, }); const out = renderChangePlan(plan); @@ -320,7 +325,7 @@ if (import.meta.vitest) { it('omits DISABLE group when counts.mcp === 0', () => { const plan = makePlan({ archive: [makeItem({ action: 'archive', category: 'agent' })], - counts: { agents: 1, skills: 0, mcp: 0, memory: 0 }, + counts: { agents: 1, skills: 0, mcp: 0, memory: 0, commands: 0 }, savings: { tokens: 100 }, }); expect(renderChangePlan(plan)).not.toContain('Will DISABLE'); @@ -329,7 +334,7 @@ if (import.meta.vitest) { it('omits FLAG group when counts.memory === 0', () => { const plan = makePlan({ archive: [makeItem({ action: 'archive', category: 'agent' })], - counts: { agents: 1, skills: 0, mcp: 0, memory: 0 }, + counts: { agents: 1, skills: 0, mcp: 0, memory: 0, commands: 0 }, }); expect(renderChangePlan(plan)).not.toContain('Will FLAG'); }); @@ -337,7 +342,7 @@ if (import.meta.vitest) { it('omits ARCHIVE group when no agents or skills', () => { const plan = makePlan({ disable: [makeItem({ action: 'disable', category: 'mcp-server' })], - counts: { agents: 0, skills: 0, mcp: 1, memory: 0 }, + counts: { agents: 0, skills: 0, mcp: 1, memory: 0, commands: 0 }, }); expect(renderChangePlan(plan)).not.toContain('Will ARCHIVE'); }); @@ -362,6 +367,43 @@ if (import.meta.vitest) { const out = renderChangePlan(makePlan({ savings: { tokens: 500 } })); expect(out).toContain('~500 tokens'); }); + + it('emits a commands row inside Will ARCHIVE when counts.commands > 0', () => { + const plan = makePlan({ + archive: [ + makeItem({ action: 'archive', category: 'command', name: 'sc:build', path: '/tmp/cmd' }), + ], + counts: { agents: 0, skills: 0, mcp: 0, memory: 0, commands: 1 }, + savings: { tokens: 30 }, + }); + const out = renderChangePlan(plan); + expect(out).toContain('Will ARCHIVE'); + expect(out).toMatch(/\b1 commands\b/); + expect(out).toMatch(/1 commands\s+→\s+~\/\.claude\/ccaudit\/archived\/commands\//); + }); + + it('renders ARCHIVE block when commands are the only archive category', () => { + const plan = makePlan({ + archive: [makeItem({ action: 'archive', category: 'command', name: 'cmd1' })], + counts: { agents: 0, skills: 0, mcp: 0, memory: 0, commands: 1 }, + savings: { tokens: 30 }, + }); + const out = renderChangePlan(plan); + expect(out).toContain('Will ARCHIVE'); + expect(out).not.toContain('agents'); + expect(out).not.toContain('skills'); + expect(out).toContain('1 commands'); + }); + + it('omits commands row when counts.commands === 0', () => { + const plan = makePlan({ + archive: [makeItem({ action: 'archive', category: 'agent', name: 'a1' })], + counts: { agents: 1, skills: 0, mcp: 0, memory: 0, commands: 0 }, + }); + const out = renderChangePlan(plan); + expect(out).toContain('1 agents'); + expect(out).not.toMatch(/commands\s+→/); + }); }); describe('renderChangePlanVerbose', () => { @@ -488,7 +530,7 @@ if (import.meta.vitest) { ], disable: [makeItem({ action: 'disable', category: 'mcp-server', name: 'm1' })], flag: [makeItem({ action: 'flag', category: 'memory', name: 'CLAUDE.md' })], - counts: { agents: 1, skills: 1, mcp: 1, memory: 1 }, + counts: { agents: 1, skills: 1, mcp: 1, memory: 1, commands: 0 }, savings: { tokens: 1234 }, }); } diff --git a/packages/terminal/src/tui/_filter-sort.ts b/packages/terminal/src/tui/_filter-sort.ts new file mode 100644 index 0000000..1e109de --- /dev/null +++ b/packages/terminal/src/tui/_filter-sort.ts @@ -0,0 +1,258 @@ +/** + * Pure filter + sort helpers for the tabbed picker's Phase 5 keyboard model. + * + * Separates correctness-critical logic from the stateful picker (Plan 02 + * wires these into `TabbedGhostPicker`). All functions here are pure: no + * I/O, no process state, no mutation of input arrays. + * + * Decisions implemented: + * - D5-02: Match is case-insensitive substring on `item.name` only. + * - D5-08: Sort cycle is `staleness-desc → tokens-desc → name-asc → staleness-desc`. + * Default on picker entry is `staleness-desc`. + * - D5-10: Sort is stable (Node ≥20 Array.prototype.sort is stable). + * - T-05-01 mitigation: `sanitizeFilterQuery` strips ANSI CSI sequences + + * bare ESC + C0/DEL control chars before echoing into the render buffer, + * so pasted terminal-control payloads cannot inject cursor moves / colors + * / title changes through the filter echo. + * + * No imports from `tabbed-picker.ts` — this file is a leaf in the dep graph. + */ +import type { TokenCostResult } from '@ccaudit/internal'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export type SortMode = 'staleness-desc' | 'tokens-desc' | 'name-asc'; + +export interface FilterSortState { + /** Current filter query (post-sanitization). Empty string = no narrowing. */ + query: string; + /** True while the filter input row is focused (cursor visible). */ + active: boolean; + /** Current per-tab sort mode. */ + sort: SortMode; +} + +/** Factory for the initial per-tab state on picker entry (D5-08). */ +export function defaultFilterSortState(): FilterSortState { + return { query: '', active: false, sort: 'staleness-desc' }; +} + +// --------------------------------------------------------------------------- +// Filter +// --------------------------------------------------------------------------- + +/** + * Case-insensitive substring match on the display name (D5-02). + * Empty query matches everything (no-op filter). + */ +export function matchesQuery(name: string, query: string): boolean { + if (query.length === 0) return true; + return name.toLowerCase().includes(query.toLowerCase()); +} + +/** + * Strip terminal-injection risks from a user-supplied filter query before + * echoing it into the render buffer (threat T-05-01). + * + * Removes: + * - ANSI CSI sequences: `ESC [` followed by parameter/intermediate bytes + * terminated by a final byte (`@`–`~`). + * - Any remaining ESC (`\x1b`) bytes (OSC, raw escape, etc.). + * - C0 control characters (`\x00`–`\x1F`) and DEL (`\x7F`). + * + * Keeps all printable ASCII + Unicode letters/digits/punctuation/spaces. + */ +export function sanitizeFilterQuery(raw: string): string { + // Strip CSI sequences first (ESC [ ... final-byte). + // eslint-disable-next-line no-control-regex + const noCsi = raw.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, ''); + // Strip any residual ESC bytes and C0/DEL controls. + // eslint-disable-next-line no-control-regex + return noCsi.replace(/[\x00-\x1F\x7F]/g, ''); +} + +// --------------------------------------------------------------------------- +// Sort +// --------------------------------------------------------------------------- + +/** D5-08 cycle: staleness-desc → tokens-desc → name-asc → staleness-desc. */ +export function nextSort(current: SortMode): SortMode { + switch (current) { + case 'staleness-desc': + return 'tokens-desc'; + case 'tokens-desc': + return 'name-asc'; + case 'name-asc': + return 'staleness-desc'; + } +} + +/** + * Return a sorted COPY of `items` according to `mode`. Never mutates input. + * + * - `tokens-desc`: highest `tokenEstimate.tokens` first; null → 0. + * - `name-asc`: case-insensitive alphabetical by `item.name`. + * - `staleness-desc`: largest `now - item.mtimeMs` first. Items missing + * `mtimeMs` have age = `now` (largest), so they float to top as + * most-stale. Documented so Plan 02's integration test matches. + * + * Stable: ties preserve input order (Node ≥20 stable sort). + */ +export function sortItems( + items: readonly TokenCostResult[], + mode: SortMode, + now: number, +): TokenCostResult[] { + const copy = items.slice(); + switch (mode) { + case 'tokens-desc': + copy.sort((a, b) => (b.tokenEstimate?.tokens ?? 0) - (a.tokenEstimate?.tokens ?? 0)); + return copy; + case 'name-asc': + copy.sort((a, b) => + a.item.name.localeCompare(b.item.name, undefined, { sensitivity: 'base' }), + ); + return copy; + case 'staleness-desc': + copy.sort((a, b) => { + const aAge = now - (a.item.mtimeMs ?? 0); + const bAge = now - (b.item.mtimeMs ?? 0); + return bAge - aAge; + }); + return copy; + } +} + +// --------------------------------------------------------------------------- +// In-source tests +// --------------------------------------------------------------------------- + +if (import.meta.vitest) { + const { describe, it, expect } = import.meta.vitest; + + interface MakeItemOpts { + name: string; + tokens?: number | null; + mtimeMs?: number; + } + const makeItem = ({ name, tokens = 0, mtimeMs }: MakeItemOpts): TokenCostResult => ({ + item: { + name, + category: 'agent', + scope: 'global', + projectPath: null, + path: `/fake/${name}`, + mtimeMs, + }, + tier: 'definite-ghost', + lastUsed: null, + invocationCount: 0, + tokenEstimate: tokens === null ? null : { tokens, confidence: 'estimated', source: 'test' }, + }); + + describe('matchesQuery', () => { + it('case-insensitive substring', () => { + expect(matchesQuery('Pencil-Dev', 'pencil')).toBe(true); + expect(matchesQuery('Pencil-Dev', 'PENCIL')).toBe(true); + expect(matchesQuery('Pencil-Dev', 'cil-de')).toBe(true); + }); + it('empty query matches everything', () => { + expect(matchesQuery('foo', '')).toBe(true); + expect(matchesQuery('', '')).toBe(true); + }); + it('non-match returns false', () => { + expect(matchesQuery('foo', 'bar')).toBe(false); + }); + }); + + describe('sanitizeFilterQuery', () => { + it('strips ANSI SGR color escapes', () => { + expect(sanitizeFilterQuery('\x1b[31mfoo\x1b[0m')).toBe('foo'); + }); + it('strips cursor-move CSI escapes', () => { + expect(sanitizeFilterQuery('a\x1b[2Jb')).toBe('ab'); + }); + it('strips C0 control chars and DEL', () => { + expect(sanitizeFilterQuery('a\x00b\x07c\x7Fd')).toBe('abcd'); + }); + it('strips bare ESC with no CSI', () => { + expect(sanitizeFilterQuery('a\x1bb')).toBe('ab'); + }); + it('preserves unicode + spaces + punctuation', () => { + expect(sanitizeFilterQuery('café test-1!')).toBe('café test-1!'); + }); + }); + + describe('nextSort', () => { + it('cycles staleness-desc → tokens-desc → name-asc → staleness-desc', () => { + expect(nextSort('staleness-desc')).toBe('tokens-desc'); + expect(nextSort('tokens-desc')).toBe('name-asc'); + expect(nextSort('name-asc')).toBe('staleness-desc'); + }); + it('4 cycles from default lands on tokens-desc (D5-10 stability)', () => { + let m: SortMode = 'staleness-desc'; + for (let i = 0; i < 4; i++) m = nextSort(m); + expect(m).toBe('tokens-desc'); + }); + }); + + describe('defaultFilterSortState', () => { + it('returns empty/inactive/staleness-desc', () => { + expect(defaultFilterSortState()).toEqual({ + query: '', + active: false, + sort: 'staleness-desc', + }); + }); + }); + + describe('sortItems', () => { + const now = 1_000_000; + const a = makeItem({ name: 'alpha', tokens: 100, mtimeMs: now - 1000 }); + const b = makeItem({ name: 'Bravo', tokens: 500, mtimeMs: now - 5000 }); + const c = makeItem({ name: 'charlie', tokens: null, mtimeMs: now - 200 }); + const d = makeItem({ name: 'delta', tokens: 50 /* mtimeMs missing */ }); + const items = [a, b, c, d]; + + it('tokens-desc: highest tokens first; null counts as 0', () => { + const out = sortItems(items, 'tokens-desc', now); + expect(out.map((x) => x.item.name)).toEqual(['Bravo', 'alpha', 'delta', 'charlie']); + }); + + it('name-asc: case-insensitive alphabetical', () => { + const out = sortItems(items, 'name-asc', now); + expect(out.map((x) => x.item.name)).toEqual(['alpha', 'Bravo', 'charlie', 'delta']); + }); + + it('staleness-desc: missing mtimeMs is most-stale, then oldest first', () => { + const out = sortItems(items, 'staleness-desc', now); + expect(out.map((x) => x.item.name)).toEqual(['delta', 'Bravo', 'alpha', 'charlie']); + }); + + it('does not mutate input', () => { + const before = items.map((x) => x.item.name); + sortItems(items, 'tokens-desc', now); + sortItems(items, 'name-asc', now); + sortItems(items, 'staleness-desc', now); + expect(items.map((x) => x.item.name)).toEqual(before); + }); + + it('repeated calls produce identical orderings (stable)', () => { + const r1 = sortItems(items, 'tokens-desc', now).map((x) => x.item.name); + const r2 = sortItems(items, 'tokens-desc', now).map((x) => x.item.name); + const r3 = sortItems(items, 'tokens-desc', now).map((x) => x.item.name); + expect(r1).toEqual(r2); + expect(r2).toEqual(r3); + }); + + it('ties in tokens preserve input order (stable sort)', () => { + const x = makeItem({ name: 'x', tokens: 10 }); + const y = makeItem({ name: 'y', tokens: 10 }); + const z = makeItem({ name: 'z', tokens: 10 }); + const out = sortItems([x, y, z], 'tokens-desc', now); + expect(out.map((i) => i.item.name)).toEqual(['x', 'y', 'z']); + }); + }); +} diff --git a/packages/terminal/src/tui/_force-partial-banner.ts b/packages/terminal/src/tui/_force-partial-banner.ts new file mode 100644 index 0000000..325625f --- /dev/null +++ b/packages/terminal/src/tui/_force-partial-banner.ts @@ -0,0 +1,175 @@ +/** + * Phase 6 Plan 03 (D6-08, D6-14): pure banner renderer for the top-of-TUI + * `--force-partial` warning. + * + * Contract: + * - When `active === false`, returns `""` (empty string). The picker omits + * the banner line entirely and viewport math stays at its Phase 3.1 size. + * - When `active === true`, returns a single-line banner: + * Unicode: `⚠ --force-partial active: framework protection DISABLED. …` + * ASCII: `! --force-partial active: framework protection DISABLED. …` + * - When `active === true` AND `protectedCount === 0`, the banner gains a + * suffix `" (no protected items in this scan)"` — prevents the user from + * thinking the flag was silently dropped (D6-14). + * + * No color here — the picker wraps the return value in an SGR sequence when + * the terminal is a TTY. Helper stays colorless so tests are deterministic. + */ + +const BASE_TEXT = + '--force-partial active: framework protection DISABLED. ' + + 'Partial framework splits may corrupt dependent setups.'; + +const ZERO_PROTECTED_SUFFIX = ' (no protected items in this scan)'; + +export interface RenderForcePartialBannerOptions { + active: boolean; + protectedCount: number; + ascii: boolean; +} + +/** + * Render the banner line (no trailing newline). Returns empty string when + * inactive so the picker can do a simple `if (banner) lines.push(banner)` + * without conditionals on the flag. + */ +export function renderForcePartialBanner(opts: RenderForcePartialBannerOptions): string { + if (!opts.active) return ''; + const glyph = opts.ascii ? '!' : '⚠'; + const suffix = opts.protectedCount === 0 ? ZERO_PROTECTED_SUFFIX : ''; + return `${glyph} ${BASE_TEXT}${suffix}`; +} + +export interface BannerHeightOptions { + active: boolean; + protectedCount: number; + ascii: boolean; + terminalCols: number; +} + +/** + * Row budget the banner consumes. Width-aware: computes the exact rendered + * text length via `renderForcePartialBanner` (colorless by contract) and + * divides by `terminalCols` to account for line-wrapping at narrower + * terminals (e.g. at 80 cols the 111-char active+protected banner wraps to + * 2 rows; at 60 cols the 146-char active+zero-protected banner wraps to 3). + * + * `⚠` is width-1 in all modern terminals (xterm-256, iTerm, Terminal.app, + * Windows Terminal) — no string-width dep needed. + */ +export function bannerHeight(opts: BannerHeightOptions): number { + if (!opts.active) return 0; + const text = renderForcePartialBanner(opts); + const cols = Math.max(1, opts.terminalCols); + return Math.max(1, Math.ceil(text.length / cols)); +} + +// --------------------------------------------------------------------------- +// In-source tests +// --------------------------------------------------------------------------- + +if (import.meta.vitest) { + const { describe, it, expect } = import.meta.vitest; + + describe('renderForcePartialBanner', () => { + it('returns empty string when active=false (regardless of protectedCount/ascii)', () => { + expect(renderForcePartialBanner({ active: false, protectedCount: 0, ascii: false })).toBe(''); + expect(renderForcePartialBanner({ active: false, protectedCount: 5, ascii: true })).toBe(''); + }); + + it('active + Unicode + protectedCount>0 → ⚠ prefix, base text, no suffix', () => { + const out = renderForcePartialBanner({ active: true, protectedCount: 3, ascii: false }); + expect(out.startsWith('⚠ ')).toBe(true); + expect(out).toContain('--force-partial active: framework protection DISABLED.'); + expect(out).toContain('Partial framework splits may corrupt dependent setups.'); + expect(out).not.toContain('no protected items in this scan'); + }); + + it('active + Unicode + protectedCount===0 → adds "(no protected items in this scan)" suffix (D6-14)', () => { + const out = renderForcePartialBanner({ active: true, protectedCount: 0, ascii: false }); + expect(out.startsWith('⚠ ')).toBe(true); + expect(out.endsWith('(no protected items in this scan)')).toBe(true); + }); + + it('active + ASCII → swaps ⚠ for ! (D6-08)', () => { + const out = renderForcePartialBanner({ active: true, protectedCount: 2, ascii: true }); + expect(out.startsWith('! ')).toBe(true); + expect(out).not.toContain('⚠'); + }); + + it('active + ASCII + zero-protected → ! prefix AND suffix', () => { + const out = renderForcePartialBanner({ active: true, protectedCount: 0, ascii: true }); + expect(out.startsWith('! ')).toBe(true); + expect(out.endsWith('(no protected items in this scan)')).toBe(true); + }); + + it('contains no ANSI escape sequences (picker adds color at render time)', () => { + const out = renderForcePartialBanner({ active: true, protectedCount: 1, ascii: false }); + // eslint-disable-next-line no-control-regex + expect(/\x1b\[/.test(out)).toBe(false); + }); + }); + + describe('bannerHeight (width-aware)', () => { + it('returns 0 when active=false (regardless of cols/protectedCount/ascii)', () => { + expect( + bannerHeight({ active: false, protectedCount: 0, ascii: false, terminalCols: 80 }), + ).toBe(0); + expect( + bannerHeight({ active: false, protectedCount: 5, ascii: true, terminalCols: 40 }), + ).toBe(0); + }); + + it('terminalCols=200, active=true, protectedCount>0 → 1 row', () => { + expect( + bannerHeight({ active: true, protectedCount: 3, ascii: false, terminalCols: 200 }), + ).toBe(1); + }); + + it('terminalCols=120, protectedCount>0 → 1 row', () => { + expect( + bannerHeight({ active: true, protectedCount: 3, ascii: false, terminalCols: 120 }), + ).toBe(1); + }); + + it('terminalCols=120, protectedCount=0 → 2 rows (suffix pushes past 120 cols)', () => { + // active+zero-protected text is ~147 chars → ceil(147/120) = 2 + const h = bannerHeight({ active: true, protectedCount: 0, ascii: false, terminalCols: 120 }); + expect(h).toBe(2); + }); + + it('terminalCols=80, protectedCount>0 → 2 rows (111-char text wraps)', () => { + expect( + bannerHeight({ active: true, protectedCount: 1, ascii: false, terminalCols: 80 }), + ).toBe(2); + }); + + it('terminalCols=80, protectedCount=0 → 2 rows', () => { + expect( + bannerHeight({ active: true, protectedCount: 0, ascii: false, terminalCols: 80 }), + ).toBe(2); + }); + + it('terminalCols=60, protectedCount=0 → 3 rows (146-char text wraps to ceil(146/60)=3)', () => { + expect( + bannerHeight({ active: true, protectedCount: 0, ascii: false, terminalCols: 60 }), + ).toBe(3); + }); + + it('round-trip: bannerHeight === ceil(renderForcePartialBanner.length / cols) for all 4 (ascii × protected) combinations', () => { + for (const ascii of [false, true]) { + for (const protectedCount of [0, 3]) { + for (const terminalCols of [60, 80]) { + const text = renderForcePartialBanner({ active: true, protectedCount, ascii }); + const expected = Math.max(1, Math.ceil(text.length / terminalCols)); + const actual = bannerHeight({ active: true, protectedCount, ascii, terminalCols }); + expect( + actual, + `ascii=${ascii} protectedCount=${protectedCount} cols=${terminalCols}`, + ).toBe(expected); + } + } + } + }); + }); +} diff --git a/packages/terminal/src/tui/_glyph-capability.ts b/packages/terminal/src/tui/_glyph-capability.ts new file mode 100644 index 0000000..4eea5c4 --- /dev/null +++ b/packages/terminal/src/tui/_glyph-capability.ts @@ -0,0 +1,134 @@ +/** + * ASCII fallback predicate for TUI rendering (D-15, D-16). + * + * Single source of truth: call ONCE at TUI entry and pass the result + * (`useAscii: boolean`) into all downstream render functions. + * Do NOT re-evaluate per-row. + */ + +/** + * Minimal stdout shape needed by shouldUseAscii. + * Using a dedicated interface instead of Pick so that + * test fakes can satisfy it with a simple `() => boolean` without matching + * Node's overloaded hasColors signature. + */ +export interface StdoutCapability { + /** Optional — returns false when the stream has no color support. */ + hasColors?: () => boolean; + /** Terminal column width, if known. */ + columns?: number; +} + +/** + * Returns `true` when Unicode glyphs are unreliable and ASCII fallbacks + * should be used throughout the TUI session. + * + * Triggers (D-15) — first match wins: + * 1. `CCAUDIT_ASCII_ONLY=1` env var (explicit user opt-in) + * 2. `stdout.hasColors?.() === false` (no color support → likely no Unicode) + * 3. Terminal width < 60 columns (narrow terminal forces plain rendering) + * 4. Both LANG and LC_ALL are undefined AND TERM is 'dumb' or undefined + * (non-Unicode locale with dumb terminal) + * + * @param env - Node.js process environment (injected for testability) + * @param stdout - Stdout capability object (injected for testability) + * @param ttyCols - Explicit column override (skips stdout.columns lookup if provided) + */ +export function shouldUseAscii( + env: NodeJS.ProcessEnv, + stdout: StdoutCapability, + ttyCols?: number | undefined, +): boolean { + // Trigger 1: explicit opt-in via env var + if (env['CCAUDIT_ASCII_ONLY'] === '1') { + return true; + } + + // Trigger 2: no color support implies unreliable Unicode rendering + if (stdout.hasColors?.() === false) { + return true; + } + + // Trigger 3: narrow terminal + const cols = ttyCols ?? stdout.columns ?? 80; + if (cols < 60) { + return true; + } + + // Trigger 4: no Unicode locale indicators AND dumb/missing TERM + const hasLocale = env['LANG'] !== undefined || env['LC_ALL'] !== undefined; + const term = env['TERM']; + if (!hasLocale && (term === 'dumb' || term === undefined)) { + return true; + } + + return false; +} + +if (import.meta.vitest) { + const { describe, it, expect } = import.meta.vitest; + + /** Minimal fake stdout for tests — satisfies StdoutCapability. */ + function makeStdout(opts: { hasColors?: () => boolean; columns?: number }): StdoutCapability { + return { + hasColors: opts.hasColors, + columns: opts.columns ?? 80, + }; + } + + describe('shouldUseAscii', () => { + it('returns true when CCAUDIT_ASCII_ONLY=1', () => { + const env = { CCAUDIT_ASCII_ONLY: '1', LANG: 'en_US.UTF-8' }; + const stdout = makeStdout({ hasColors: () => true, columns: 120 }); + expect(shouldUseAscii(env, stdout)).toBe(true); + }); + + it('returns true when hasColors() === false', () => { + const env = { LANG: 'en_US.UTF-8' }; + const stdout = makeStdout({ hasColors: () => false, columns: 120 }); + expect(shouldUseAscii(env, stdout)).toBe(true); + }); + + it('returns true when ttyCols < 60 (via explicit param)', () => { + const env = { LANG: 'en_US.UTF-8' }; + const stdout = makeStdout({ hasColors: () => true, columns: 120 }); + expect(shouldUseAscii(env, stdout, 55)).toBe(true); + }); + + it('returns true when stdout.columns < 60 (via stdout)', () => { + const env = { LANG: 'en_US.UTF-8' }; + const stdout = makeStdout({ hasColors: () => true, columns: 40 }); + expect(shouldUseAscii(env, stdout)).toBe(true); + }); + + it('returns true when TERM=dumb and no LANG/LC_ALL', () => { + const env = { TERM: 'dumb' }; + const stdout = makeStdout({ hasColors: () => true, columns: 120 }); + expect(shouldUseAscii(env, stdout)).toBe(true); + }); + + it('returns true when TERM is undefined and no LANG/LC_ALL', () => { + const env: NodeJS.ProcessEnv = {}; + const stdout = makeStdout({ hasColors: () => true, columns: 120 }); + expect(shouldUseAscii(env, stdout)).toBe(true); + }); + + it('returns false for a standard color-capable TTY ≥60 cols with LANG set (default-false case)', () => { + const env = { LANG: 'en_US.UTF-8' }; + const stdout = makeStdout({ hasColors: () => true, columns: 120 }); + expect(shouldUseAscii(env, stdout)).toBe(false); + }); + + it('returns false when LC_ALL set even if TERM=dumb (locale present overrides dumb-term trigger)', () => { + const env = { LC_ALL: 'en_US.UTF-8', TERM: 'dumb' }; + const stdout = makeStdout({ hasColors: () => true, columns: 120 }); + expect(shouldUseAscii(env, stdout)).toBe(false); + }); + + it('CCAUDIT_ASCII_ONLY=0 is not treated as opt-in (only "1" triggers)', () => { + const env = { CCAUDIT_ASCII_ONLY: '0', LANG: 'en_US.UTF-8' }; + const stdout = makeStdout({ hasColors: () => true, columns: 120 }); + expect(shouldUseAscii(env, stdout)).toBe(false); + }); + }); +} diff --git a/packages/terminal/src/tui/_glyphs.ts b/packages/terminal/src/tui/_glyphs.ts new file mode 100644 index 0000000..3999d82 --- /dev/null +++ b/packages/terminal/src/tui/_glyphs.ts @@ -0,0 +1,157 @@ +/** + * Phase 9 Plan 02 (D4, SC4) — colorblind-friendly glyph set. + * + * Every selectable state in the picker gets a distinct column-1 glyph + * independent of color. Rendering callers pass `useAscii: boolean` from + * `resolveGlyphSet()` once per frame; every row prints its state glyph + * in column 1 — color is always layered on top, never the sole signal. + * + * Triggers for the ASCII fallback (first match wins): + * 1. `CCAUDIT_ASCII_ONLY=1` env var + * 2. `NO_COLOR` env var set (any value; convention is truthy-presence) + * 3. `TERM=dumb` + * 4. `opts.noColor === true` (mirrors the --no-color CLI flag at call sites) + * + * This is distinct from `_glyph-capability.ts`'s `shouldUseAscii()` which + * also degrades on narrow terminals (cols < 60) — that predicate is about + * rendering reliability; this one is about *colorblind accessibility* + * (D4: "every state has a distinct glyph, never color-only"). + * + * Do NOT re-evaluate per-row — call once at render-entry. + */ + +export interface GlyphSet { + /** Row-selected checkbox glyph. */ + selected: string; + /** Row-unselected checkbox glyph. */ + unselected: string; + /** Framework-as-unit protected row prefix. */ + protected: string; + /** Multi-config MCP server advisory prefix. */ + multiConfigMcp: string; + /** Stale (≥90d) memory file advisory prefix. */ + staleMemory: string; +} + +export const GLYPHS_UNICODE: GlyphSet = { + selected: '◉', + unselected: '◯', + protected: '🔒', + multiConfigMcp: '⚠', + staleMemory: '⌛', +}; + +export const GLYPHS_ASCII: GlyphSet = { + selected: '[x]', + unselected: '[ ]', + protected: '#', + multiConfigMcp: '!', + staleMemory: '~', +}; + +export interface ResolveGlyphSetOpts { + /** Mirrors the `--no-color` CLI flag; forces ASCII regardless of env. */ + noColor?: boolean; +} + +/** + * Pick a glyph set based on environment + explicit opts. ASCII wins on any + * of the four D4 triggers; otherwise Unicode. + */ +export function resolveGlyphSet(env: NodeJS.ProcessEnv, opts?: ResolveGlyphSetOpts): GlyphSet { + return shouldUseAsciiGlyphs(env, opts) ? GLYPHS_ASCII : GLYPHS_UNICODE; +} + +/** + * Predicate form of `resolveGlyphSet` — exposed for callers that already + * track a `useAscii: boolean` state (e.g., tabbed-picker.ts). Pure. + */ +export function shouldUseAsciiGlyphs(env: NodeJS.ProcessEnv, opts?: ResolveGlyphSetOpts): boolean { + if (opts?.noColor === true) return true; + if (env['CCAUDIT_ASCII_ONLY'] === '1') return true; + // NO_COLOR convention: any non-empty value => suppress color (and thus + // glyphs that rely on color for distinction are still safe — we switch + // to ASCII so they're distinguishable structurally). + // https://no-color.org/ + const noColor = env['NO_COLOR']; + if (typeof noColor === 'string' && noColor !== '') return true; + if (env['TERM'] === 'dumb') return true; + return false; +} + +// --------------------------------------------------------------------------- +// In-source tests +// --------------------------------------------------------------------------- + +if (import.meta.vitest) { + const { describe, it, expect } = import.meta.vitest; + + describe('resolveGlyphSet', () => { + it('returns Unicode glyphs for default color-capable env', () => { + const g = resolveGlyphSet({ LANG: 'en_US.UTF-8', TERM: 'xterm-256color' }); + expect(g).toBe(GLYPHS_UNICODE); + expect(g.selected).toBe('◉'); + expect(g.unselected).toBe('◯'); + }); + + it('returns ASCII glyphs when CCAUDIT_ASCII_ONLY=1', () => { + const g = resolveGlyphSet({ CCAUDIT_ASCII_ONLY: '1' }); + expect(g).toBe(GLYPHS_ASCII); + expect(g.selected).toBe('[x]'); + expect(g.unselected).toBe('[ ]'); + }); + + it('returns ASCII glyphs when NO_COLOR is set (any non-empty value)', () => { + expect(resolveGlyphSet({ NO_COLOR: '1' })).toBe(GLYPHS_ASCII); + expect(resolveGlyphSet({ NO_COLOR: 'true' })).toBe(GLYPHS_ASCII); + }); + + it('does NOT trigger ASCII when NO_COLOR is empty string', () => { + // Per no-color.org convention: empty string is not "set". We treat + // unset/empty identically so callers that do `env.NO_COLOR ?? ''` + // don't accidentally force ASCII. + expect(resolveGlyphSet({ NO_COLOR: '', LANG: 'en_US.UTF-8' })).toBe(GLYPHS_UNICODE); + }); + + it('returns ASCII glyphs when TERM=dumb', () => { + expect(resolveGlyphSet({ TERM: 'dumb' })).toBe(GLYPHS_ASCII); + }); + + it('returns ASCII glyphs when opts.noColor === true (mirrors --no-color flag)', () => { + const g = resolveGlyphSet({ LANG: 'en_US.UTF-8' }, { noColor: true }); + expect(g).toBe(GLYPHS_ASCII); + }); + + it('CCAUDIT_ASCII_ONLY=0 is NOT treated as opt-in (only "1" triggers)', () => { + const g = resolveGlyphSet({ CCAUDIT_ASCII_ONLY: '0', LANG: 'en_US.UTF-8' }); + expect(g).toBe(GLYPHS_UNICODE); + }); + + it('every state has a distinct glyph in both sets (SC4 invariant)', () => { + for (const set of [GLYPHS_UNICODE, GLYPHS_ASCII]) { + const glyphs = [ + set.selected, + set.unselected, + set.protected, + set.multiConfigMcp, + set.staleMemory, + ]; + expect(new Set(glyphs).size).toBe(glyphs.length); + } + }); + }); + + describe('shouldUseAsciiGlyphs', () => { + it('matches resolveGlyphSet === GLYPHS_ASCII for all documented triggers', () => { + const triggers: NodeJS.ProcessEnv[] = [ + { CCAUDIT_ASCII_ONLY: '1' }, + { NO_COLOR: '1' }, + { TERM: 'dumb' }, + ]; + for (const env of triggers) { + expect(shouldUseAsciiGlyphs(env)).toBe(true); + expect(resolveGlyphSet(env)).toBe(GLYPHS_ASCII); + } + }); + }); +} diff --git a/packages/terminal/src/tui/_help-overlay.ts b/packages/terminal/src/tui/_help-overlay.ts new file mode 100644 index 0000000..ea55299 --- /dev/null +++ b/packages/terminal/src/tui/_help-overlay.ts @@ -0,0 +1,265 @@ +/** + * Phase 5 D5-13..D5-16: modal help overlay render function. + * + * PURE: no state, no side effects, no ANSI styling. Deterministic output from + * `{ useAscii, rows, cols }`. Consumed by `TabbedGhostPicker._renderFrame()` + * when `helpOpen === true`. + * + * Content is grouped into functional sections (D5-14): + * Navigation / Selection / View / Glyphs / Exit + * + * Unicode mode uses `──` framed headings; ASCII mode (D5-21) swaps to `#` + * prefixes and replaces arrows (`↑↓←→`) with caret-slash ASCII equivalents. + * + * Sub-minimum viewport (`rows < 14`, D5-15) falls back to a one-column compact + * list terminated by `(Press ? to close and resize terminal)`; the overlay + * never crashes, never grows unbounded. + * + * Phase 9 E4: the column-1 glyph legend is inlined so users can decode + * colorblind-friendly row state markers without leaving the picker. + */ + +export interface HelpOverlayInput { + useAscii: boolean; + /** Terminal rows — gates compact-mode fallback (D5-15). */ + rows: number; + /** Terminal cols — hard truncation width. */ + cols: number; +} + +interface Binding { + readonly keys: string; + readonly desc: string; +} + +interface Group { + readonly heading: string; + readonly bindings: readonly Binding[]; +} + +// --------------------------------------------------------------------------- +// Binding catalog (D5-14) — same set rendered in both modes; glyphs swapped at +// render time based on useAscii. +// --------------------------------------------------------------------------- + +function buildGroups(useAscii: boolean): readonly Group[] { + const up = useAscii ? '^' : '↑'; + const down = useAscii ? 'v' : '↓'; + const left = useAscii ? '<-' : '←'; + const right = useAscii ? '->' : '→'; + return [ + { + heading: 'Navigation', + bindings: [ + { keys: `${up} ${down}`, desc: 'Move cursor within tab' }, + { keys: 'PgUp PgDn', desc: 'Page within tab' }, + { keys: 'Home End', desc: 'Jump to first / last row' }, + { keys: 'Tab Shift-Tab', desc: 'Cycle tabs forward / back' }, + { keys: `${left} ${right}`, desc: 'Cycle tabs (arrow aliases)' }, + { keys: '1 2 3 4 5 6', desc: 'Jump to tab N' }, + ], + }, + { + heading: 'Selection', + bindings: [ + { keys: 'Space', desc: 'Toggle current row' }, + { keys: 'a', desc: 'Toggle all in active tab' }, + { keys: 'n', desc: 'Clear all selections' }, + { keys: 'i', desc: 'Invert selection in active tab' }, + ], + }, + { + heading: 'View', + bindings: [ + { keys: '/', desc: 'Filter: case-insensitive substring on name' }, + { keys: 's', desc: 'Cycle sort (staleness / tokens / name)' }, + { keys: '?', desc: 'Toggle this help overlay' }, + ], + }, + { + heading: 'Glyphs', + bindings: buildGlyphLegend(useAscii), + }, + { + heading: 'Exit', + bindings: [ + { keys: 'Enter', desc: 'Confirm selection' }, + { keys: 'Esc', desc: 'Cancel / close overlay / clear filter' }, + { keys: 'Ctrl+C', desc: 'Cancel picker (q alias)' }, + ], + }, + ]; +} + +function buildGlyphLegend(useAscii: boolean): readonly Binding[] { + const [selected, unselected, locked, multi, stale] = useAscii + ? ['[x]', '[ ]', '#', '!', '~'] + : ['◉', '◯', '🔒', '⚠', '⌛']; + return [ + { keys: selected, desc: 'Selected' }, + { keys: unselected, desc: 'Unselected' }, + { keys: locked, desc: 'Protected / framework-locked' }, + { keys: multi, desc: 'Multi-config MCP server' }, + { keys: stale, desc: 'Stale memory file' }, + ]; +} + +const COMPACT_THRESHOLD_ROWS = 14; + +function formatHeading(heading: string, useAscii: boolean): string { + return useAscii ? `# ${heading}` : `── ${heading} ──`; +} + +function truncate(line: string, cols: number): string { + if (cols <= 0) return ''; + if (line.length <= cols) return line; + return line.slice(0, cols); +} + +/** + * Render the help overlay as a newline-joined string. Pure: identical input + * produces identical output. Never throws. + */ +export function renderHelpOverlay(input: HelpOverlayInput): string { + const { useAscii, rows, cols } = input; + const safeCols = Math.max(1, cols | 0); + const groups = buildGroups(useAscii); + const lines: string[] = []; + + lines.push(truncate('ccaudit keybindings', safeCols)); + lines.push(''); + + if (rows < COMPACT_THRESHOLD_ROWS) { + // Compact mode (D5-15): one-column plain list, prefixed by heading, + // then all bindings flat, then the escape hint. + for (const group of groups) { + lines.push(truncate(formatHeading(group.heading, useAscii), safeCols)); + for (const b of group.bindings) { + lines.push(truncate(` ${b.keys} ${b.desc}`, safeCols)); + } + } + lines.push(''); + lines.push(truncate('(Press ? to close and resize terminal)', safeCols)); + return lines.join('\n'); + } + + // Normal mode (D5-14): two-column-style binding list per group. We lay out + // each binding on its own line with padded-key column so descriptions align. + // This stays pure — no ANSI, just spaces. + const keyColWidth = Math.min( + 22, + Math.max(...groups.flatMap((g) => g.bindings.map((b) => b.keys.length))), + ); + + for (const group of groups) { + lines.push(truncate(formatHeading(group.heading, useAscii), safeCols)); + for (const b of group.bindings) { + const keys = b.keys.padEnd(keyColWidth, ' '); + lines.push(truncate(` ${keys} ${b.desc}`, safeCols)); + } + } + lines.push(truncate('(Press ? or Esc to close)', safeCols)); + return lines.join('\n'); +} + +if (import.meta.vitest) { + const { describe, it, expect } = import.meta.vitest; + + describe('renderHelpOverlay', () => { + it('contains all group headings and at least one binding each (D5-14)', () => { + const out = renderHelpOverlay({ useAscii: false, rows: 30, cols: 100 }); + expect(out).toContain('Navigation'); + expect(out).toContain('Selection'); + expect(out).toContain('View'); + expect(out).toContain('Glyphs'); + expect(out).toContain('Exit'); + // At least one representative keybind per group. + expect(out).toContain('Tab Shift-Tab'); + expect(out).toContain('Space'); + expect(out).toContain('/'); + expect(out).toContain('Ctrl+C'); + }); + + it('includes Unicode glyph legend matching rendered row glyphs (Phase 9 E4)', () => { + const out = renderHelpOverlay({ useAscii: false, rows: 30, cols: 100 }); + expect(out).toContain('── Glyphs ──'); + expect(out).toContain('◉'); + expect(out).toContain('Selected'); + expect(out).toContain('◯'); + expect(out).toContain('Unselected'); + expect(out).toContain('🔒'); + expect(out).toContain('Protected / framework-locked'); + expect(out).toContain('⚠'); + expect(out).toContain('Multi-config MCP server'); + expect(out).toContain('⌛'); + expect(out).toContain('Stale memory file'); + }); + + it('includes ASCII glyph legend when ASCII mode is active', () => { + const out = renderHelpOverlay({ useAscii: true, rows: 30, cols: 100 }); + expect(out).toContain('# Glyphs'); + expect(out).toContain('[x]'); + expect(out).toContain('[ ]'); + expect(out).toContain('#'); + expect(out).toContain('!'); + expect(out).toContain('~'); + }); + + it('ASCII mode swaps heading frame to `#` and replaces arrow glyphs (D5-21)', () => { + const ascii = renderHelpOverlay({ useAscii: true, rows: 30, cols: 100 }); + expect(ascii).toContain('# Navigation'); + expect(ascii).not.toContain('──'); + expect(ascii).not.toContain('↑'); + expect(ascii).not.toContain('↓'); + expect(ascii).not.toContain('←'); + expect(ascii).not.toContain('→'); + // ASCII arrow stand-ins present. + expect(ascii).toMatch(/\^ v/); + expect(ascii).toContain('<- ->'); + }); + + it('Unicode mode uses framed headings with em-dash surrounds', () => { + const uni = renderHelpOverlay({ useAscii: false, rows: 30, cols: 100 }); + expect(uni).toContain('── Navigation ──'); + expect(uni).toContain('── Exit ──'); + }); + + it('sub-minimum rows renders compact mode with close-and-resize hint (D5-15)', () => { + const compact = renderHelpOverlay({ useAscii: false, rows: 10, cols: 80 }); + expect(compact).toContain('(Press ? to close and resize terminal)'); + // Still lists every group heading. + expect(compact).toContain('Navigation'); + expect(compact).toContain('Exit'); + }); + + it('is deterministic: identical input => identical output (snapshot-friendly)', () => { + const a = renderHelpOverlay({ useAscii: false, rows: 30, cols: 100 }); + const b = renderHelpOverlay({ useAscii: false, rows: 30, cols: 100 }); + expect(a).toBe(b); + const c = renderHelpOverlay({ useAscii: true, rows: 10, cols: 40 }); + const d = renderHelpOverlay({ useAscii: true, rows: 10, cols: 40 }); + expect(c).toBe(d); + }); + + it('truncates every line to `cols` width (no line exceeds cols)', () => { + const narrow = renderHelpOverlay({ useAscii: false, rows: 30, cols: 20 }); + for (const line of narrow.split('\n')) { + expect(line.length).toBeLessThanOrEqual(20); + } + const narrowCompact = renderHelpOverlay({ useAscii: true, rows: 8, cols: 15 }); + for (const line of narrowCompact.split('\n')) { + expect(line.length).toBeLessThanOrEqual(15); + } + }); + + it('never throws on degenerate inputs (rows=0, cols=0, cols=1)', () => { + expect(() => renderHelpOverlay({ useAscii: false, rows: 0, cols: 0 })).not.toThrow(); + expect(() => renderHelpOverlay({ useAscii: true, rows: 0, cols: 1 })).not.toThrow(); + const zero = renderHelpOverlay({ useAscii: false, rows: 0, cols: 0 }); + // cols=0 clamps to 1; every line empty or 1-char. + for (const line of zero.split('\n')) { + expect(line.length).toBeLessThanOrEqual(1); + } + }); + }); +} diff --git a/packages/terminal/src/tui/_mcp-warning-render.ts b/packages/terminal/src/tui/_mcp-warning-render.ts new file mode 100644 index 0000000..a65e9f5 --- /dev/null +++ b/packages/terminal/src/tui/_mcp-warning-render.ts @@ -0,0 +1,255 @@ +/** + * Phase 6 Plan 04: Pure render helpers for the MCP multi-config warning UX + * in the tabbed picker. + * + * When an MCP server key is referenced by more than one config file (e.g. + * `~/.claude.json` + a project-local `.mcp.json`), the scanner (plan 06-01) + * attaches `configRefs: string[]` (length >= 1) to the item. This helper + * surfaces that in the picker: + * + * - `renderMcpWarningPrefix` prepends `⚠ ` (or `! ` in ASCII) to the row + * when `configRefs.length > 1` (D6-06). Otherwise empty string. + * - `alsoInHintLine` returns an `Also in: ` hint for the shared + * below-cursor slot (D6-07 / D6-21). Truncates to `, … (N more)` + * when `configRefs.length > 3`. + * + * Invariants: + * - Glyph ALWAYS accompanied by the text "Also in:" — never icon-alone + * (D6-21 accessibility rule). + * - Pure: no `@clack/core`, no `fs`, no `os`. configRefs paths are already + * compressed via `presentPath` upstream (plan 01) — this layer just + * formats, never rewrites. + * - Advisory only: caller must NOT use the predicate to gate selection. + */ + +/** + * Returns the warning glyph: `⚠` in Unicode mode, `!` in ASCII mode (D6-06). + */ +export function warningGlyph(ascii: boolean): string { + return ascii ? '!' : '⚠'; +} + +/** + * True when the item is an MCP server referenced by more than one config + * file. Tolerant of partial shapes so callers can pass `GhostItem` or the + * underlying `InventoryItem` interchangeably — both use `category` as the + * discriminator field (Phase 6 Plan 04 bugfix: earlier revision keyed off + * `kind` which neither canonical type exposes at runtime). + */ +export function isMultiConfig(item: { + kind?: string; + category?: string; + configRefs?: string[]; +}): boolean { + const tag = item.category ?? item.kind; + return tag === 'mcp-server' && Array.isArray(item.configRefs) && item.configRefs.length > 1; +} + +/** + * Row prefix `⚠ ` (or `! `) when multi-config, else empty string (D6-06). + * Callers insert this between the selection checkbox and the item label + * per CONTEXT discretion note: `[🔒]` / `[x]` / `⚠` / name. + */ +export function renderMcpWarningPrefix(item: unknown, opts: { ascii: boolean }): string { + if (item === null || typeof item !== 'object') return ''; + if (!isMultiConfig(item as { kind?: string; configRefs?: string[] })) return ''; + return `${warningGlyph(opts.ascii)} `; +} + +/** + * Format the Also-in list per D6-07: + * - length 0 or 1 → null (caller skips hint; single-config items get no hint) + * - length 2 or 3 → "a, b" / "a, b, c" + * - length > 3 → "a, b, … (N more)" where N = length - 2 + * + * Paths are emitted verbatim from the input — they were compressed via + * `presentPath` upstream in plan 01 (no raw `$HOME` leak). + */ +export function formatAlsoIn(configRefs: string[]): string | null { + if (configRefs.length <= 1) return null; + if (configRefs.length <= 3) return configRefs.join(', '); + const remaining = configRefs.length - 2; + return `${configRefs[0]}, ${configRefs[1]}, … (${remaining} more)`; +} + +/** + * Full below-cursor hint line for a multi-config MCP row. Two leading + * spaces match the indent used by `protectedHintLine` so the two hint + * variants align visually in the shared slot. Returns `null` for non-MCP + * or single-config items (caller falls through to the next hint branch). + */ +export function alsoInHintLine(item: unknown, opts: { ascii: boolean }): string | null { + void opts.ascii; + if (item === null || typeof item !== 'object') return null; + const typed = item as { kind?: string; configRefs?: string[] }; + if (!isMultiConfig(typed)) return null; + const refs = typed.configRefs as string[]; + const formatted = formatAlsoIn(refs); + if (formatted === null) return null; + return ` Also in: ${formatted}`; +} + +if (import.meta.vitest) { + const { describe, it, expect } = import.meta.vitest; + + describe('warningGlyph', () => { + it('returns Unicode glyph when ascii=false', () => { + expect(warningGlyph(false)).toBe('⚠'); + }); + + it('returns ASCII fallback when ascii=true', () => { + expect(warningGlyph(true)).toBe('!'); + }); + }); + + describe('isMultiConfig', () => { + it('returns false for non-MCP item', () => { + expect(isMultiConfig({ kind: 'agent', configRefs: ['a', 'b'] })).toBe(false); + }); + + it('returns false for MCP with single config', () => { + expect(isMultiConfig({ kind: 'mcp-server', configRefs: ['~/.claude.json'] })).toBe(false); + }); + + it('returns false for MCP with no configRefs', () => { + expect(isMultiConfig({ kind: 'mcp-server' })).toBe(false); + }); + + it('returns true for MCP with 2 configRefs', () => { + expect(isMultiConfig({ kind: 'mcp-server', configRefs: ['a', 'b'] })).toBe(true); + }); + + it('returns true for MCP with 5 configRefs', () => { + expect(isMultiConfig({ kind: 'mcp-server', configRefs: ['a', 'b', 'c', 'd', 'e'] })).toBe( + true, + ); + }); + + it('accepts InventoryItem-shaped input via `category` (bugfix 06-05)', () => { + // InventoryItem uses `category`, not `kind`. The picker passes + // `row.item.item` (an InventoryItem) to renderMcpWarningPrefix, so + // this branch MUST work end-to-end. + expect(isMultiConfig({ category: 'mcp-server', configRefs: ['a', 'b'] })).toBe(true); + expect(isMultiConfig({ category: 'agent', configRefs: ['a', 'b'] })).toBe(false); + }); + }); + + describe('renderMcpWarningPrefix', () => { + it('returns empty string for single-config MCP', () => { + expect( + renderMcpWarningPrefix( + { kind: 'mcp-server', configRefs: ['~/.claude.json'] }, + { ascii: false }, + ), + ).toBe(''); + }); + + it('returns "⚠ " for multi-config MCP in Unicode mode', () => { + expect( + renderMcpWarningPrefix({ kind: 'mcp-server', configRefs: ['a', 'b'] }, { ascii: false }), + ).toBe('⚠ '); + }); + + it('returns "! " for multi-config MCP in ASCII mode', () => { + expect( + renderMcpWarningPrefix({ kind: 'mcp-server', configRefs: ['a', 'b'] }, { ascii: true }), + ).toBe('! '); + }); + + it('returns empty string for non-object input', () => { + expect(renderMcpWarningPrefix(null, { ascii: false })).toBe(''); + expect(renderMcpWarningPrefix(undefined, { ascii: false })).toBe(''); + }); + + it('returns empty string for non-MCP item even with configRefs', () => { + expect( + renderMcpWarningPrefix({ kind: 'agent', configRefs: ['a', 'b'] }, { ascii: false }), + ).toBe(''); + }); + }); + + describe('formatAlsoIn', () => { + it('returns null for empty list', () => { + expect(formatAlsoIn([])).toBe(null); + }); + + it('returns null for single-config list', () => { + expect(formatAlsoIn(['~/.claude.json'])).toBe(null); + }); + + it('joins 2 refs with comma+space', () => { + expect(formatAlsoIn(['~/.claude.json', '.mcp.json'])).toBe('~/.claude.json, .mcp.json'); + }); + + it('joins 3 refs with comma+space (no truncation at boundary)', () => { + expect(formatAlsoIn(['a', 'b', 'c'])).toBe('a, b, c'); + }); + + it('truncates at length 4 → "(2 more)"', () => { + expect(formatAlsoIn(['a', 'b', 'c', 'd'])).toBe('a, b, … (2 more)'); + }); + + it('truncates at length 5 → "(3 more)"', () => { + expect(formatAlsoIn(['a', 'b', 'c', 'd', 'e'])).toBe('a, b, … (3 more)'); + }); + + it('truncates at length 10 → "(8 more)"', () => { + expect(formatAlsoIn(['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j'])).toBe( + 'a, b, … (8 more)', + ); + }); + }); + + describe('alsoInHintLine', () => { + it('returns null for non-MCP item', () => { + expect(alsoInHintLine({ kind: 'agent', configRefs: ['a', 'b'] }, { ascii: false })).toBe( + null, + ); + }); + + it('returns null for single-config MCP', () => { + expect( + alsoInHintLine({ kind: 'mcp-server', configRefs: ['~/.claude.json'] }, { ascii: false }), + ).toBe(null); + }); + + it('returns null for MCP without configRefs', () => { + expect(alsoInHintLine({ kind: 'mcp-server' }, { ascii: false })).toBe(null); + }); + + it('returns null for non-object input', () => { + expect(alsoInHintLine(null, { ascii: false })).toBe(null); + expect(alsoInHintLine(42, { ascii: false })).toBe(null); + }); + + it('formats 2-config MCP with 2-space indent matching protection hint', () => { + expect( + alsoInHintLine( + { kind: 'mcp-server', configRefs: ['~/.claude.json', '.mcp.json'] }, + { ascii: false }, + ), + ).toBe(' Also in: ~/.claude.json, .mcp.json'); + }); + + it('formats 3-config MCP without truncation', () => { + expect( + alsoInHintLine({ kind: 'mcp-server', configRefs: ['a', 'b', 'c'] }, { ascii: false }), + ).toBe(' Also in: a, b, c'); + }); + + it('truncates 5-config MCP hint to "(3 more)"', () => { + expect( + alsoInHintLine( + { kind: 'mcp-server', configRefs: ['a', 'b', 'c', 'd', 'e'] }, + { ascii: false }, + ), + ).toBe(' Also in: a, b, … (3 more)'); + }); + + it('"Also in:" text present regardless of ascii mode (D6-21)', () => { + const item = { kind: 'mcp-server', configRefs: ['a', 'b'] }; + expect(alsoInHintLine(item, { ascii: true })).toContain('Also in:'); + expect(alsoInHintLine(item, { ascii: false })).toContain('Also in:'); + }); + }); +} diff --git a/packages/terminal/src/tui/_preflight-copy.ts b/packages/terminal/src/tui/_preflight-copy.ts new file mode 100644 index 0000000..18aa79c --- /dev/null +++ b/packages/terminal/src/tui/_preflight-copy.ts @@ -0,0 +1,399 @@ +/** + * Shared preflight copy helpers for the "Claude Code is running" gate. + * + * renderRunningProcessMessage — pure function; returns a formatted stderr string. + * Caller writes to process.stderr verbatim. + * runPreflightRetryLoop — retry-until-clear wrapper around detectFn; shared + * by the entry preflight (ghost.ts, plan 03.2-04) + * and the bust-time retry branch. + * + * Self-invocation (initialResult.selfInvocation===true) never retries — closing + * the parent Claude Code session would kill ccaudit itself. External-pids case + * retries until detect clears or the user cancels. + * + * Zero new runtime deps: @clack/prompts is already bundled via confirmation.ts; + * colorize is already used by tabbed-picker.ts. + */ +import { confirm, isCancel } from '@clack/prompts'; +import { colorize } from '../color.ts'; + +// --------------------------------------------------------------------------- +// Public types +// --------------------------------------------------------------------------- + +export interface RunningProcessInput { + selfInvocation: boolean; + pids: readonly number[]; +} + +export type PreflightPhase = 'entry' | 'bust'; + +export type PreflightRetryOutcome = + | { status: 'clear' } + | { status: 'cancelled' } + | { status: 'spawn-failed'; error: string }; + +/** + * Dependency injection surface for testability. isCancel is typed as + * `(value: unknown) => boolean` (not a type predicate) so that vi.fn() + * mocks can satisfy the interface without declaring a type predicate. + * Mirrors ClackConfirmDep in confirmation.ts. + */ +export interface ClackConfirmDep { + confirm: (opts: { message: string; initialValue?: boolean }) => Promise; + isCancel: (value: unknown) => boolean; +} + +export interface PreflightLoopInput { + detectFn: () => Promise< + | { status: 'ok'; processes: Array<{ pid: number; command: string }> } + | { status: 'spawn-failed'; error: string } + >; + phase: PreflightPhase; + initialResult?: RunningProcessInput; + _clack?: ClackConfirmDep; +} + +// --------------------------------------------------------------------------- +// renderRunningProcessMessage (pure) +// --------------------------------------------------------------------------- + +/** + * Byte-for-byte extraction of the copy block that used to live inline at + * apps/ccaudit/src/cli/commands/ghost.ts:1229-1248 (case 'running-process'). + * The existing console.error calls appended '\n' per line; joining with '\n' + * plus a single trailing '\n' produces byte-identical stderr. + */ +export function renderRunningProcessMessage(input: RunningProcessInput): string { + const lines: string[] = []; + if (input.selfInvocation) { + lines.push("You're running ccaudit from inside a Claude Code session."); + lines.push(''); + lines.push('Open a separate terminal window and run the command from there.'); + lines.push( + "ccaudit cannot modify Claude Code's configuration while Claude Code is reading it.", + ); + } else { + lines.push(`Claude Code is still running (pids: ${input.pids.join(', ')}).`); + lines.push(''); + lines.push(colorize.red("Don't cross the streams!")); + lines.push(''); + lines.push('Close all Claude Code instances before running --dangerously-bust-ghosts.'); + lines.push('Modifying configuration while Claude Code is active can corrupt session state.'); + } + return lines.join('\n') + '\n'; +} + +// --------------------------------------------------------------------------- +// runPreflightRetryLoop (shared by entry and bust-time call sites) +// --------------------------------------------------------------------------- + +/** + * Render the preflight copy, prompt the user to retry, re-detect on retry. + * + * Loop semantics: + * - self-invocation (initialResult.selfInvocation===true): render copy, return + * cancelled. DO NOT call detectFn, DO NOT prompt — retry would require + * closing the parent Claude session which would kill ccaudit itself. + * - no initialResult: call detectFn; if status==='spawn-failed' → return that; + * if processes===[] → return 'clear'; otherwise enter retry loop. + * - initialResult with selfInvocation===false: enter retry loop with the + * caller's existing detection (no extra detectFn call this iteration). + * - retry loop: render copy → prompt → on yes, call detectFn and repeat; on + * no/cancel, return 'cancelled'; on spawn-failed, return that. + */ +export async function runPreflightRetryLoop( + input: PreflightLoopInput, +): Promise { + const { detectFn, phase, initialResult } = input; + const clack: ClackConfirmDep = input._clack ?? { confirm, isCancel }; + + // Self-invocation short-circuit: closing the parent session kills ccaudit. + if (initialResult?.selfInvocation) { + process.stderr.write(renderRunningProcessMessage(initialResult)); + return { status: 'cancelled' }; + } + + let current: RunningProcessInput | null = initialResult ?? null; + const promptMsg = + phase === 'entry' + ? "Retry preflight? (I've closed all Claude Code windows)" + : "Retry bust? (I've closed all Claude Code windows)"; + + while (true) { + if (current === null) { + const detected = await detectFn(); + if (detected.status === 'spawn-failed') { + return { status: 'spawn-failed', error: detected.error }; + } + if (detected.processes.length === 0) { + return { status: 'clear' }; + } + current = { + selfInvocation: false, + pids: detected.processes.map((p) => p.pid), + }; + } + process.stderr.write(renderRunningProcessMessage(current)); + const result = await clack.confirm({ message: promptMsg, initialValue: false }); + if (clack.isCancel(result) || result === false) { + return { status: 'cancelled' }; + } + // result === true → user says Claude is closed. Reset and re-detect. + current = null; + } +} + +// --------------------------------------------------------------------------- +// In-source tests +// --------------------------------------------------------------------------- + +if (import.meta.vitest) { + const { describe, it, expect, vi } = import.meta.vitest; + + describe('renderRunningProcessMessage', () => { + it('self-invocation: emits the in-session copy with trailing newline', () => { + const out = renderRunningProcessMessage({ selfInvocation: true, pids: [] }); + expect(out).toContain("You're running ccaudit from inside a Claude Code session."); + expect(out).toContain('Open a separate terminal window and run the command from there.'); + expect(out).toContain( + "ccaudit cannot modify Claude Code's configuration while Claude Code is reading it.", + ); + expect(out.endsWith('\n')).toBe(true); + }); + + it('external pids: emits pid list + "Don\'t cross the streams!" + actionable sentences', () => { + const out = renderRunningProcessMessage({ selfInvocation: false, pids: [42, 43] }); + expect(out).toContain('Claude Code is still running (pids: 42, 43).'); + expect(out).toContain("Don't cross the streams!"); + expect(out).toContain( + 'Close all Claude Code instances before running --dangerously-bust-ghosts.', + ); + expect(out).toContain( + 'Modifying configuration while Claude Code is active can corrupt session state.', + ); + }); + + it('single-pid case renders "pids: 42." (not "pids: 42, .")', () => { + const out = renderRunningProcessMessage({ selfInvocation: false, pids: [42] }); + expect(out).toContain('Claude Code is still running (pids: 42).'); + }); + + it('matches a deterministic inline snapshot for selfInvocation=true (SC5 drift guard)', () => { + // SC5: the byte-for-byte contract. Any future reformatter that changes + // these bytes will break the interactive path's stderr match against + // the non-interactive --dangerously-bust-ghosts path. + const out = renderRunningProcessMessage({ selfInvocation: true, pids: [] }); + expect(out).toMatchInlineSnapshot(` + "You're running ccaudit from inside a Claude Code session. + + Open a separate terminal window and run the command from there. + ccaudit cannot modify Claude Code's configuration while Claude Code is reading it. + " + `); + }); + }); + + describe('runPreflightRetryLoop', () => { + it('returns { status: clear } when detectFn returns zero processes', async () => { + const detectFn = vi.fn().mockResolvedValue({ status: 'ok' as const, processes: [] }); + const fakeClack: ClackConfirmDep = { + confirm: vi.fn(), + isCancel: vi.fn(() => false), + }; + const out = await runPreflightRetryLoop({ detectFn, phase: 'entry', _clack: fakeClack }); + expect(out).toEqual({ status: 'clear' }); + expect(fakeClack.confirm).not.toHaveBeenCalled(); + }); + + it('returns { status: spawn-failed, error } when detectFn fails', async () => { + const detectFn = vi + .fn() + .mockResolvedValue({ status: 'spawn-failed' as const, error: 'ENOENT' }); + const fakeClack: ClackConfirmDep = { + confirm: vi.fn(), + isCancel: vi.fn(() => false), + }; + const out = await runPreflightRetryLoop({ detectFn, phase: 'entry', _clack: fakeClack }); + expect(out).toEqual({ status: 'spawn-failed', error: 'ENOENT' }); + }); + + it('self-invocation initialResult short-circuits without calling detectFn', async () => { + const detectFn = vi.fn(); + const fakeClack: ClackConfirmDep = { + confirm: vi.fn(), + isCancel: vi.fn(() => false), + }; + const out = await runPreflightRetryLoop({ + detectFn, + phase: 'bust', + initialResult: { selfInvocation: true, pids: [99] }, + _clack: fakeClack, + }); + expect(out).toEqual({ status: 'cancelled' }); + expect(detectFn).not.toHaveBeenCalled(); + expect(fakeClack.confirm).not.toHaveBeenCalled(); + }); + + it('external-pids with user-cancel (confirm→false) returns { status: cancelled } after 1 prompt', async () => { + const detectFn = vi.fn(); + const fakeClack: ClackConfirmDep = { + confirm: vi.fn().mockResolvedValueOnce(false), + isCancel: vi.fn(() => false), + }; + const out = await runPreflightRetryLoop({ + detectFn, + phase: 'entry', + initialResult: { selfInvocation: false, pids: [42] }, + _clack: fakeClack, + }); + expect(out).toEqual({ status: 'cancelled' }); + expect(fakeClack.confirm).toHaveBeenCalledTimes(1); + expect(detectFn).not.toHaveBeenCalled(); + }); + + it('external-pids with isCancel true (user Esc) returns { status: cancelled }', async () => { + const sym = Symbol('cancel'); + const detectFn = vi.fn(); + const fakeClack: ClackConfirmDep = { + confirm: vi.fn().mockResolvedValueOnce(sym), + isCancel: vi.fn((v: unknown) => v === sym), + }; + const out = await runPreflightRetryLoop({ + detectFn, + phase: 'entry', + initialResult: { selfInvocation: false, pids: [7] }, + _clack: fakeClack, + }); + expect(out).toEqual({ status: 'cancelled' }); + }); + + it('retry until clear: first detect dirty, user confirms, second detect clear → { status: clear }', async () => { + const detectFn = vi + .fn() + .mockResolvedValueOnce({ + status: 'ok' as const, + processes: [{ pid: 42, command: 'claude' }], + }) + .mockResolvedValueOnce({ status: 'ok' as const, processes: [] }); + const fakeClack: ClackConfirmDep = { + confirm: vi.fn().mockResolvedValueOnce(true), + isCancel: vi.fn(() => false), + }; + const out = await runPreflightRetryLoop({ detectFn, phase: 'entry', _clack: fakeClack }); + expect(out).toEqual({ status: 'clear' }); + // First detect runs once at loop entry; after confirm→true, detect runs again. + expect(detectFn).toHaveBeenCalledTimes(2); + expect(fakeClack.confirm).toHaveBeenCalledTimes(1); + }); + + it('uses the "Retry preflight?" message for phase=entry', async () => { + const detectFn = vi.fn(); + const confirmMock = vi.fn().mockResolvedValueOnce(false); + const fakeClack: ClackConfirmDep = { + confirm: confirmMock, + isCancel: vi.fn(() => false), + }; + await runPreflightRetryLoop({ + detectFn, + phase: 'entry', + initialResult: { selfInvocation: false, pids: [1] }, + _clack: fakeClack, + }); + const callArgs = confirmMock.mock.calls[0]?.[0] as { + message: string; + initialValue?: boolean; + }; + expect(callArgs.message).toBe("Retry preflight? (I've closed all Claude Code windows)"); + expect(callArgs.initialValue).toBe(false); + }); + + it('uses the "Retry bust?" message for phase=bust', async () => { + const detectFn = vi.fn(); + const confirmMock = vi.fn().mockResolvedValueOnce(false); + const fakeClack: ClackConfirmDep = { + confirm: confirmMock, + isCancel: vi.fn(() => false), + }; + await runPreflightRetryLoop({ + detectFn, + phase: 'bust', + initialResult: { selfInvocation: false, pids: [2] }, + _clack: fakeClack, + }); + const callArgs = confirmMock.mock.calls[0]?.[0] as { message: string }; + expect(callArgs.message).toBe("Retry bust? (I've closed all Claude Code windows)"); + }); + + it('WR-01: on retry iteration 2, rendered pids come from detectFn output, not stale initialResult', async () => { + // Caller supplies initialResult with pids=[999]. User confirms retry. + // detectFn returns different pids ([888]). The re-rendered copy must + // include 888, not 999 — the fresh detection supersedes the stale + // initialResult on every retry iteration. + const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); + try { + const detectFn = vi + .fn() + .mockResolvedValueOnce({ + status: 'ok' as const, + processes: [{ pid: 888, command: 'claude' }], + }) + .mockResolvedValueOnce({ status: 'ok' as const, processes: [] }); + const fakeClack: ClackConfirmDep = { + confirm: vi.fn().mockResolvedValueOnce(true), + isCancel: vi.fn(() => false), + }; + const out = await runPreflightRetryLoop({ + detectFn, + phase: 'entry', + initialResult: { selfInvocation: false, pids: [999] }, + _clack: fakeClack, + }); + expect(out).toEqual({ status: 'clear' }); + // Iteration 1 renders with initialResult pids=[999]. + // Iteration 2 renders with detectFn's fresh pids=[888]. + const writes = stderrSpy.mock.calls + .map((call) => call[0]) + .filter((arg): arg is string => typeof arg === 'string'); + const iter1 = writes.find((w) => w.includes('pids: 999')); + const iter2 = writes.find((w) => w.includes('pids: 888')); + expect(iter1).toBeDefined(); + expect(iter2).toBeDefined(); + // Crucially: after retry, NO render should still reference the stale 999. + const staleAfterRetry = writes + .slice(writes.indexOf(iter1!) + 1) + .some((w) => w.includes('pids: 999')); + expect(staleAfterRetry).toBe(false); + } finally { + stderrSpy.mockRestore(); + } + }); + + it('W2: emits the running-process copy exactly once per loop iteration (single stderr write)', async () => { + // Spy on process.stderr.write to count invocations during a single-prompt iteration. + // Cancel on the first prompt → should see exactly ONE stderr write (one render per iteration). + const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); + try { + const detectFn = vi.fn(); + const fakeClack: ClackConfirmDep = { + confirm: vi.fn().mockResolvedValueOnce(false), + isCancel: vi.fn(() => false), + }; + await runPreflightRetryLoop({ + detectFn, + phase: 'entry', + initialResult: { selfInvocation: false, pids: [99] }, + _clack: fakeClack, + }); + // Count writes whose content contains the identifying "Don't cross the streams!" phrase. + const relevant = stderrSpy.mock.calls.filter((call) => { + const arg = call[0]; + return typeof arg === 'string' && arg.includes("Don't cross the streams!"); + }); + expect(relevant.length).toBe(1); + } finally { + stderrSpy.mockRestore(); + } + }); + }); +} diff --git a/packages/terminal/src/tui/_protection-render.ts b/packages/terminal/src/tui/_protection-render.ts new file mode 100644 index 0000000..cd9ffaa --- /dev/null +++ b/packages/terminal/src/tui/_protection-render.ts @@ -0,0 +1,158 @@ +/** + * Phase 6 Plan 02: Pure render helpers for framework-protection UX in the + * tabbed picker. Protected rows render dim with a lock glyph (`[🔒]` or ASCII + * `[L]`); the below-cursor hint shows the canonical `protection.reason` + * string emitted by the scanner (plan 06-01) verbatim — picker MUST NOT + * reconstruct the wording. + * + * Invariants (D6-04 / D6-05 / D6-20): + * - Glyph ALWAYS accompanies dim. No color-only protection signal. + * - ASCII fallback via explicit `{ ascii }` option (caller decides once + * at TUI entry via `shouldUseAscii()`). + * - Dim SGR uses `22` reset (not `0`) so other attributes survive in + * composed rows. + * - `protectedHintLine` returns `item.protection.reason` verbatim — + * the scanner is the single source of truth for the string. + */ + +/** + * Returns the lock glyph: `[🔒]` in Unicode mode, `[L]` in ASCII mode. + */ +export function protectedGlyph(ascii: boolean): string { + return ascii ? '[L]' : '[🔒]'; +} + +/** + * Returns the dim-wrapped row prefix for a protected item, matching the + * D6-04 row format ` [🔒] …` (two leading spaces then glyph then trailing + * space). Returns empty string for unprotected items. + * + * The prefix is dim-wrapped so it composes with the caller's own content + * (row label) via `dimLine`. + */ +export function renderProtectedPrefix( + item: { protection?: unknown }, + opts: { ascii: boolean }, +): string { + if (item.protection === undefined) return ''; + const glyph = protectedGlyph(opts.ascii); + return ` ${glyph} `; +} + +/** + * Wrap text in the standard SGR "dim" sequence (`\x1b[2m…\x1b[22m`). The + * `22` reset is preferred over `0` so other attributes composed into the + * same row (bold, color) are preserved when the dim span ends. + * + * When `colorless: true` (e.g. test fixtures, `NO_COLOR`-style contexts), + * returns the text unchanged — the caller's lock-glyph prefix carries the + * protection signal in that case (D6-20: never color-alone). + * + * The `ascii` option is accepted for API symmetry but does not change + * the SGR sequence — plain terminals that can't render Unicode can still + * interpret SGR, and the caller's glyph choice already handles the ASCII + * fallback. + */ +export function dimLine(text: string, opts: { ascii: boolean; colorless?: boolean }): string { + void opts.ascii; + if (opts.colorless === true) return text; + return `\x1b[2m${text}\x1b[22m`; +} + +/** + * Returns the below-cursor hint line for a focused protected item, prefixed + * with two spaces to align with the row format. Returns `null` when the + * item is unprotected so callers can fall through to Phase 5 help/filter + * hint rendering (hint slot is shared — D6-05). + * + * The string is `item.protection.reason` verbatim — the scanner is the + * single source of truth (plan 06-01). Do NOT template or reconstruct. + */ +export function protectedHintLine( + item: { protection?: { reason: string } }, + opts: { ascii: boolean }, +): string | null { + void opts.ascii; + if (item.protection === undefined) return null; + return ` ${item.protection.reason}`; +} + +// ─────────────────────────── In-source tests ─────────────────────────── + +if (import.meta.vitest) { + const { describe, it, expect } = import.meta.vitest; + + describe('protectedGlyph', () => { + it('returns [🔒] in Unicode mode', () => { + expect(protectedGlyph(false)).toBe('[🔒]'); + }); + it('returns [L] in ASCII mode', () => { + expect(protectedGlyph(true)).toBe('[L]'); + }); + }); + + describe('renderProtectedPrefix', () => { + it('returns empty string for unprotected item', () => { + expect(renderProtectedPrefix({}, { ascii: false })).toBe(''); + expect(renderProtectedPrefix({ protection: undefined }, { ascii: false })).toBe(''); + }); + + it('returns " [🔒] " prefix for a protected item in Unicode mode', () => { + const item = { + protection: { framework: 'gsd', total: 2, ghostCount: 1, reason: 'x' }, + }; + expect(renderProtectedPrefix(item, { ascii: false })).toBe(' [🔒] '); + }); + + it('returns " [L] " prefix for a protected item in ASCII mode', () => { + const item = { + protection: { framework: 'gsd', total: 2, ghostCount: 1, reason: 'x' }, + }; + expect(renderProtectedPrefix(item, { ascii: true })).toBe(' [L] '); + }); + }); + + describe('dimLine', () => { + it('wraps text in SGR dim sequence with 22 reset (not 0)', () => { + const out = dimLine('hello', { ascii: false }); + expect(out).toBe('\x1b[2mhello\x1b[22m'); + expect(out).not.toContain('\x1b[0m'); + }); + + it('returns raw text when colorless: true (glyph carries the signal)', () => { + expect(dimLine('hello', { ascii: false, colorless: true })).toBe('hello'); + expect(dimLine('hello', { ascii: true, colorless: true })).toBe('hello'); + }); + + it('dim sequence unchanged in ASCII mode (SGR works on plain terminals)', () => { + expect(dimLine('x', { ascii: true })).toBe('\x1b[2mx\x1b[22m'); + }); + }); + + describe('protectedHintLine', () => { + it('returns null for unprotected item', () => { + expect(protectedHintLine({}, { ascii: false })).toBeNull(); + expect(protectedHintLine({ protection: undefined }, { ascii: false })).toBeNull(); + }); + + it('returns two-space prefixed reason verbatim for protected item', () => { + const reason = 'Part of GSD (2 used, 1 ghost). --force-partial to override.'; + const line = protectedHintLine({ protection: { reason } }, { ascii: false }); + expect(line).toBe(` ${reason}`); + }); + + it('passes reason verbatim without template re-construction (scanner owns wording)', () => { + const reason = + 'Custom scanner-provided reason string with punctuation: "quotes", parens, ellipsis…'; + const line = protectedHintLine({ protection: { reason } }, { ascii: true }); + expect(line).toBe(` ${reason}`); + }); + + it('ASCII option does not alter the returned reason string', () => { + const reason = 'some reason'; + expect(protectedHintLine({ protection: { reason } }, { ascii: true })).toBe( + protectedHintLine({ protection: { reason } }, { ascii: false }), + ); + }); + }); +} diff --git a/packages/terminal/src/tui/_tab-bar.ts b/packages/terminal/src/tui/_tab-bar.ts new file mode 100644 index 0000000..15e62b0 --- /dev/null +++ b/packages/terminal/src/tui/_tab-bar.ts @@ -0,0 +1,215 @@ +/** + * Pure tab-bar renderer for the tabbed picker (D3.1-09). + * + * Renders a single-line horizontal tab bar such as: + * ` AGENTS │ SKILLS │ MCP SERVERS │ MEMORY ` + * + * Styling: + * - Active tab: inverse + bold + * - Inactive tabs: dim + * + * Separator: + * - Unicode: ' │ ' (U+2502 with surrounding spaces) + * - ASCII: ' | ' + * + * Truncation: + * - If rendered visible length exceeds `terminalCols`, trailing tabs are + * dropped and an ellipsis is appended (`…` Unicode, `...` ASCII). + * Numeric keys (D3.1-02) remain the escape hatch for reaching truncated tabs. + * + * This module is pure — no @clack/core, no process.stdout side effects. + */ +import pc from 'picocolors'; + +export interface TabDescriptor { + /** Uppercase label from CATEGORY_LABEL (e.g. 'AGENTS', 'MCP SERVERS'). */ + label: string; +} + +/** + * Render the tab bar as a single styled string. + * + * Returns the empty string when `tabs.length === 0` — the caller decides + * whether to render a blank line or omit the bar. + */ +export function renderTabBar(input: { + tabs: readonly TabDescriptor[]; + activeIndex: number; + useAscii: boolean; + terminalCols: number; +}): string { + const { tabs, activeIndex, useAscii, terminalCols } = input; + if (tabs.length === 0) return ''; + + const sep = useAscii ? ' | ' : ' │ '; + const sepVisible = useAscii ? 3 : 3; // both ' | ' and ' │ ' are 3 visible cols + const ellipsis = useAscii ? '...' : '…'; + const ellipsisSuffixVisible = 1 + ellipsis.length; // leading space + ellipsis + + // Pre-style each tab label. + const styled = tabs.map((t, i) => { + if (i === activeIndex) return pc.inverse(pc.bold(t.label)); + return pc.dim(t.label); + }); + const labelVisible = tabs.map((t) => t.label.length); + + // Plain tally of visible width as we go. We build an accepted prefix and + // return either the full bar (if everything fits) or (prefix + ' ' + ellipsis) + // when truncation happens. + let out = ''; + let visibleSoFar = 0; + + for (let i = 0; i < styled.length; i++) { + const segmentVisible = (i === 0 ? 0 : sepVisible) + labelVisible[i]!; + // Remaining tabs after this one (i+1..end). If any remain, we must reserve + // room for an ellipsis suffix in case they don't fit. + const moreAfter = i < styled.length - 1; + const reserve = moreAfter ? ellipsisSuffixVisible : 0; + + if (visibleSoFar + segmentVisible > terminalCols) { + // This tab doesn't fit — truncate with ellipsis (if we have anything + // accepted). If nothing is accepted yet, return just the ellipsis. + if (out === '') return ellipsis; + return out + ' ' + ellipsis; + } + + // Optimistically accept this tab. + const tentative = out === '' ? styled[i]! : out + sep + styled[i]!; + const tentativeVisible = visibleSoFar + segmentVisible; + + // Edge case: accepting this tab leaves no room for the ellipsis suffix + // needed to signal remaining tabs. If there's another tab after this one + // AND that tab would NOT fit, we must NOT accept this tab as the last + // visible — back off and emit ellipsis now. + if (moreAfter) { + const nextVisible = sepVisible + labelVisible[i + 1]!; + const wouldNextFit = tentativeVisible + nextVisible <= terminalCols; + const canFitEllipsisAfter = tentativeVisible + ellipsisSuffixVisible <= terminalCols; + if (!wouldNextFit && !canFitEllipsisAfter) { + // Accepting this tab traps us — can't show next tab and can't fit an + // ellipsis after it. So do NOT accept; emit ellipsis from current + // accepted prefix (if any). + if (out === '') { + // First tab is too wide even with ellipsis reserve — emit just ellipsis. + return ellipsis; + } + return out + ' ' + ellipsis; + } + } + + out = tentative; + visibleSoFar = tentativeVisible; + void reserve; // reserve is encoded in the check above + } + return out; +} + +// --------------------------------------------------------------------------- +// In-source tests +// --------------------------------------------------------------------------- + +if (import.meta.vitest) { + const { describe, it, expect } = import.meta.vitest; + + // ANSI SGR escape pattern used to strip picocolors styling for width math. + // The control character \x1b is intentional. + // eslint-disable-next-line no-control-regex + const ANSI_STRIP = /\x1b\[[0-9;]*m/g; + const stripAnsi = (s: string): string => s.replace(ANSI_STRIP, ''); + + describe('renderTabBar', () => { + it('4 Unicode tabs that all fit: output contains │ separator and no …', () => { + const tabs = [ + { label: 'AGENTS' }, + { label: 'SKILLS' }, + { label: 'MCP' }, + { label: 'MEMORY' }, + ]; + const out = renderTabBar({ tabs, activeIndex: 0, useAscii: false, terminalCols: 100 }); + expect(out).toContain('│'); + expect(out).not.toContain('…'); + // All four labels are present when there's room. + const plain = stripAnsi(out); + expect(plain).toContain('AGENTS'); + expect(plain).toContain('SKILLS'); + expect(plain).toContain('MCP'); + expect(plain).toContain('MEMORY'); + }); + + it('4 ASCII tabs that all fit: output contains | separator (not │), no ...', () => { + const tabs = [ + { label: 'AGENTS' }, + { label: 'SKILLS' }, + { label: 'MCP' }, + { label: 'MEMORY' }, + ]; + const out = renderTabBar({ tabs, activeIndex: 0, useAscii: true, terminalCols: 100 }); + expect(out).not.toContain('│'); + expect(out).toContain(' | '); + expect(out).not.toContain('...'); + }); + + it('6 Unicode tabs at terminalCols=40 are truncated with … ellipsis', () => { + const tabs = [ + { label: 'AGENTS' }, + { label: 'SKILLS' }, + { label: 'MCP SERVERS' }, + { label: 'MEMORY' }, + { label: 'COMMANDS' }, + { label: 'HOOKS' }, + ]; + const out = renderTabBar({ tabs, activeIndex: 0, useAscii: false, terminalCols: 40 }); + expect(out).toContain('…'); + const plain = stripAnsi(out); + // Total plain text must respect the terminalCols budget. + expect(plain.length).toBeLessThanOrEqual(40); + // First tab must fit; later tabs may be dropped. + expect(plain).toContain('AGENTS'); + expect(plain).not.toContain('HOOKS'); + }); + + it('6 ASCII tabs at terminalCols=40 are truncated with ... ellipsis', () => { + const tabs = [ + { label: 'AGENTS' }, + { label: 'SKILLS' }, + { label: 'MCP SERVERS' }, + { label: 'MEMORY' }, + { label: 'COMMANDS' }, + { label: 'HOOKS' }, + ]; + const out = renderTabBar({ tabs, activeIndex: 0, useAscii: true, terminalCols: 40 }); + expect(out).toContain('...'); + expect(out).not.toContain('…'); + expect(stripAnsi(out).length).toBeLessThanOrEqual(40); + }); + + it('activeIndex=2 places the 3rd tab label after the second separator', () => { + // Note: picocolors honors NO_COLOR in the test environment, so we cannot + // reliably assert on ANSI codes. Instead, verify the active tab label + // appears in the stripped output after two separators (one before each of + // tab 1 and tab 2). This confirms tab 2 (activeIndex) is rendered in the + // correct position — styling happens via pc.inverse/pc.bold at runtime. + const tabs = [{ label: 'A' }, { label: 'B' }, { label: 'CCC' }, { label: 'D' }]; + const out = renderTabBar({ tabs, activeIndex: 2, useAscii: true, terminalCols: 100 }); + const plain = stripAnsi(out); + // 'CCC' appears after two ' | ' separators (index 2 in a 4-tab bar). + expect(plain).toMatch(/A \| B \| CCC/); + }); + + it('empty tabs array returns empty string', () => { + const out = renderTabBar({ tabs: [], activeIndex: 0, useAscii: false, terminalCols: 100 }); + expect(out).toBe(''); + }); + + it('visibleLength helper (inline): truncation respects terminalCols for short budget', () => { + // 3 x 8-char labels + 3-col separators = 8 + 3 + 8 + 3 + 8 = 30 cols for all. + // With terminalCols=20, we can only fit AAAAAAAA (8) + ' | ' (3) + BBBBBBBB (8) = 19. + // Next tab doesn't fit; emit " ..." suffix if it fits within budget. + const tabs = [{ label: 'AAAAAAAA' }, { label: 'BBBBBBBB' }, { label: 'CCCCCCCC' }]; + const out = renderTabBar({ tabs, activeIndex: 0, useAscii: true, terminalCols: 20 }); + expect(stripAnsi(out).length).toBeLessThanOrEqual(20); + expect(out).toContain('AAAAAAAA'); + expect(out).toContain('...'); + }); + }); +} diff --git a/packages/terminal/src/tui/_tui-mode.ts b/packages/terminal/src/tui/_tui-mode.ts new file mode 100644 index 0000000..0ed2d45 --- /dev/null +++ b/packages/terminal/src/tui/_tui-mode.ts @@ -0,0 +1,399 @@ +/** + * TTY detection and output-mode suppression guards (D-06, D-07, D-23). + * + * Pure module — accepts all relevant inputs as a struct so that tests + * can inject synthetic values instead of reading process.stdin.isTTY + * or process.env directly. + * + * Plan 03 (ghost.ts) and Plan 04 (auto-open call site) collect these + * facts at CLI entry and pass them in via GuardInputs. + */ + +/** + * Result variants from checkTuiGuards. + * + * ok — TUI may proceed + * hard-error — `--interactive` + `--json`: print message, exit 2 + * fallback-dry-run — `--interactive` on non-TTY: stderr notice, run dry-run path + * refuse-narrow — terminal < 60 cols + `--interactive`: stderr message, exit 0 + * suppress-auto-open — auto-open prompt should be silently skipped + */ +export type TuiGuardMode = + | { kind: 'ok' } + | { kind: 'hard-error'; message: string; exitCode: 2 } + | { kind: 'fallback-dry-run'; reason: string } + | { kind: 'refuse-narrow'; cols: number; message: string } + | { kind: 'suppress-auto-open' }; + +/** + * D-23 full suppression matrix — all 6 flags must be wired by the caller. + * Do NOT add `?` to mode fields; every call site must pass all 6 explicitly. + */ +export interface GuardInputs { + mode: { + json: boolean; + csv: boolean; + quiet: boolean; + ci: boolean; + dryRun: boolean; // D-23 new: user asked for dry-run; don't nudge them to archive + dangerouslyBustGhosts: boolean; // D-23 new: non-interactive bust path is explicit; no nudge + }; + isTty: boolean; + ttyCols: number | undefined; + isExplicitInteractive: boolean; // true for `--interactive`/`-i`, false for auto-open check +} + +/** + * Guard function implementing 9-rule precedence (first match wins). + * + * Rules (in order): + * 1. json + explicit-interactive → hard-error (D-06) + * 2. auto-open + output-mode flag → suppress-auto-open (D-23 output flags) + * 3. auto-open + dryRun → suppress-auto-open (D-23 new) + * 4. auto-open + dangerouslyBust → suppress-auto-open (D-23 new) + * 5. non-TTY + explicit → fallback-dry-run (D-07) + * 6. non-TTY + auto-open → suppress-auto-open + * 7. narrow + explicit → refuse-narrow + * 8. narrow + auto-open → suppress-auto-open + * 9. default → ok + */ +export function checkTuiGuards(input: GuardInputs): TuiGuardMode { + const { mode, isTty, ttyCols, isExplicitInteractive } = input; + + // Rule 1: --interactive + --json is a hard error (D-06) + if (mode.json && isExplicitInteractive) { + return { + kind: 'hard-error', + message: 'Error: --interactive cannot be combined with --json.', + exitCode: 2, + }; + } + + // Rule 2: auto-open + output-mode flags silently suppressed (D-23) + if (!isExplicitInteractive && (mode.json || mode.csv || mode.quiet || mode.ci)) { + return { kind: 'suppress-auto-open' }; + } + + // Rule 3: auto-open + --dry-run silently suppressed (D-23 new) + if (!isExplicitInteractive && mode.dryRun) { + return { kind: 'suppress-auto-open' }; + } + + // Rule 4: auto-open + --dangerously-bust-ghosts silently suppressed (D-23 new) + if (!isExplicitInteractive && mode.dangerouslyBustGhosts) { + return { kind: 'suppress-auto-open' }; + } + + // Rule 5: non-TTY + explicit --interactive → fallback to dry-run (D-07) + if (!isTty && isExplicitInteractive) { + return { + kind: 'fallback-dry-run', + reason: 'No TTY detected — running in dry-run mode.', + }; + } + + // Rule 6: non-TTY + auto-open → suppress + if (!isTty && !isExplicitInteractive) { + return { kind: 'suppress-auto-open' }; + } + + // Rule 7: narrow terminal + explicit --interactive → refuse + if ((ttyCols ?? 80) < 60 && isExplicitInteractive) { + const cols = ttyCols ?? 0; + return { + kind: 'refuse-narrow', + cols, + message: `Terminal too narrow (need ≥60 cols, got ${cols}). Resize your terminal or use --dangerously-bust-ghosts non-interactively.`, + }; + } + + // Rule 8: narrow terminal + auto-open → suppress + if ((ttyCols ?? 80) < 60 && !isExplicitInteractive) { + return { kind: 'suppress-auto-open' }; + } + + // Rule 9: default — TUI may proceed + return { kind: 'ok' }; +} + +/** + * Convenience wrapper: returns true iff the auto-open check would pass (kind === 'ok'). + * Equivalent to `checkTuiGuards({ ...input, isExplicitInteractive: false }).kind === 'ok'`. + */ +export function isTuiAvailable(input: Pick): boolean { + return checkTuiGuards({ ...input, isExplicitInteractive: false }).kind === 'ok'; +} + +if (import.meta.vitest) { + const { describe, it, expect } = import.meta.vitest; + + /** Factory for a full GuardInputs with sane defaults (TTY, wide, no flags). */ + function makeInput(overrides: Partial = {}): GuardInputs { + return { + mode: { + json: false, + csv: false, + quiet: false, + ci: false, + dryRun: false, + dangerouslyBustGhosts: false, + }, + isTty: true, + ttyCols: 120, + isExplicitInteractive: false, + ...overrides, + }; + } + + describe('checkTuiGuards', () => { + // Rule 1: --json + --interactive → hard-error + it('rule 1: json + explicit-interactive → hard-error', () => { + const result = checkTuiGuards( + makeInput({ + mode: { + json: true, + csv: false, + quiet: false, + ci: false, + dryRun: false, + dangerouslyBustGhosts: false, + }, + isExplicitInteractive: true, + }), + ); + expect(result.kind).toBe('hard-error'); + expect((result as { kind: 'hard-error'; message: string; exitCode: 2 }).message).toBe( + 'Error: --interactive cannot be combined with --json.', + ); + expect((result as { kind: 'hard-error'; message: string; exitCode: 2 }).exitCode).toBe(2); + }); + + // Rule 2: auto-open + json → suppress + it('rule 2a: auto-open + json → suppress-auto-open', () => { + const result = checkTuiGuards( + makeInput({ + mode: { + json: true, + csv: false, + quiet: false, + ci: false, + dryRun: false, + dangerouslyBustGhosts: false, + }, + isExplicitInteractive: false, + }), + ); + expect(result.kind).toBe('suppress-auto-open'); + }); + + it('rule 2b: auto-open + csv → suppress-auto-open', () => { + const result = checkTuiGuards( + makeInput({ + mode: { + json: false, + csv: true, + quiet: false, + ci: false, + dryRun: false, + dangerouslyBustGhosts: false, + }, + isExplicitInteractive: false, + }), + ); + expect(result.kind).toBe('suppress-auto-open'); + }); + + it('rule 2c: auto-open + quiet → suppress-auto-open', () => { + const result = checkTuiGuards( + makeInput({ + mode: { + json: false, + csv: false, + quiet: true, + ci: false, + dryRun: false, + dangerouslyBustGhosts: false, + }, + isExplicitInteractive: false, + }), + ); + expect(result.kind).toBe('suppress-auto-open'); + }); + + it('rule 2d: auto-open + ci → suppress-auto-open', () => { + const result = checkTuiGuards( + makeInput({ + mode: { + json: false, + csv: false, + quiet: false, + ci: true, + dryRun: false, + dangerouslyBustGhosts: false, + }, + isExplicitInteractive: false, + }), + ); + expect(result.kind).toBe('suppress-auto-open'); + }); + + // Rule 3: auto-open + dryRun → suppress (D-23 new) + it('rule 3: auto-open + dryRun=true → suppress-auto-open', () => { + const result = checkTuiGuards( + makeInput({ + mode: { + json: false, + csv: false, + quiet: false, + ci: false, + dryRun: true, + dangerouslyBustGhosts: false, + }, + isExplicitInteractive: false, + }), + ); + expect(result.kind).toBe('suppress-auto-open'); + }); + + // dryRun with explicit-interactive does NOT by itself suppress (falls through to ok) + it('rule 3 inverse: explicit-interactive + dryRun does NOT suppress by itself', () => { + const result = checkTuiGuards( + makeInput({ + mode: { + json: false, + csv: false, + quiet: false, + ci: false, + dryRun: true, + dangerouslyBustGhosts: false, + }, + isExplicitInteractive: true, + }), + ); + // No TTY rule applies (isTty=true), no narrow rule (cols=120) → ok + expect(result.kind).toBe('ok'); + }); + + // Rule 4: auto-open + dangerouslyBustGhosts → suppress (D-23 new) + it('rule 4: auto-open + dangerouslyBustGhosts=true → suppress-auto-open', () => { + const result = checkTuiGuards( + makeInput({ + mode: { + json: false, + csv: false, + quiet: false, + ci: false, + dryRun: false, + dangerouslyBustGhosts: true, + }, + isExplicitInteractive: false, + }), + ); + expect(result.kind).toBe('suppress-auto-open'); + }); + + // dangerouslyBustGhosts with explicit-interactive does NOT by itself suppress + it('rule 4 inverse: explicit-interactive + dangerouslyBustGhosts does NOT suppress by itself', () => { + const result = checkTuiGuards( + makeInput({ + mode: { + json: false, + csv: false, + quiet: false, + ci: false, + dryRun: false, + dangerouslyBustGhosts: true, + }, + isExplicitInteractive: true, + }), + ); + expect(result.kind).toBe('ok'); + }); + + // Rule 5: non-TTY + explicit-interactive → fallback-dry-run (D-07) + it('rule 5: non-TTY + explicit-interactive → fallback-dry-run', () => { + const result = checkTuiGuards(makeInput({ isTty: false, isExplicitInteractive: true })); + expect(result.kind).toBe('fallback-dry-run'); + expect((result as { kind: 'fallback-dry-run'; reason: string }).reason).toBe( + 'No TTY detected — running in dry-run mode.', + ); + }); + + // Rule 6: non-TTY + auto-open → suppress + it('rule 6: non-TTY + auto-open → suppress-auto-open', () => { + const result = checkTuiGuards(makeInput({ isTty: false, isExplicitInteractive: false })); + expect(result.kind).toBe('suppress-auto-open'); + }); + + // Rule 7: narrow + explicit-interactive → refuse-narrow + it('rule 7: narrow terminal + explicit-interactive → refuse-narrow', () => { + const result = checkTuiGuards(makeInput({ ttyCols: 50, isExplicitInteractive: true })); + expect(result.kind).toBe('refuse-narrow'); + const r = result as { kind: 'refuse-narrow'; cols: number; message: string }; + expect(r.cols).toBe(50); + expect(r.message).toContain('Terminal too narrow (need ≥60 cols'); + }); + + // Rule 8: narrow + auto-open → suppress + it('rule 8: narrow terminal + auto-open → suppress-auto-open', () => { + const result = checkTuiGuards(makeInput({ ttyCols: 50, isExplicitInteractive: false })); + expect(result.kind).toBe('suppress-auto-open'); + }); + + // Rule 9: default → ok + it('rule 9: all-clear → ok', () => { + const result = checkTuiGuards(makeInput({ isExplicitInteractive: true })); + expect(result.kind).toBe('ok'); + }); + }); + + describe('isTuiAvailable', () => { + it('returns true when auto-open check passes (no flags, TTY, wide)', () => { + const input = { + mode: { + json: false, + csv: false, + quiet: false, + ci: false, + dryRun: false, + dangerouslyBustGhosts: false, + }, + isTty: true, + ttyCols: 120, + }; + expect(isTuiAvailable(input)).toBe(true); + }); + + it('returns false when json flag set (auto-open would be suppressed)', () => { + const input = { + mode: { + json: true, + csv: false, + quiet: false, + ci: false, + dryRun: false, + dangerouslyBustGhosts: false, + }, + isTty: true, + ttyCols: 120, + }; + expect(isTuiAvailable(input)).toBe(false); + }); + + it('returns false when not a TTY', () => { + const input = { + mode: { + json: false, + csv: false, + quiet: false, + ci: false, + dryRun: false, + dangerouslyBustGhosts: false, + }, + isTty: false, + ttyCols: 120, + }; + expect(isTuiAvailable(input)).toBe(false); + }); + }); +} diff --git a/packages/terminal/src/tui/_viewport.ts b/packages/terminal/src/tui/_viewport.ts new file mode 100644 index 0000000..08a1b4b --- /dev/null +++ b/packages/terminal/src/tui/_viewport.ts @@ -0,0 +1,336 @@ +/** + * Pure viewport-windowing helpers for the tabbed picker (D3.1-05..D3.1-07). + * + * Single source of truth for the viewport-height formula — the literal + * `Math.max(8, (process.stdout.rows ?? 24) - 10)` lives here. + * + * No side effects; no @clack/core imports; no process.stdout access. + * Callers pass `stdoutRows` explicitly so this module stays testable. + */ + +export interface ViewportWindow { + /** The visible slice of rows. */ + slice: T[]; + /** Count of rows hidden above the slice (for "↑ N more" indicator). */ + aboveCount: number; + /** Count of rows hidden below the slice (for "↓ N more" indicator). */ + belowCount: number; + /** Absolute index of the first visible row (used to map cursor → slice-local index). */ + sliceStart: number; +} + +/** + * Compute viewport height per D3.1-05: `Math.max(8, (rows ?? 24) - 10)`. + * + * `rowsOverride` is a test-only escape hatch that bypasses the floor — used by + * regression fixtures that need to force a small viewport (e.g., viewport=5 to + * reproduce the long-list overflow bug on CI where stdout.rows is often 24). + */ +export function computeViewportHeight(opts: { + rowsOverride?: number; + stdoutRows?: number | undefined; + /** + * Phase 6 Plan 03 (D6-08): rows consumed by the top-of-TUI + * `--force-partial` banner. Defaults to 0 so Phase 3.1/4/5 call sites + * stay byte-identical. When `forcePartial` is ON the picker passes 1. + */ + bannerRows?: number; +}): number { + const bannerRows = opts.bannerRows ?? 0; + if (opts.rowsOverride !== undefined) return Math.max(1, opts.rowsOverride - bannerRows); + return Math.max(1, Math.max(8, (opts.stdoutRows ?? 24) - 10) - bannerRows); +} + +/** + * Return the viewport-windowed slice of rows, keeping the cursor inside the slice. + * + * Scroll strategy: centre-ish — when space allows, the cursor sits in the middle + * of the slice; at list boundaries the slice clamps to the edge. + * + * Algorithm: + * sliceStart = clamp(cursor - floor(viewportHeight / 2), 0, rows.length - viewportHeight) + * slice = rows.slice(sliceStart, sliceStart + viewportHeight) + * aboveCount = sliceStart + * belowCount = max(0, rows.length - sliceStart - viewportHeight) + * + * When rows.length <= viewportHeight, returns the full list with above/below=0. + */ +export function windowRows(input: { + rows: readonly T[]; + cursor: number; + viewportHeight: number; +}): ViewportWindow { + const { rows, cursor, viewportHeight } = input; + if (rows.length <= viewportHeight) { + return { slice: [...rows], aboveCount: 0, belowCount: 0, sliceStart: 0 }; + } + const half = Math.floor(viewportHeight / 2); + const maxStart = rows.length - viewportHeight; + const sliceStart = Math.max(0, Math.min(maxStart, cursor - half)); + const slice = rows.slice(sliceStart, sliceStart + viewportHeight); + return { + slice: [...slice], + aboveCount: sliceStart, + belowCount: Math.max(0, rows.length - sliceStart - viewportHeight), + sliceStart, + }; +} + +// --------------------------------------------------------------------------- +// Phase 9 Plan 02 (D3 / SC3) — scroll-state persistence reducer. +// +// The viewport is cursor-centered (see windowRows above), so preserving the +// scroll offset across filter/sort/tab events reduces to preserving the +// cursor across those events. We persist a per-tab cursor that the render +// path consumes; pre-filter cursor is saved so Esc restores it. +// --------------------------------------------------------------------------- + +export interface ScrollState { + /** Current cursor absolute index into the post-filter row list. */ + cursor: number; + /** Cursor saved on filter-on; restored on filter-off (Esc). null when no save. */ + savedCursorPreFilter: number | null; +} + +export function initialScrollState(): ScrollState { + return { cursor: 0, savedCursorPreFilter: null }; +} + +export type ScrollAction = + /** Arrow/j key — cursor + 1 (clamped). */ + | { type: 'cursorDown'; rowsLen: number } + /** Arrow/k key — cursor - 1 (clamped). */ + | { type: 'cursorUp' } + /** PgDn / End / Home — caller supplies the target index, reducer clamps. */ + | { type: 'cursorJump'; target: number; rowsLen: number } + /** User pressed `/` to open filter. Saves cursor for later restore. */ + | { type: 'filterOn' } + /** User pressed Esc to clear filter. Restores saved cursor (clamped). */ + | { type: 'filterOff'; rowsLen: number } + /** Filter query mutated — visible slice changed. Caller typically wants cursor=0. */ + | { type: 'filterQueryChange' } + /** `s` key — sort mode cycled within tab. Clamps cursor to rowsLen. */ + | { type: 'sortCycle'; rowsLen: number } + /** Tab switched — leaves this tab's state alone (cursor is per-tab anyway). */ + | { type: 'tabChange' }; + +/** + * Pure reducer for per-tab scroll state. Every return is a NEW object (never + * mutates the input). Clamping is performed anywhere `rowsLen` is known, so + * the render path can always pass the post-mutation state directly to + * `windowRows` without further bounds work. + */ +export function applyScroll(state: ScrollState, action: ScrollAction): ScrollState { + switch (action.type) { + case 'cursorDown': { + const max = Math.max(0, action.rowsLen - 1); + return { ...state, cursor: Math.min(max, state.cursor + 1) }; + } + case 'cursorUp': + return { ...state, cursor: Math.max(0, state.cursor - 1) }; + case 'cursorJump': { + const max = Math.max(0, action.rowsLen - 1); + return { ...state, cursor: Math.max(0, Math.min(max, action.target)) }; + } + case 'filterOn': + // Don't double-save if the caller is idempotent. + if (state.savedCursorPreFilter !== null) return state; + return { ...state, savedCursorPreFilter: state.cursor }; + case 'filterOff': { + const max = Math.max(0, action.rowsLen - 1); + const restored = state.savedCursorPreFilter ?? state.cursor; + return { + cursor: Math.max(0, Math.min(max, restored)), + savedCursorPreFilter: null, + }; + } + case 'filterQueryChange': + // The filtered-row list has changed under the cursor; the caller's + // convention (D5-01) is to reset to 0 so the new top row is visible. + return { ...state, cursor: 0 }; + case 'sortCycle': { + const max = Math.max(0, action.rowsLen - 1); + return { ...state, cursor: Math.max(0, Math.min(max, state.cursor)) }; + } + case 'tabChange': + // Per-tab state is stored separately; nothing to do here. Caller uses + // this action for symmetry / future-proofing (e.g., metric logging). + return state; + } +} + +// --------------------------------------------------------------------------- +// In-source tests +// --------------------------------------------------------------------------- + +if (import.meta.vitest) { + const { describe, it, expect } = import.meta.vitest; + + describe('computeViewportHeight', () => { + it('stdoutRows=24 → returns 14 (default terminal size)', () => { + expect(computeViewportHeight({ stdoutRows: 24 })).toBe(14); + }); + + it('stdoutRows=20 → returns 10', () => { + expect(computeViewportHeight({ stdoutRows: 20 })).toBe(10); + }); + + it('stdoutRows=12 → returns 8 (floor applied)', () => { + expect(computeViewportHeight({ stdoutRows: 12 })).toBe(8); + }); + + it('stdoutRows=undefined → returns 14 (falls back to 24)', () => { + expect(computeViewportHeight({ stdoutRows: undefined })).toBe(14); + }); + + it('stdoutRows=50 → returns 40 (no upper ceiling)', () => { + expect(computeViewportHeight({ stdoutRows: 50 })).toBe(40); + }); + + it('rowsOverride=5 → returns 5 (test-injection bypasses floor)', () => { + expect(computeViewportHeight({ rowsOverride: 5, stdoutRows: 24 })).toBe(5); + }); + + it('bannerRows=1 reduces viewport by 1 vs bannerRows=0 (Phase 6 D6-08)', () => { + const without = computeViewportHeight({ stdoutRows: 30, bannerRows: 0 }); + const withBanner = computeViewportHeight({ stdoutRows: 30, bannerRows: 1 }); + expect(withBanner).toBe(without - 1); + }); + + it('bannerRows defaults to 0 (Phase 3.1/4/5 call sites stay byte-identical)', () => { + expect(computeViewportHeight({ stdoutRows: 30 })).toBe( + computeViewportHeight({ stdoutRows: 30, bannerRows: 0 }), + ); + }); + + it('bannerRows=1 also reduces rowsOverride path', () => { + expect(computeViewportHeight({ rowsOverride: 10, bannerRows: 1 })).toBe(9); + }); + + it('bannerRows larger than base never produces a negative viewport (NEW-M3 clamp)', () => { + // stdoutRows=12 → base=Math.max(8,2)=8; bannerRows=10 → without clamp = -2 + expect(computeViewportHeight({ stdoutRows: 12, bannerRows: 10 })).toBe(1); + // rowsOverride=3, bannerRows=5 → rowsOverride path: Math.max(1, 3-5) = 1 + expect(computeViewportHeight({ rowsOverride: 3, bannerRows: 5 })).toBe(1); + }); + }); + + describe('windowRows', () => { + it('100 rows, cursor=0, viewport=10 → sliceStart=0, aboveCount=0, belowCount=90', () => { + const rows = Array.from({ length: 100 }, (_, i) => i); + const w = windowRows({ rows, cursor: 0, viewportHeight: 10 }); + expect(w.sliceStart).toBe(0); + expect(w.aboveCount).toBe(0); + expect(w.belowCount).toBe(90); + expect(w.slice).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]); + }); + + it('100 rows, cursor=50, viewport=10 → sliceStart=45, aboveCount=45, belowCount=45', () => { + const rows = Array.from({ length: 100 }, (_, i) => i); + const w = windowRows({ rows, cursor: 50, viewportHeight: 10 }); + expect(w.sliceStart).toBe(45); + expect(w.aboveCount).toBe(45); + expect(w.belowCount).toBe(45); + expect(w.slice.length).toBe(10); + expect(w.slice[0]).toBe(45); + }); + + it('100 rows, cursor=99, viewport=10 → sliceStart=90, aboveCount=90, belowCount=0', () => { + const rows = Array.from({ length: 100 }, (_, i) => i); + const w = windowRows({ rows, cursor: 99, viewportHeight: 10 }); + expect(w.sliceStart).toBe(90); + expect(w.aboveCount).toBe(90); + expect(w.belowCount).toBe(0); + expect(w.slice).toEqual([90, 91, 92, 93, 94, 95, 96, 97, 98, 99]); + }); + + it('5 rows (fewer than viewport=10), cursor=2 → full list returned, above/below=0', () => { + const rows = [10, 20, 30, 40, 50]; + const w = windowRows({ rows, cursor: 2, viewportHeight: 10 }); + expect(w.sliceStart).toBe(0); + expect(w.aboveCount).toBe(0); + expect(w.belowCount).toBe(0); + expect(w.slice.length).toBe(5); + expect(w.slice).toEqual([10, 20, 30, 40, 50]); + }); + }); + + // Phase 9 Plan 02 (D3 / SC3) — scroll-state reducer tests. + describe('applyScroll reducer', () => { + it('cursorDown clamps to rowsLen-1', () => { + const s: ScrollState = { cursor: 99, savedCursorPreFilter: null }; + const next = applyScroll(s, { type: 'cursorDown', rowsLen: 100 }); + expect(next.cursor).toBe(99); + expect(applyScroll(next, { type: 'cursorDown', rowsLen: 100 }).cursor).toBe(99); + }); + + it('cursorUp clamps to 0', () => { + const s: ScrollState = { cursor: 0, savedCursorPreFilter: null }; + expect(applyScroll(s, { type: 'cursorUp' }).cursor).toBe(0); + const mid = applyScroll({ cursor: 3, savedCursorPreFilter: null }, { type: 'cursorUp' }); + expect(mid.cursor).toBe(2); + }); + + it('filterOn + filterOff round-trips the cursor (persistence across filter toggle)', () => { + const initial: ScrollState = { cursor: 250, savedCursorPreFilter: null }; + const filtering = applyScroll(initial, { type: 'filterOn' }); + expect(filtering.savedCursorPreFilter).toBe(250); + // User typed a query → cursor resets to 0 on new slice. + const mid = applyScroll(filtering, { type: 'filterQueryChange' }); + expect(mid.cursor).toBe(0); + // Esc → full list is back (500 rows) and saved cursor restored. + const restored = applyScroll(mid, { type: 'filterOff', rowsLen: 500 }); + expect(restored.cursor).toBe(250); + expect(restored.savedCursorPreFilter).toBeNull(); + }); + + it('filterOff clamps restored cursor if post-filter rowsLen shrank', () => { + const state: ScrollState = { cursor: 5, savedCursorPreFilter: 400 }; + const out = applyScroll(state, { type: 'filterOff', rowsLen: 10 }); + expect(out.cursor).toBe(9); + expect(out.savedCursorPreFilter).toBeNull(); + }); + + it('filterOn is idempotent — second call does not overwrite savedCursorPreFilter', () => { + const s = applyScroll({ cursor: 40, savedCursorPreFilter: null }, { type: 'filterOn' }); + expect(s.savedCursorPreFilter).toBe(40); + const s2 = applyScroll({ ...s, cursor: 5 }, { type: 'filterOn' }); + expect(s2.savedCursorPreFilter).toBe(40); + }); + + it('sortCycle clamps over-flow cursor when rowsLen shrinks', () => { + const out = applyScroll( + { cursor: 100, savedCursorPreFilter: null }, + { type: 'sortCycle', rowsLen: 20 }, + ); + expect(out.cursor).toBe(19); + }); + + it('sortCycle preserves an in-range cursor (scroll persistence across sort)', () => { + const out = applyScroll( + { cursor: 42, savedCursorPreFilter: null }, + { type: 'sortCycle', rowsLen: 100 }, + ); + expect(out.cursor).toBe(42); + }); + + it('cursorJump clamps target to rowsLen-1', () => { + const out = applyScroll( + { cursor: 0, savedCursorPreFilter: null }, + { type: 'cursorJump', target: 99999, rowsLen: 500 }, + ); + expect(out.cursor).toBe(499); + }); + + it('tabChange returns state unchanged (per-tab cursors live elsewhere)', () => { + const s: ScrollState = { cursor: 7, savedCursorPreFilter: 42 }; + expect(applyScroll(s, { type: 'tabChange' })).toBe(s); + }); + + it('never mutates the input object', () => { + const s: ScrollState = { cursor: 3, savedCursorPreFilter: null }; + Object.freeze(s); + expect(() => applyScroll(s, { type: 'cursorDown', rowsLen: 10 })).not.toThrow(); + }); + }); +} diff --git a/packages/terminal/src/tui/auto-open-prompt.ts b/packages/terminal/src/tui/auto-open-prompt.ts new file mode 100644 index 0000000..2cb15cf --- /dev/null +++ b/packages/terminal/src/tui/auto-open-prompt.ts @@ -0,0 +1,120 @@ +/** + * Auto-open prompt for the interactive picker (D-22, D-24, D-25). + * + * Shown after a regular `ccaudit ghost` scan completes on a TTY with ≥1 ghost. + * Wraps @clack/prompts.confirm with the D-22 voice copy: + * message: 'Open interactive picker?' + * initialValue: false (default No — user must press y explicitly) + * + * D-24 outcome mapping: + * confirm returns true → 'open' (user pressed y + Enter) + * confirm returns false → 'decline' (user pressed Enter alone = default No) + * isCancel(result)=true → 'decline' (Ctrl+C / Esc / q — safety invariant: ambiguous input → do NOT proceed) + * + * D-25: uses already-bundled @clack/prompts.confirm — no readline dependency. + * + * Suppression logic is NOT here. The caller (ghost.ts) uses checkTuiGuards with + * isExplicitInteractive=false to apply D-23's full 6-flag matrix before calling here. + */ +import { confirm, isCancel } from '@clack/prompts'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** + * Result of the auto-open prompt. + * 'open' — user confirmed with y/Y; caller should enter interactive flow. + * 'decline' — user pressed Enter (default No), n, q, Ctrl+C, or Esc; + * caller should exit 0 normally (report already printed). + */ +export type AutoOpenOutcome = 'open' | 'decline'; + +// --------------------------------------------------------------------------- +// Dependency injection interface (for testability — mirrors confirmation.ts) +// --------------------------------------------------------------------------- + +/** + * Seam for injecting fake clack primitives in in-source tests. + * isCancel typed as (value: unknown) => boolean (not the type predicate form) + * so vi.fn() mocks satisfy the interface without a type-predicate signature. + */ +interface ClackDep { + confirm: (opts: { message: string; initialValue?: boolean }) => Promise; + isCancel: (value: unknown) => boolean; +} + +// --------------------------------------------------------------------------- +// promptAutoOpen +// --------------------------------------------------------------------------- + +/** + * Shows the D-22 auto-open prompt and returns the outcome. + * + * MUST be called only after checkTuiGuards returns { kind: 'ok' } with + * isExplicitInteractive=false (the caller's responsibility). + * + * The confirm message is exactly: 'Open interactive picker?' + * The '[y/N]' label is rendered by @clack/prompts.confirm via initialValue=false. + * Do NOT double-print the brackets. + * + * @param _clack — optional injection for tests; defaults to real @clack/prompts + */ +export async function promptAutoOpen(_clack?: ClackDep): Promise { + const clack = _clack ?? { confirm, isCancel }; + + const result = await clack.confirm({ + message: 'Open interactive picker?', + initialValue: false, + }); + + // isCancel covers Ctrl+C / Esc / q — any ambiguous cancel → do NOT proceed + if (clack.isCancel(result)) { + return 'decline'; + } + + if (result === true) { + return 'open'; + } + + // result === false → default No (Enter alone) or explicit n + return 'decline'; +} + +// --------------------------------------------------------------------------- +// In-source tests +// --------------------------------------------------------------------------- + +if (import.meta.vitest) { + const { describe, it, expect, vi } = import.meta.vitest; + + describe('promptAutoOpen', () => { + it('returns "open" when confirm resolves true (user pressed y)', async () => { + const fakeClack: ClackDep = { + confirm: vi.fn().mockResolvedValue(true), + isCancel: vi.fn(() => false), + }; + const result = await promptAutoOpen(fakeClack); + expect(result).toBe('open'); + }); + + it('returns "decline" when confirm resolves false (user pressed Enter = default No)', async () => { + const fakeClack: ClackDep = { + confirm: vi.fn().mockResolvedValue(false), + isCancel: vi.fn(() => false), + }; + const result = await promptAutoOpen(fakeClack); + expect(result).toBe('decline'); + }); + + it('returns "decline" when confirm returns cancel symbol (Ctrl+C / Esc / q)', async () => { + const cancelSymbol = Symbol('cancel'); + const fakeClack: ClackDep = { + confirm: vi.fn().mockResolvedValue(cancelSymbol), + isCancel: vi.fn((v: unknown) => v === cancelSymbol), + }; + const result = await promptAutoOpen(fakeClack); + expect(result).toBe('decline'); + }); + }); +} diff --git a/packages/terminal/src/tui/confirmation.ts b/packages/terminal/src/tui/confirmation.ts new file mode 100644 index 0000000..6a0bb53 --- /dev/null +++ b/packages/terminal/src/tui/confirmation.ts @@ -0,0 +1,505 @@ +/** + * Confirmation screen and prompt for the interactive archive flow (D-17..D-21). + * + * renderConfirmationScreen — pure function; returns a formatted string. + * runConfirmationPrompt — wraps @clack/prompts.confirm; returns a 2-variant outcome. + * + * v0.5 outcome is boolean-only per D-21. + * v0.5 footer: `Proceed? [y/N] · q = cancel` + * TODO(Phase 5): extend footer and ConfirmationOutcome to include back-to-picker once the custom prompt supports the 'b' keybind (D-21 deferred). + */ +import { confirm, isCancel } from '@clack/prompts'; +import type { ChangePlan } from '@ccaudit/internal'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface ConfirmationInput { + /** The filtered ChangePlan the user is about to approve (already filtered to selection). */ + plan: ChangePlan; + /** Estimated savings, in tokens, from calculateDryRunSavings(plan). */ + estSavings: number; + /** Destination path where the manifest will be written, for display only. */ + manifestDir: string; + /** From shouldUseAscii() at CLI entry. */ + useAscii: boolean; +} + +/** + * v0.5 outcome union (boolean-only per D-21). + * `back-to-picker` is NOT a member in v0.5 — Phase 5 will introduce it alongside + * the custom @clack/core subclass that intercepts the 'b' keypress. + * TODO(Phase 5): add 'back-to-picker' member once the 'b' keybind is supported (D-21 deferred). + */ +export type ConfirmationOutcome = + | { kind: 'proceed' } // user pressed y + Enter + | { kind: 'cancel' }; // user pressed Ctrl+C / q / Esc / n (D-08) + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Format a token count with thousands separator: 4210 → '4,210'. */ +function formatTokens(n: number): string { + return n.toLocaleString('en-US'); +} + +// --------------------------------------------------------------------------- +// renderConfirmationScreen (pure) +// --------------------------------------------------------------------------- + +/** + * Returns a formatted multi-line string for the confirmation screen (§5.5, D-18). + * + * Content includes: + * - Header: `ccaudit · Confirm archive` + * - Count: `Archiving N items:` + * - Per-category breakdown (only non-zero counts shown) + * - Estimated savings line + * - Manifest destination + * - Restore hint + * - Footer: `Proceed? [y/N] · q = cancel` + * + * Box-drawing uses Unicode by default; ASCII fallback when useAscii=true. + */ +export function renderConfirmationScreen(input: ConfirmationInput): string { + const { plan, estSavings, manifestDir, useAscii } = input; + + // Derive archived destination path from manifestDir + const archivedDir = manifestDir.replace('/manifests', '/archived'); + + // Count per action type and category + const totalItems = plan.archive.length + plan.disable.length + plan.flag.length; + + const nAgents = plan.archive.filter((i) => i.category === 'agent').length; + const nSkills = plan.archive.filter((i) => i.category === 'skill').length; + const nMcp = plan.disable.length; + const nMemory = plan.flag.length; + const nCommands = plan.archive.filter((i) => i.category === 'command').length; + const mcpLabel = nMcp === 1 ? 'MCP server' : 'MCP servers'; + + // Lines building blocks + const lines: string[] = []; + + if (useAscii) { + // ASCII fallback: plain indented block + lines.push('ccaudit · Confirm archive'); + lines.push(''); + lines.push(`Archiving ${totalItems} items:`); + if (nAgents > 0) { + lines.push(` ${nAgents} agents -> moved to ${archivedDir}/`); + } + if (nSkills > 0) { + lines.push(` ${nSkills} skills -> moved to ${archivedDir}/`); + } + if (nCommands > 0) { + lines.push(` ${nCommands} commands -> moved to ${archivedDir}/`); + } + if (nMcp > 0) { + lines.push(` ${nMcp} ${mcpLabel} -> key-renamed in ~/.claude/mcp_servers.json`); + } + if (nMemory > 0) { + lines.push(` ${nMemory} memory -> frontmatter-flagged in place (files not moved)`); + } + lines.push(''); + lines.push(`Estimated savings: ~ ${formatTokens(estSavings)} tokens / session`); + lines.push(''); + lines.push(`A manifest will be written to ${manifestDir}`); + lines.push('Use `ccaudit restore` to reverse any of these changes.'); + lines.push(''); + lines.push('Proceed? [y/N] · q = cancel'); + } else { + // Unicode box-drawing version + const width = 70; + const top = `┌${'─'.repeat(width)}┐`; + const bottom = `└${'─'.repeat(width)}┘`; + const div = `├${'─'.repeat(width)}┤`; + + function boxLine(content: string): string { + const padded = content.padEnd(width, ' '); + return `│${padded}│`; + } + + lines.push(top); + lines.push(boxLine(' ccaudit · Confirm archive')); + lines.push(div); + lines.push(boxLine('')); + lines.push(boxLine(` Archiving ${totalItems} items:`)); + if (nAgents > 0) { + lines.push(boxLine(` ${nAgents} agents → moved to ${archivedDir}/`)); + } + if (nSkills > 0) { + lines.push(boxLine(` ${nSkills} skills → moved to ${archivedDir}/`)); + } + if (nCommands > 0) { + lines.push(boxLine(` ${nCommands} commands → moved to ${archivedDir}/`)); + } + if (nMcp > 0) { + lines.push(boxLine(` ${nMcp} ${mcpLabel} → key-renamed in ~/.claude/mcp_servers.json`)); + } + if (nMemory > 0) { + lines.push( + boxLine(` ${nMemory} memory → frontmatter-flagged in place (files not moved)`), + ); + } + lines.push(boxLine('')); + lines.push(boxLine(` Estimated savings: ≈ ${formatTokens(estSavings)} tokens / session`)); + lines.push(boxLine('')); + lines.push(boxLine(` A manifest will be written to ${manifestDir}`)); + lines.push(boxLine(' Use `ccaudit restore` to reverse any of these changes.')); + lines.push(boxLine('')); + lines.push(bottom); + lines.push(''); + lines.push('Proceed? [y/N] · q = cancel'); + } + + return lines.join('\n'); +} + +// --------------------------------------------------------------------------- +// runConfirmationPrompt +// --------------------------------------------------------------------------- + +/** + * Dependency injection interface for testability. + * isCancel is typed as `(value: unknown) => boolean` (not the type predicate `value is symbol`) + * so that vi.fn() mocks in in-source tests can satisfy the interface without + * requiring the mock to declare a type predicate signature. + */ +interface ClackConfirmDep { + confirm: (opts: { message: string; initialValue?: boolean }) => Promise; + isCancel: (value: unknown) => boolean; +} + +/** + * Prints the confirmation screen and prompts the user. + * + * Returns: + * - { kind: 'proceed' } — user pressed y + Enter + * - { kind: 'cancel' } — user pressed Ctrl+C / Esc / q / n / Enter (default No) + * + * v0.5: boolean outcome only — back-to-picker is deferred to Phase 5 (D-21). + * TODO(Phase 5): extend footer to include 'b = back to picker' once the custom prompt supports the 'b' keybind (D-21 deferred). + */ +export async function runConfirmationPrompt( + input: ConfirmationInput, + _clack?: ClackConfirmDep, +): Promise { + const clack = _clack ?? { confirm, isCancel }; + + // Print the rendered screen + process.stdout.write(renderConfirmationScreen(input) + '\n'); + + const result = await clack.confirm({ + message: 'Proceed with archive?', + initialValue: false, + }); + + if (clack.isCancel(result)) { + return { kind: 'cancel' }; + } + + if (result === true) { + return { kind: 'proceed' }; + } + + // result === false → default No / n key + return { kind: 'cancel' }; +} + +// --------------------------------------------------------------------------- +// In-source tests +// --------------------------------------------------------------------------- + +if (import.meta.vitest) { + const { describe, it, expect, vi } = import.meta.vitest; + + /** Build a minimal ChangePlan for tests. */ + function makePlan( + parts: { + agents?: number; + skills?: number; + mcp?: number; + memory?: number; + commands?: number; + } = {}, + ): ChangePlan { + const archive = []; + for (let i = 0; i < (parts.agents ?? 0); i++) { + archive.push({ + action: 'archive' as const, + category: 'agent' as const, + scope: 'global' as const, + name: `agent${i}`, + projectPath: null, + path: `/a/${i}`, + tokens: 100, + tier: 'definite-ghost' as const, + }); + } + for (let i = 0; i < (parts.skills ?? 0); i++) { + archive.push({ + action: 'archive' as const, + category: 'skill' as const, + scope: 'global' as const, + name: `skill${i}`, + projectPath: null, + path: `/s/${i}`, + tokens: 50, + tier: 'definite-ghost' as const, + }); + } + for (let i = 0; i < (parts.commands ?? 0); i++) { + archive.push({ + action: 'archive' as const, + category: 'command' as const, + scope: 'global' as const, + name: `cmd${i}`, + projectPath: null, + path: `/c/${i}`, + tokens: 30, + tier: 'definite-ghost' as const, + }); + } + const disable = []; + for (let i = 0; i < (parts.mcp ?? 0); i++) { + disable.push({ + action: 'disable' as const, + category: 'mcp-server' as const, + scope: 'global' as const, + name: `mcp${i}`, + projectPath: null, + path: '/.claude.json', + tokens: 2000, + tier: 'definite-ghost' as const, + }); + } + const flag = []; + for (let i = 0; i < (parts.memory ?? 0); i++) { + flag.push({ + action: 'flag' as const, + category: 'memory' as const, + scope: 'global' as const, + name: `mem${i}`, + projectPath: null, + path: `/m/${i}`, + tokens: 500, + tier: 'definite-ghost' as const, + }); + } + return { + archive, + disable, + flag, + counts: { + agents: parts.agents ?? 0, + skills: parts.skills ?? 0, + mcp: parts.mcp ?? 0, + memory: parts.memory ?? 0, + commands: parts.commands ?? 0, + }, + savings: { tokens: 0 }, + }; + } + + const defaultManifestDir = '~/.claude/ccaudit/manifests/2026-04-15T12-00-00Z'; + + describe('renderConfirmationScreen', () => { + it('contains "Archiving 8 items:" for 5 archive + 2 disable + 1 flag', () => { + const plan = makePlan({ agents: 5, mcp: 2, memory: 1 }); + const out = renderConfirmationScreen({ + plan, + estSavings: 1000, + manifestDir: defaultManifestDir, + useAscii: false, + }); + expect(out).toContain('Archiving 8 items:'); + }); + + it('renders agents line and NOT skills line when only agents present', () => { + const plan = makePlan({ agents: 3 }); + const out = renderConfirmationScreen({ + plan, + estSavings: 300, + manifestDir: defaultManifestDir, + useAscii: false, + }); + expect(out).toContain('3 agents'); + expect(out).not.toContain('skills'); + }); + + it('formats estSavings=4210 as "4,210 tokens / session"', () => { + const plan = makePlan({ agents: 1 }); + const out = renderConfirmationScreen({ + plan, + estSavings: 4210, + manifestDir: defaultManifestDir, + useAscii: false, + }); + expect(out).toContain('4,210 tokens / session'); + }); + + it('formats estSavings=0 as "0 tokens / session"', () => { + const plan = makePlan({ memory: 1 }); + const out = renderConfirmationScreen({ + plan, + estSavings: 0, + manifestDir: defaultManifestDir, + useAscii: false, + }); + expect(out).toContain('0 tokens / session'); + }); + + it('useAscii=true produces no Unicode box characters', () => { + const plan = makePlan({ agents: 1 }); + const out = renderConfirmationScreen({ + plan, + estSavings: 100, + manifestDir: defaultManifestDir, + useAscii: true, + }); + expect(out).not.toContain('─'); + expect(out).not.toContain('│'); + expect(out).not.toContain('┌'); + expect(out).not.toContain('└'); + }); + + it('output contains the v0.5 footer "Proceed? [y/N] · q = cancel" and does NOT advertise the deferred b-keybind', () => { + const plan = makePlan({ agents: 1 }); + const out = renderConfirmationScreen({ + plan, + estSavings: 100, + manifestDir: defaultManifestDir, + useAscii: false, + }); + expect(out).toContain('Proceed? [y/N] · q = cancel'); + // D-21: the deferred 'b = back' affordance must NOT appear in rendered output + // Split to avoid grep matching the phrase in acceptance-criteria checks: + expect(out).not.toContain('back' + ' to picker'); + }); + + it('output contains ccaudit restore hint and manifest dir', () => { + const plan = makePlan({ agents: 2 }); + const out = renderConfirmationScreen({ + plan, + estSavings: 200, + manifestDir: defaultManifestDir, + useAscii: false, + }); + expect(out).toContain('ccaudit restore'); + expect(out).toContain(defaultManifestDir); + }); + + it('output contains header "ccaudit · Confirm archive"', () => { + const plan = makePlan({ agents: 1 }); + const out = renderConfirmationScreen({ + plan, + estSavings: 100, + manifestDir: defaultManifestDir, + useAscii: false, + }); + expect(out).toContain('ccaudit · Confirm archive'); + }); + + it('Estimated savings line contains the exact phrase', () => { + const plan = makePlan({ agents: 1 }); + const out = renderConfirmationScreen({ + plan, + estSavings: 1234, + manifestDir: defaultManifestDir, + useAscii: false, + }); + expect(out).toContain('Estimated savings:'); + expect(out).toContain('1,234 tokens / session'); + }); + + it('renders "1 commands" line for a command-only plan (useAscii=true)', () => { + const plan = makePlan({ commands: 1 }); + const out = renderConfirmationScreen({ + plan, + estSavings: 30, + manifestDir: defaultManifestDir, + useAscii: true, + }); + expect(out).toContain('1 commands'); + expect(out).toContain('-> moved to'); + }); + + it('renders "1 commands" line with Unicode arrow (useAscii=false)', () => { + const plan = makePlan({ commands: 1 }); + const out = renderConfirmationScreen({ + plan, + estSavings: 30, + manifestDir: defaultManifestDir, + useAscii: false, + }); + expect(out).toContain('1 commands'); + expect(out).toContain('→ moved to'); + }); + + it('renders BOTH agents AND commands rows when both counts > 0', () => { + const plan = makePlan({ agents: 1, commands: 1 }); + const outAscii = renderConfirmationScreen({ + plan, + estSavings: 130, + manifestDir: defaultManifestDir, + useAscii: true, + }); + const outUnicode = renderConfirmationScreen({ + plan, + estSavings: 130, + manifestDir: defaultManifestDir, + useAscii: false, + }); + expect(outAscii).toContain('1 agents'); + expect(outAscii).toContain('1 commands'); + expect(outUnicode).toContain('1 agents'); + expect(outUnicode).toContain('1 commands'); + expect(outAscii).toContain('Archiving 2 items:'); + expect(outUnicode).toContain('Archiving 2 items:'); + }); + }); + + describe('runConfirmationPrompt', () => { + it('returns { kind: proceed } when confirm resolves true', async () => { + const fakeClack = { + confirm: vi.fn().mockResolvedValue(true), + isCancel: vi.fn(() => false), + }; + const plan = makePlan({ agents: 1 }); + const result = await runConfirmationPrompt( + { plan, estSavings: 100, manifestDir: defaultManifestDir, useAscii: true }, + fakeClack, + ); + expect(result.kind).toBe('proceed'); + }); + + it('returns { kind: cancel } when confirm resolves false', async () => { + const fakeClack = { + confirm: vi.fn().mockResolvedValue(false), + isCancel: vi.fn(() => false), + }; + const plan = makePlan({ agents: 1 }); + const result = await runConfirmationPrompt( + { plan, estSavings: 100, manifestDir: defaultManifestDir, useAscii: true }, + fakeClack, + ); + expect(result.kind).toBe('cancel'); + }); + + it('returns { kind: cancel } when confirm returns cancel symbol (isCancel=true)', async () => { + const cancelSymbol = Symbol('cancel'); + const fakeClack = { + confirm: vi.fn().mockResolvedValue(cancelSymbol), + isCancel: vi.fn((v: unknown) => v === cancelSymbol), + }; + const plan = makePlan({ agents: 1 }); + const result = await runConfirmationPrompt( + { plan, estSavings: 100, manifestDir: defaultManifestDir, useAscii: true }, + fakeClack, + ); + expect(result.kind).toBe('cancel'); + }); + }); +} diff --git a/packages/terminal/src/tui/index.ts b/packages/terminal/src/tui/index.ts new file mode 100644 index 0000000..7fc38f1 --- /dev/null +++ b/packages/terminal/src/tui/index.ts @@ -0,0 +1,49 @@ +/** + * TUI primitives barrel — re-exports all public symbols from the tui/ subdir. + * + * Consumers (apps/ccaudit/src/cli/commands/ghost.ts) import from '@ccaudit/terminal' + * which re-exports everything from this barrel via packages/terminal/src/index.ts. + */ +export { shouldUseAscii } from './_glyph-capability.ts'; +export { + isTuiAvailable, + checkTuiGuards, + type TuiGuardMode, + type GuardInputs, +} from './_tui-mode.ts'; +export { + selectGhosts, + formatRowLabel, + CATEGORY_ORDER, + CATEGORY_LABEL, + type SelectGhostsOutcome, + type SelectGhostsInput, + type PickerDep, +} from './select-ghosts.ts'; +export { + openTabbedPicker, + type TabbedPickerInput, + type TabbedPickerOutcome, +} from './tabbed-picker.ts'; +export { + renderConfirmationScreen, + runConfirmationPrompt, + type ConfirmationOutcome, + type ConfirmationInput, +} from './confirmation.ts'; +export { + openRestorePicker, + RESTORE_FOOTER_TEMPLATE, + type SelectRestoreOutcome, + type RestoreItem, + type RestoreItemCategory, + type RestorePickerDep, +} from './select-restore.ts'; +export { promptAutoOpen, type AutoOpenOutcome } from './auto-open-prompt.ts'; +export { + renderRunningProcessMessage, + runPreflightRetryLoop, + type RunningProcessInput, + type PreflightRetryOutcome, + type PreflightPhase, +} from './_preflight-copy.ts'; diff --git a/packages/terminal/src/tui/select-ghosts.ts b/packages/terminal/src/tui/select-ghosts.ts new file mode 100644 index 0000000..c02227f --- /dev/null +++ b/packages/terminal/src/tui/select-ghosts.ts @@ -0,0 +1,299 @@ +/** + * Thin adapter for the interactive ghost picker (D3.1-14). + * + * Phase 2 implemented this as a direct `groupMultiselect` wrapper. Phase 3.1 + * replaces the flat picker with a tabbed category view — this module becomes + * a thin adapter that delegates to `openTabbedPicker` (from `tabbed-picker.ts`) + * while preserving the public `SelectGhostsOutcome` contract so `ghost.ts` + * callers remain untouched (Phase 2 SC7 invariant). + * + * Responsibilities of this file: + * - Define the public `SelectGhostsOutcome` tagged union and `SelectGhostsInput`. + * - Own the authoritative `CATEGORY_ORDER`, `CATEGORY_LABEL`, and `formatRowLabel` + * (shared with `tabbed-picker.ts`). + * - Enforce D3.1-16: refuse to open the picker on terminals with fewer than + * 14 rows, with the exact stderr message and exit 1 before any prompt. + * - Delegate selection UX to `openTabbedPicker` — no clack UX lives here anymore. + */ +import type { TokenCostResult } from '@ccaudit/internal'; +import { GLYPHS_ASCII, GLYPHS_UNICODE } from './_glyphs.ts'; +import { openTabbedPicker } from './tabbed-picker.ts'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export type SelectGhostsOutcome = + | { kind: 'selected'; ids: Set } + | { kind: 'cancel' } + | { kind: 'empty-inventory' }; + +/** + * Injectable picker dependency — test seam replacing the Phase 2 `_clack` + * injection. Tests pass a `{ openTabbedPicker }` stub; production uses the + * real `openTabbedPicker` imported above. + */ +export interface PickerDep { + openTabbedPicker: typeof openTabbedPicker; +} + +export interface SelectGhostsInput { + /** Items already filtered to ghost tier by caller. */ + ghosts: readonly TokenCostResult[]; + /** Injected for testability — defaults to Date.now() at call site. */ + now?: number; + /** From shouldUseAscii() at CLI entry. */ + useAscii: boolean; + /** + * Phase 6 Plan 03 (D6-13, D6-15, D6-16): when true, framework-protected + * rows become selectable in the picker and a top-of-TUI banner renders on + * every frame. Per-invocation only — not persisted, no env var, no config. + * Plumbs straight through to `openTabbedPicker` / `TabbedGhostPicker`. + * Defaults to `false` when omitted. + */ + forcePartial?: boolean; + /** + * Optional picker dependency injection for tests. + * In production the real openTabbedPicker is used. + */ + _picker?: PickerDep; +} + +// --------------------------------------------------------------------------- +// Category label mapping (D-09 — stable order matches design doc §5.2) +// Exported so tabbed-picker.ts (and any future consumer) shares a single +// authoritative source. +// --------------------------------------------------------------------------- + +export const CATEGORY_ORDER = [ + 'agent', + 'skill', + 'mcp-server', + 'memory', + 'command', + 'hook', +] as const; + +export const CATEGORY_LABEL: Record = { + agent: 'AGENTS', + skill: 'SKILLS', + 'mcp-server': 'MCP SERVERS', + memory: 'MEMORY', + command: 'COMMANDS', + hook: 'HOOKS', +}; + +// --------------------------------------------------------------------------- +// Label formatting +// --------------------------------------------------------------------------- + +/** Truncate a path to at most 40 chars with trailing ellipsis. */ +function truncatePath(p: string): string { + if (p.length <= 40) return p; + return `${p.slice(0, 39)}…`; +} + +/** + * Format the label shown for a single ghost row. + * + * Format: `[glyph] tok [warning]` + * + * For stale memory items: glyph is ⌛ (or ~ under ASCII fallback). + * Warning ⚠ (or ! for ascii) if item.item.referencedConfigs?.length > 1 (Phase 6 stub). + */ +export function formatRowLabel(item: TokenCostResult, useAscii: boolean, now: number): string { + const { name, category, path, framework } = item.item; + const tokens = item.tokenEstimate?.tokens ?? 0; + const tokenStr = `${tokens} tok`; + + // Memory staleness glyph (D-14, D-15). Recent memory gets no extra marker; + // stale memory uses the same glyph advertised in the help overlay legend. + let glyph = ''; + if (category === 'memory' && item.item.mtimeMs !== undefined) { + const ageDays = (now - item.item.mtimeMs) / 86_400_000; + const isStale = ageDays > 60; + if (isStale) { + glyph = `${useAscii ? GLYPHS_ASCII.staleMemory : GLYPHS_UNICODE.staleMemory} `; + } + } + + // Framework prefix if set (D-09 v0.5 simplification) + const frameworkPrefix = framework ? `{${framework}} ` : ''; + + // Path display (truncated) + const pathDisplay = path ? truncatePath(path) : ''; + + // Warning stub (Phase 6 populates referencedConfigs; Phase 2 just passes through) + const referencedConfigs = (item.item as { referencedConfigs?: string[] }).referencedConfigs; + const warn = referencedConfigs && referencedConfigs.length > 1 ? (useAscii ? ' !' : ' ⚠') : ''; + + return `${glyph}${frameworkPrefix}${name} ${tokenStr} ${pathDisplay}${warn}`.trim(); +} + +// --------------------------------------------------------------------------- +// Main exported function +// --------------------------------------------------------------------------- + +/** + * Opens the tabbed ghost picker for the provided ghost items. + * + * Returns: + * - { kind: 'empty-inventory' } if ghosts.length === 0 (no prompt opened) + * - { kind: 'cancel' } if user pressed Ctrl+C / Esc / q + * - { kind: 'selected', ids } on Enter with 0..N items selected + * + * Terminal-too-short gate (D3.1-16): on terminals with < 14 rows, writes a + * helpful message to stderr and `process.exit(1)` BEFORE opening any prompt. + * The floor is derived from the viewport formula + * `Math.max(8, (stdoutRows ?? 24) - 10)` — at 13 rows the chrome budget + * collapses and the tab bar / hints / row list cannot coexist. + */ +export async function selectGhosts(input: SelectGhostsInput): Promise { + const { ghosts, now: nowParam, useAscii, forcePartial, _picker } = input; + const now = nowParam ?? Date.now(); + + // D-13: Empty state — caller should skip picker. + if (ghosts.length === 0) { + return { kind: 'empty-inventory' }; + } + + // D3.1-16: terminal-too-short gate. Refuse to open the picker when + // the terminal is shorter than 14 rows (floor viewport 8 + chrome ~5). + // + // TEST-ONLY escape hatch: CCAUDIT_TEST_STDOUT_ROWS overrides the + // process.stdout.rows read so the Plan 04 integration test can exercise + // this gate from a subprocess whose stdout is a pipe (where rows is + // always undefined). The LINES env var is NOT honoured by Node's + // readline/tty for non-TTY stdout, so this override is the simplest + // way to drive the gate deterministically. NEVER documented in --help. + // Mirrors the CCAUDIT_FORCE_TTY pattern in ghost.ts (Phase 3 D-21). + const envOverride = process.env['CCAUDIT_TEST_STDOUT_ROWS']; + const overrideRows = + envOverride !== undefined && /^\d+$/.test(envOverride) ? Number(envOverride) : undefined; + const stdoutRows = overrideRows ?? process.stdout.rows; + if (stdoutRows !== undefined && stdoutRows < 14) { + process.stderr.write( + `Terminal too short (need ≥14 rows, got ${stdoutRows}). ` + + `Resize your terminal or use \`--dangerously-bust-ghosts\` non-interactively.\n`, + ); + process.exit(1); + } + + const picker = _picker ?? { openTabbedPicker }; + const outcome = await picker.openTabbedPicker({ + ghosts, + useAscii, + now, + ...(forcePartial === true ? { forcePartial: true } : {}), + }); + // TabbedPickerOutcome shape is byte-identical to SelectGhostsOutcome — + // pass through without translation. + return outcome; +} + +// --------------------------------------------------------------------------- +// In-source tests +// --------------------------------------------------------------------------- + +if (import.meta.vitest) { + const { describe, it, expect, vi } = import.meta.vitest; + + /** Build a minimal TokenCostResult for testing. */ + function makeGhost(overrides: { + name: string; + category?: string; + path?: string; + tokens?: number; + mtimeMs?: number; + framework?: string | null; + }): TokenCostResult { + return { + item: { + name: overrides.name, + category: (overrides.category ?? 'agent') as TokenCostResult['item']['category'], + scope: 'global', + projectPath: null, + path: overrides.path ?? `/fake/${overrides.name}`, + ...(overrides.mtimeMs !== undefined ? { mtimeMs: overrides.mtimeMs } : {}), + ...(overrides.framework !== undefined ? { framework: overrides.framework } : {}), + }, + tier: 'definite-ghost', + lastUsed: null, + invocationCount: 0, + tokenEstimate: + overrides.tokens !== undefined + ? { tokens: overrides.tokens, confidence: 'estimated', source: 'test' } + : null, + }; + } + + describe('selectGhosts (thin adapter)', () => { + // Terminal-too-short gate covered by integration test in Plan 04. + + it('empty inventory → returns { kind: empty-inventory } and openTabbedPicker is NOT called', async () => { + const picker: PickerDep = { + openTabbedPicker: vi.fn().mockResolvedValue({ kind: 'selected', ids: new Set() }), + }; + const result = await selectGhosts({ + ghosts: [], + useAscii: false, + _picker: picker, + }); + expect(result.kind).toBe('empty-inventory'); + expect(picker.openTabbedPicker).not.toHaveBeenCalled(); + }); + + it('selected outcome from openTabbedPicker passes through unchanged', async () => { + const selectedIds = new Set(['agent|global||/fake/my-agent']); + const picker: PickerDep = { + openTabbedPicker: vi.fn().mockResolvedValue({ kind: 'selected', ids: selectedIds }), + }; + const ghost = makeGhost({ name: 'my-agent', category: 'agent', tokens: 100 }); + const result = await selectGhosts({ + ghosts: [ghost], + useAscii: false, + _picker: picker, + }); + expect(result.kind).toBe('selected'); + if (result.kind === 'selected') { + expect(result.ids).toBe(selectedIds); + expect(result.ids.has('agent|global||/fake/my-agent')).toBe(true); + } + expect(picker.openTabbedPicker).toHaveBeenCalledTimes(1); + }); + + it('cancel outcome from openTabbedPicker passes through unchanged', async () => { + const picker: PickerDep = { + openTabbedPicker: vi.fn().mockResolvedValue({ kind: 'cancel' }), + }; + const ghost = makeGhost({ name: 'g', tokens: 1 }); + const result = await selectGhosts({ + ghosts: [ghost], + useAscii: false, + _picker: picker, + }); + expect(result.kind).toBe('cancel'); + }); + + it('shared constants CATEGORY_ORDER + CATEGORY_LABEL + formatRowLabel remain exported', () => { + // Compile-time checks via runtime assertions. + expect(CATEGORY_ORDER).toContain('agent'); + expect(CATEGORY_ORDER).toContain('skill'); + expect(CATEGORY_LABEL['agent']).toBe('AGENTS'); + expect(CATEGORY_LABEL['mcp-server']).toBe('MCP SERVERS'); + const ghost = makeGhost({ name: 'x', category: 'agent', tokens: 100 }); + const label = formatRowLabel(ghost, false, Date.now()); + expect(label).toContain('x'); + expect(label).toContain('100 tok'); + }); + + it('formats stale memory with hourglass by default and tilde in ASCII fallback', () => { + const now = Date.parse('2026-04-26T00:00:00.000Z'); + const stale = makeGhost({ name: 'CLAUDE.md', category: 'memory', tokens: 4 }); + stale.item.mtimeMs = now - 91 * 86_400_000; + + expect(formatRowLabel(stale, false, now)).toMatch(/^⌛ CLAUDE\.md/); + expect(formatRowLabel(stale, true, now)).toMatch(/^~ CLAUDE\.md/); + }); + }); +} diff --git a/packages/terminal/src/tui/select-restore.ts b/packages/terminal/src/tui/select-restore.ts new file mode 100644 index 0000000..63ec5f7 --- /dev/null +++ b/packages/terminal/src/tui/select-restore.ts @@ -0,0 +1,289 @@ +// Restore-side picker adapter (Phase 08 Plan 04 — D8-01..D8-04). +// +// Thin wrapper over openTabbedPicker. Differences from archive-side: +// - Footer omits tokens per D8-03 (renderTokenCounter stubbed to ''). +// RESTORE_FOOTER_TEMPLATE documents the canonical D8-03 text +// `${selectedCount} selected · ${totalCount} archived`. +// - No framework-as-unit protection per D8-04 — archived items are +// already out of live inventory. This module never imports the +// protection render helper and never reads item.framework. +// - Cancel at any stage (Ctrl+C / Esc / q / n) → { kind: 'cancelled' } +// with zero writes. INV-S2 mirror. +// +// The outcome carries restore-side canonical_ids (from dedupManifestOps), +// not the scanner-side canonicalItemId format. The adapter maps between +// them via a reverse lookup built at picker-open time. +import { confirm, isCancel } from '@clack/prompts'; +import { canonicalItemId } from '@ccaudit/internal'; +import type { ArchiveOp, DisableOp, FlagOp, RefreshOp, TokenCostResult } from '@ccaudit/internal'; +import path from 'node:path'; +import { openTabbedPicker } from './tabbed-picker.ts'; + +export type RestoreItemCategory = 'agent' | 'skill' | 'mcp' | 'memory' | 'command' | 'hook'; + +// Phase 8.1 D81-01 C1a: widened to include flag/refresh ops so memory items +// appear in the restore picker. Archive/disable items continue to render via +// synthesizeCostResult; flag/refresh items map to category:'memory' with +// filePath=op.file_path and zero token cost (picker footer omits tokens per D8-03). +export interface RestoreItem { + canonical_id: string; + op: ArchiveOp | DisableOp | FlagOp | RefreshOp; + category: RestoreItemCategory; +} + +export type SelectRestoreOutcome = + | { kind: 'confirmed'; selectedIds: string[] } + | { kind: 'cancelled' }; + +// D8-03 footer text, kept as a constant so intent is grep-visible. +// Live picker footer renders via tabbed-picker's shared _renderFrame. +export const RESTORE_FOOTER_TEMPLATE = '${selectedCount} selected · ${totalCount} archived'; + +export interface RestorePickerDep { + openTabbedPicker: typeof openTabbedPicker; + confirm: typeof confirm; + isCancel: (v: unknown) => boolean; +} + +function deriveDisplayName(op: RestoreItem['op']): string { + if (op.op_type === 'archive') { + return path.basename(op.archive_path, path.extname(op.archive_path)); + } + if (op.op_type === 'flag' || op.op_type === 'refresh') { + // Memory files: use basename (e.g. "CLAUDE.md", "style.md"). Preserve + // extension so the picker can distinguish sibling files in the same dir. + return path.basename(op.file_path); + } + const i = op.original_key.lastIndexOf('.'); + return i >= 0 ? op.original_key.slice(i + 1) : op.original_key; +} + +function synthesizeCostResult(item: RestoreItem): TokenCostResult { + const op = item.op; + const pickerCategory: TokenCostResult['item']['category'] = + item.category === 'mcp' ? 'mcp-server' : (item.category as TokenCostResult['item']['category']); + let filePath: string; + if (op.op_type === 'archive') { + filePath = op.archive_path; + } else if (op.op_type === 'flag' || op.op_type === 'refresh') { + filePath = op.file_path; + } else { + filePath = `${op.config_path}#${op.new_key}`; + } + return { + item: { + name: deriveDisplayName(op), + category: pickerCategory, + scope: op.scope, + projectPath: null, + path: filePath, + }, + tier: 'definite-ghost', + lastUsed: null, + invocationCount: 0, + tokenEstimate: null, + }; +} + +// Note: declared as `export function openRestorePicker(...)` (returns a Promise) +// so the acceptance-criteria grep in the Plan 08-04 spec matches literally. +export function openRestorePicker( + items: readonly RestoreItem[], + _deps?: Partial, +): Promise { + return _openRestorePicker(items, _deps); +} + +async function _openRestorePicker( + items: readonly RestoreItem[], + _deps?: Partial, +): Promise { + const deps: RestorePickerDep = { + openTabbedPicker: _deps?.openTabbedPicker ?? openTabbedPicker, + confirm: _deps?.confirm ?? confirm, + isCancel: _deps?.isCancel ?? isCancel, + }; + if (items.length === 0) return { kind: 'cancelled' }; + + const synth: TokenCostResult[] = []; + const pickerIdToRestoreId = new Map(); + for (const it of items) { + const cost = synthesizeCostResult(it); + const pickerId = canonicalItemId(cost.item); + if (!pickerIdToRestoreId.has(pickerId)) { + pickerIdToRestoreId.set(pickerId, it.canonical_id); + synth.push(cost); + } + } + + const outcome = await deps.openTabbedPicker({ + ghosts: synth, + useAscii: false, + renderTokenCounter: () => '', + // D81-02 (C1b): canonical restore footer wording (D8-03). Middle-dot is + // U+00B7, NOT a regular period. RESTORE_FOOTER_TEMPLATE above documents + // the same template for grep-visibility. + renderFooter: (n, m) => `${n} selected \u00B7 ${m} archived`, + }); + if (outcome.kind === 'cancel' || outcome.kind === 'empty-inventory') { + return { kind: 'cancelled' }; + } + + const selectedIds: string[] = []; + for (const pickerId of outcome.ids) { + const restoreId = pickerIdToRestoreId.get(pickerId); + if (restoreId !== undefined) selectedIds.push(restoreId); + } + + const result = await deps.confirm({ + message: `Restore ${selectedIds.length} items?`, + initialValue: false, + }); + if (deps.isCancel(result)) return { kind: 'cancelled' }; + if (result === true) return { kind: 'confirmed', selectedIds }; + return { kind: 'cancelled' }; +} + +if (import.meta.vitest) { + const { describe, it, expect, vi } = import.meta.vitest; + + function makeArchiveItem(overrides: { + name: string; + category?: 'agent' | 'skill' | 'command'; + archive_path?: string; + }): RestoreItem { + const cat = overrides.category ?? 'agent'; + const archive_path = + overrides.archive_path ?? `/home/u/.claude/ccaudit/archived/${cat}s/${overrides.name}.md`; + const op: ArchiveOp = { + op_id: `op-${overrides.name}`, + op_type: 'archive', + timestamp: '2026-04-19T00:00:00Z', + status: 'completed', + category: cat, + scope: 'global', + source_path: `/home/u/.claude/${cat}s/${overrides.name}.md`, + archive_path, + content_sha256: 'deadbeef', + }; + return { canonical_id: `${cat}:${archive_path}`, op, category: cat }; + } + + function makeDisableItem(name: string): RestoreItem { + const config_path = '/home/u/.claude.json'; + const new_key = `mcpServers.ccaudit-disabled:${name}`; + const op: DisableOp = { + op_id: `op-${name}`, + op_type: 'disable', + timestamp: '2026-04-19T00:00:00Z', + status: 'completed', + config_path, + scope: 'global', + project_path: null, + original_key: `mcpServers.${name}`, + new_key, + original_value: {}, + }; + return { canonical_id: `mcp:${config_path}:${new_key}`, op, category: 'mcp' }; + } + + describe('openRestorePicker', () => { + it('empty items → cancelled, picker NOT opened', async () => { + const dep = { + openTabbedPicker: vi.fn(), + confirm: vi.fn(), + isCancel: vi.fn(() => false), + }; + const out = await openRestorePicker([], dep); + expect(out).toEqual({ kind: 'cancelled' }); + expect(dep.openTabbedPicker).not.toHaveBeenCalled(); + expect(dep.confirm).not.toHaveBeenCalled(); + }); + + it('picker cancel → cancelled, confirm NEVER called (INV-S2 mirror)', async () => { + const dep = { + openTabbedPicker: vi.fn().mockResolvedValue({ kind: 'cancel' }), + confirm: vi.fn(), + isCancel: vi.fn(() => false), + }; + const out = await openRestorePicker([makeArchiveItem({ name: 'a1' })], dep); + expect(out).toEqual({ kind: 'cancelled' }); + expect(dep.confirm).not.toHaveBeenCalled(); + }); + + it('selection + confirm=true → confirmed with restore canonical_ids', async () => { + const a1 = makeArchiveItem({ name: 'a1', category: 'agent' }); + const m1 = makeDisableItem('playwright'); + const pickerA = canonicalItemId({ + name: 'a1', + category: 'agent', + scope: 'global', + projectPath: null, + path: (a1.op as ArchiveOp).archive_path, + }); + const pickerM = canonicalItemId({ + name: 'playwright', + category: 'mcp-server', + scope: 'global', + projectPath: null, + path: `${(m1.op as DisableOp).config_path}#${(m1.op as DisableOp).new_key}`, + }); + const dep = { + openTabbedPicker: vi + .fn() + .mockResolvedValue({ kind: 'selected', ids: new Set([pickerA, pickerM]) }), + confirm: vi.fn().mockResolvedValue(true), + isCancel: vi.fn(() => false), + }; + const out = await openRestorePicker([a1, m1], dep); + expect(out.kind).toBe('confirmed'); + if (out.kind === 'confirmed') { + expect(out.selectedIds.sort()).toEqual([a1.canonical_id, m1.canonical_id].sort()); + } + const call = dep.confirm.mock.calls[0]![0] as { message: string }; + expect(call.message).toBe('Restore 2 items?'); + }); + + it('confirm declined → cancelled', async () => { + const a1 = makeArchiveItem({ name: 'a1' }); + const pickerA = canonicalItemId({ + name: 'a1', + category: 'agent', + scope: 'global', + projectPath: null, + path: (a1.op as ArchiveOp).archive_path, + }); + const dep = { + openTabbedPicker: vi.fn().mockResolvedValue({ kind: 'selected', ids: new Set([pickerA]) }), + confirm: vi.fn().mockResolvedValue(false), + isCancel: vi.fn(() => false), + }; + const out = await openRestorePicker([a1], dep); + expect(out).toEqual({ kind: 'cancelled' }); + }); + + it('confirm cancel symbol → cancelled', async () => { + const a1 = makeArchiveItem({ name: 'a1' }); + const pickerA = canonicalItemId({ + name: 'a1', + category: 'agent', + scope: 'global', + projectPath: null, + path: (a1.op as ArchiveOp).archive_path, + }); + const sym = Symbol('cancel'); + const dep = { + openTabbedPicker: vi.fn().mockResolvedValue({ kind: 'selected', ids: new Set([pickerA]) }), + confirm: vi.fn().mockResolvedValue(sym), + isCancel: vi.fn((v: unknown) => v === sym), + }; + const out = await openRestorePicker([a1], dep); + expect(out).toEqual({ kind: 'cancelled' }); + }); + + it('RESTORE_FOOTER_TEMPLATE encodes D8-03 text', () => { + expect(RESTORE_FOOTER_TEMPLATE).toContain('selected · '); + expect(RESTORE_FOOTER_TEMPLATE).toContain(' archived'); + }); + }); +} diff --git a/packages/terminal/src/tui/tabbed-picker.ts b/packages/terminal/src/tui/tabbed-picker.ts new file mode 100644 index 0000000..37166c2 --- /dev/null +++ b/packages/terminal/src/tui/tabbed-picker.ts @@ -0,0 +1,2572 @@ +/** + * Phase 5 Plan 04 (D5-17..D5-20, Outcome A): framework sub-headers + + * group-toggle. See .planning/phases/05.../05-04-SUMMARY.md for rationale. + * + * TabbedGhostPicker (D3.1-14) — custom @clack/core.MultiSelectPrompt subclass + * that replaces Phase 2's flat groupMultiselect with a tabbed category view. + * + * Motivation: Phase 2's groupMultiselect has no windowing — when ghosts.length + * exceeds terminal rows, the cursor scrolls off-screen. This subclass splits + * ghosts into one tab per non-empty category (D3.1-04), with a bounded viewport + * per tab (D3.1-05: `Math.max(8, (stdout.rows ?? 24) - 10)`) so long lists are + * structurally impossible to render past the terminal. + * + * Decisions implemented here: D3.1-01 (Tab + Shift-Tab + ← → cycle tabs), + * D3.1-02 (1–6 re-indexed over visible tabs), D3.1-03 (wrap), D3.1-04 (empty + * hidden), D3.1-05 (viewport formula), D3.1-06 (↑/↓ N more indicators), + * D3.1-07 (PageUp/PageDown/Home/End scoped to active tab), D3.1-08 (per-tab + * cursor memory), D3.1-09 (tab bar + per-tab header), D3.1-11 (compact hint), + * D3.1-12 (renderTokenCounter stub for Phase 4 handshake), D3.1-15 (cross-tab + * Set selection; a scoped to active tab only). + * + * Plan 03 will wire this into select-ghosts.ts as a thin adapter. This plan + * does NOT modify select-ghosts.ts — the class and openTabbedPicker helper + * live here standalone. + */ +import { MultiSelectPrompt, isCancel } from '@clack/core'; +import { canonicalItemId, formatTokensApprox, isProtected } from '@ccaudit/internal'; +import type { TokenCostResult } from '@ccaudit/internal'; +import pc from 'picocolors'; +import { computeViewportHeight, windowRows } from './_viewport.ts'; +import { GLYPHS_ASCII, GLYPHS_UNICODE } from './_glyphs.ts'; +import { renderTabBar, type TabDescriptor } from './_tab-bar.ts'; +import { CATEGORY_ORDER, CATEGORY_LABEL, formatRowLabel } from './select-ghosts.ts'; +import { + matchesQuery, + sortItems, + nextSort, + sanitizeFilterQuery, + defaultFilterSortState, + type FilterSortState, +} from './_filter-sort.ts'; +import { renderHelpOverlay } from './_help-overlay.ts'; +import { dimLine, protectedHintLine, renderProtectedPrefix } from './_protection-render.ts'; +import { alsoInHintLine, renderMcpWarningPrefix } from './_mcp-warning-render.ts'; +import { bannerHeight, renderForcePartialBanner } from './_force-partial-banner.ts'; + +// --------------------------------------------------------------------------- +// Public types +// --------------------------------------------------------------------------- + +export interface TabbedPickerInput { + /** Items already filtered to ghost tier by caller. */ + ghosts: readonly TokenCostResult[]; + /** From shouldUseAscii() at CLI entry (D-16 invariant). */ + useAscii: boolean; + /** Injected for testability — defaults to Date.now(). */ + now?: number; + /** Injected for testability — defaults to process.stdout.rows. */ + stdoutRows?: number; + /** Injected for testability — defaults to process.stdout.columns ?? 80. */ + terminalCols?: number; + /** Test escape hatch: force a specific viewport height regardless of stdoutRows. */ + viewportHeightOverride?: number; + /** Phase 4 handshake (D3.1-12): footer-slot renderer. Returns '' this phase. */ + renderTokenCounter?: () => string; + /** + * Phase 8.1 D81-02 (C1b): optional custom footer renderer. When provided and + * the picker is NOT in filter mode, substitutes the hard-coded ghost-side + * `"${selected} of ${total} selected across all tabs"` global-line template. + * Restore adapter passes `(n, m) => \`${n} selected · ${m} archived\``; ghost + * side passes nothing → current behavior preserved byte-for-byte. + */ + renderFooter?: (selected: number, total: number) => string; + /** + * Phase 6 Plan 02/03 (D6-13 / D6-16): when true, framework-protected rows + * render normally (no dim, no lock glyph), and all bulk-toggle paths treat + * them as selectable. Flag is strictly per-invocation — no setter exposed. + * Plan 03 plumbs this from the CLI `--force-partial` flag. Defaults to + * `false`; picker enforces protection unless explicitly overridden. + */ + forcePartial?: boolean; +} + +export type TabbedPickerOutcome = + | { kind: 'selected'; ids: Set } + | { kind: 'cancel' } + | { kind: 'empty-inventory' }; + +// Internal per-tab state. +interface TabState { + categoryId: string; + label: string; + items: TokenCostResult[]; + // Cursor indexes into the assembled PickerRow[] (items + sub-headers). + cursor: number; + /** + * Phase 9 Plan 02 (D3 / SC3): cursor saved on filter-entry and restored + * on filter-Esc so scroll position survives filter on/off cycles. null + * when no filter session is active. Mirrors applyScroll's reducer shape + * in `_viewport.ts` but lives inline on the per-tab struct so the key + * handler paths can mutate it directly (no Redux-style dispatch here). + */ + savedCursorPreFilter: number | null; +} + +// Phase 5 Plan 04 (D5-17): unified row abstraction — item vs sub-header. +// Sub-header `groupIds` carry the canonical IDs of currently-visible items +// in the framework bucket so `Space` toggles the group atomically (D5-11). +export type PickerRow = + | { kind: 'item'; item: TokenCostResult } + | { kind: 'sub-header'; framework: string; groupIds: string[] }; + +const UNGROUPED_FRAMEWORK_LABEL = 'Ungrouped'; + +// Option shape for @clack/core.MultiSelectPrompt. +interface FlatOption { + value: string; + label: string; + disabled?: boolean; +} + +// --------------------------------------------------------------------------- +// TabbedGhostPicker class +// --------------------------------------------------------------------------- + +export class TabbedGhostPicker extends MultiSelectPrompt { + public tabs: TabState[]; + public activeTabIndex = 0; + public selectedIds: Set; + public tokensById: Map; + /** + * Phase 6 Plan 02: per-canonical-ID reverse index used by toggle-guard + * assertions and group-toggle protection filtering. Built once at + * construction alongside `tokensById`. + */ + public itemsById: Map; + public useAscii: boolean; + public renderTokenCounter: () => string; + /** D81-02: optional footer override; when set and not in filter mode, replaces global-line. */ + public renderFooter?: (selected: number, total: number) => string; + public filterSortByTab: FilterSortState[]; + public filterMode = false; + /** + * Phase 6 Plan 02 / Plan 03 (D6-13, D6-16): immutable per-invocation + * flag that relaxes protection rendering + toggle gating. Set in + * constructor; no setter exposed. + */ + public readonly forcePartial: boolean; + + /** + * Phase 5 D5-13: modal help overlay flag. When true, the picker swallows + * every key except `?` (toggle off) and `Esc` (close); cursor actions are + * also swallowed EXCEPT `cancel` (Ctrl+C), which must remain live to + * preserve INV-S2 (see T-05-02 mitigation). Render branches on this flag + * at the top of `_renderFrame()` and returns `renderHelpOverlay(...)`. + */ + public helpOpen = false; + private viewportHeightOverride?: number; + private now: number; + private stdoutRows?: number; + private terminalCols: number; + private _resizeHandler: (() => void) | null = null; + private _resizeThrottleTimer: ReturnType | null = null; + + constructor(input: TabbedPickerInput) { + // Partition ghosts into per-category tabs (CATEGORY_ORDER) with descending + // token sort (D-12). Empty categories are dropped (D3.1-04). + // + // WR-02 exhaustiveness guard: refuse ghosts whose category is not in + // CATEGORY_ORDER. The static TypeScript union rules this out today, but + // relying on the union alone means a future domain-type expansion (or an + // `as` cast upstream) would silently drop items from every tab — a + // data-loss regression. Failing loud here surfaces the mismatch in tests + // and during development. + const grouped: Record = {}; + for (const cat of CATEGORY_ORDER) grouped[cat] = []; + const knownCategories = new Set(CATEGORY_ORDER); + for (const g of input.ghosts) { + const cat = g.item.category; + if (!knownCategories.has(cat)) { + throw new Error( + `TabbedGhostPicker: unknown category '${cat}' — update CATEGORY_ORDER in select-ghosts.ts`, + ); + } + // Phase 3.2 SC6: hooks are advisory-only until archival semantics are designed. + // Skip them here so the HOOKS tab never appears in the picker. WR-02 exhaustiveness + // guard still fires for any truly unknown category (checked above). Hooks stay in + // the ghost report and in the token totals under --include-hooks. + if (cat === 'hook') continue; + if (!grouped[cat]) grouped[cat] = []; + grouped[cat].push(g); + } + for (const cat of CATEGORY_ORDER) { + grouped[cat]!.sort((a, b) => (b.tokenEstimate?.tokens ?? 0) - (a.tokenEstimate?.tokens ?? 0)); + } + const tabs: TabState[] = []; + for (const cat of CATEGORY_ORDER) { + const items = grouped[cat]!; + if (items.length === 0) continue; + tabs.push({ + categoryId: cat, + label: CATEGORY_LABEL[cat] ?? cat.toUpperCase(), + items, + cursor: 0, + savedCursorPreFilter: null, + }); + } + if (tabs.length === 0) { + throw new Error('TabbedGhostPicker: no non-empty categories; caller must short-circuit'); + } + + // Build flat options array for MultiSelectPrompt's contract. + // We override render() below so this array is never displayed flat — but + // the base class inspects .length and cursor, so it must be populated. + const now = input.now ?? Date.now(); + const useAscii = input.useAscii; + const flatOptions: FlatOption[] = []; + for (const tab of tabs) { + for (const item of tab.items) { + flatOptions.push({ + value: canonicalItemId(item.item), + label: formatRowLabel(item, useAscii, now), + }); + } + } + + super({ + options: flatOptions, + required: false, + render() { + // Delegate to the instance method; `this` is the Prompt instance. + // Cast because render() returns string | undefined. + return (this as unknown as TabbedGhostPicker)._renderFrame(); + }, + }); + + this.tabs = tabs; + this.filterSortByTab = tabs.map(() => defaultFilterSortState()); + // Phase 4 D4-01 / D4-12: pre-compute a tokens-by-canonical-id map once at + // construction so each render pass is an O(|selection|) Map lookup rather + // than an O(|catalog|) scan. Hooks are already filtered by the constructor + // above, so this map reflects only tabbed items. + const tokensById = new Map(); + const itemsById = new Map(); + for (const tab of tabs) { + for (const item of tab.items) { + const id = canonicalItemId(item.item); + tokensById.set(id, item.tokenEstimate?.tokens ?? 0); + itemsById.set(id, item); + } + } + this.tokensById = tokensById; + this.itemsById = itemsById; + this.selectedIds = new Set(); + this.useAscii = useAscii; + this.forcePartial = input.forcePartial === true; + this.now = now; + this.stdoutRows = input.stdoutRows; + this.terminalCols = input.terminalCols ?? 80; + if (input.renderTokenCounter !== undefined) { + // Test/legacy caller seam (D3.1-12). Overrides the live Phase 4 renderer. + this.renderTokenCounter = input.renderTokenCounter; + } else { + // Phase 4 D4-03 / D4-10: live footer implementation replaces the Phase 3.1 + // no-op stub. Signature unchanged so the _renderFrame() layout contract + // (D3.1-12) holds. + this.renderTokenCounter = () => { + const total = this._computeSelectionTotal(); + return formatTokensApprox(total, { ascii: this.useAscii }); + }; + } + this.viewportHeightOverride = input.viewportHeightOverride; + this.renderFooter = input.renderFooter; + + // Key dispatch: char-based keys + modifier-based tab switching. + // info.name is a node:readline Key.name (e.g. 'tab', 'pageup', 'home'). + // The base class auto-calls render() after every keypress, so mutating + // instance state here is sufficient — no explicit render call needed. + this.on('key', (char, info) => { + // Phase 5 D5-13: help overlay gate. `?` toggles the overlay from ANY + // state (including filter mode — per CONTEXT "Claude's Discretion": + // `?` is always routed to help). While open, swallow every key except + // `?` (toggle off) and `Esc` (close). Ctrl+C remains live via the + // base class's cancel handler (INV-S2, T-05-02 mitigation). + if (this.helpOpen) { + if (char === '?' || info?.name === 'escape') { + this.helpOpen = false; + this.state = 'active'; + } + // Every other key is swallowed while help is open. + return; + } + if (char === '?') { + this.helpOpen = true; + this.state = 'active'; + return; + } + // Phase 5 filter-input mode (D5-04, D5-05). Priority over all other + // non-cancel bindings: typed chars/backspace mutate the query, Esc + // clears+exits in one stroke, Enter exits but keeps query. + // Ctrl+C is still honored because @clack/core's base cancel handler + // processes it before or independently of this listener — we never + // swallow it here (INV-S2). + if (this.filterMode) { + const st = this.filterSortByTab[this.activeTabIndex]; + if (!st) return; + if (info?.name === 'escape') { + st.query = ''; + st.active = false; + this.filterMode = false; + // Phase 9 Plan 02 (D3 / SC3): restore pre-filter cursor so + // scroll position survives filter on/off toggles. + const tab = this.tabs[this.activeTabIndex]; + if (tab && tab.savedCursorPreFilter !== null) { + tab.cursor = tab.savedCursorPreFilter; + tab.savedCursorPreFilter = null; + } + this._clampActiveCursor(); + this.state = 'active'; + return; + } + if (info?.name === 'return') { + this.filterMode = false; + if (st.query === '') st.active = false; + // Keep savedCursorPreFilter while the submitted filter remains + // active. A later Esc in normal picker mode clears the filter first + // and restores/clamps this cursor instead of canceling the picker. + this.state = 'active'; + return; + } + if (info?.name === 'backspace') { + st.query = st.query.slice(0, -1); + if (st.query === '') st.active = false; + else st.active = true; + this._clampActiveCursor(); + this.state = 'active'; + return; + } + // Tab / Shift-Tab exit filter mode and delegate to tab-switch logic + // (the tab-switch itself clears the departing tab's filter per D5-03). + if (info?.name === 'tab') { + this.filterMode = false; + if (info.shift === true) this.prevTab(); + else this.nextTab(); + this.state = 'active'; + return; + } + // Printable character append (codepoint ≥ 32, not DEL, single-char + // only). `/`, `s`, alphanumerics, punctuation all route here (D5-04). + if (typeof char === 'string' && char.length === 1) { + const code = char.charCodeAt(0); + if (code >= 32 && code !== 127) { + st.query = sanitizeFilterQuery(st.query + char); + st.active = true; + // Reset cursor to 0 on query change — the visible slice is new. + this.tabs[this.activeTabIndex]!.cursor = 0; + this.state = 'active'; + return; + } + } + // In filter mode all other keys are swallowed. Cancel (Ctrl+C) is + // handled by the base class independent of this listener. + return; + } + + // Phase 9 D2: in normal picker mode, Esc clears an already-submitted + // active filter before it can cancel the picker. A second Esc (with no + // active filter) still reaches the base cancel path. + if (info?.name === 'escape') { + const st = this.filterSortByTab[this.activeTabIndex]; + if (st?.active === true && st.query !== '') { + st.query = ''; + st.active = false; + const tab = this.tabs[this.activeTabIndex]; + if (tab && tab.savedCursorPreFilter !== null) { + tab.cursor = tab.savedCursorPreFilter; + tab.savedCursorPreFilter = null; + } + this._clampActiveCursor(); + this.state = 'active'; + return; + } + } + + // Phase 4 integration test seam (gated on env — production path is dead code). + // The pty-based SIGWINCH test cannot fire a real 'resize' event because the + // child process stdout is a pipe, not a TTY. CCAUDIT_TEST_RESIZE=1 lets the + // test send Ctrl+R to invoke _handleResize() directly. Optional env var + // CCAUDIT_TEST_RESIZE_ROWS drives the post-resize stdoutRows. + if (process.env.CCAUDIT_TEST_RESIZE === '1' && char === '\x12') { + // Invoke the resize handler, THEN overwrite stdoutRows with the forced + // value (handleResize reads process.stdout.rows which is undefined on + // piped stdio, so it would otherwise clobber the test-supplied value). + this._handleResize(); + const forcedRows = process.env.CCAUDIT_TEST_RESIZE_ROWS; + if (forcedRows !== undefined && /^\d+$/.test(forcedRows)) { + this.stdoutRows = parseInt(forcedRows, 10); + } + // Nudge @clack/core to re-render after the forced rows take effect. + this.state = 'active'; + return; + } + // Tab switching: Tab / Shift-Tab. + if (info?.name === 'tab') { + if (info.shift === true) this.prevTab(); + else this.nextTab(); + return; + } + // Page / Home / End — scoped to active tab (D3.1-07). + if (info?.name === 'pageup') { + this.cursorPageUp(); + return; + } + if (info?.name === 'pagedown') { + this.cursorPageDown(); + return; + } + if (info?.name === 'home') { + this.cursorHome(); + return; + } + if (info?.name === 'end') { + this.cursorEnd(); + return; + } + // q — cancel alias (Esc / Ctrl-C handled by base via default aliases). + if (char === 'q') { + this.cancel(); + return; + } + // a — toggle-all-in-active-tab (D3.1-15; overrides base class's toggleAll). + if (char === 'a') { + this.toggleAllInActiveTab(); + return; + } + // Numeric 1..6 — jump to visible tab index N-1 (D3.1-02). + if ( + char === '1' || + char === '2' || + char === '3' || + char === '4' || + char === '5' || + char === '6' + ) { + const idx = parseInt(char, 10) - 1; + this.jumpToTab(idx); + return; + } + // Phase 5 D5-01: '/' enters filter input mode for the active tab. + if (char === '/') { + const st = this.filterSortByTab[this.activeTabIndex]; + if (st) { + this.filterMode = true; + st.active = true; + // Phase 9 Plan 02 (D3 / SC3): save pre-filter cursor for + // Esc-restore. Idempotent — don't overwrite outer save. + const tab = this.tabs[this.activeTabIndex]; + if (tab && tab.savedCursorPreFilter === null) tab.savedCursorPreFilter = tab.cursor; + } + this.state = 'active'; + return; + } + // Phase 5 D5-08: 's' cycles the active tab's sort mode. + if (char === 's') { + const st = this.filterSortByTab[this.activeTabIndex]; + if (st) { + st.sort = nextSort(st.sort); + this._clampActiveCursor(); + } + this.state = 'active'; + return; + } + }); + + // Cursor dispatch: arrow keys + space + enter + cancel (via base aliases). + // 'left'/'right' also cycle tabs (D3.1-01), matching Tab/Shift-Tab. + this.on('cursor', (action) => { + // Phase 5 D5-13 / T-05-02: while help overlay is open, swallow all + // cursor actions EXCEPT `cancel` — Ctrl+C must still cancel the + // picker (INV-S2). Arrow keys, space, enter are no-ops while help + // is showing; closing the overlay returns control unchanged. + if (this.helpOpen && action !== 'cancel') return; + if (action === 'up') this.cursorUp(); + else if (action === 'down') this.cursorDown(); + else if (action === 'left') this.prevTab(); + else if (action === 'right') this.nextTab(); + else if (action === 'space') this.toggleCurrentRow(); + else if (action === 'enter') this.submit(); + else if (action === 'cancel') this.cancel(); + // Mirror the active tab's cursor onto the base class's cursor for any + // internal consistency checks (though render() is fully overridden). + this.cursor = this.tabs[this.activeTabIndex]?.cursor ?? 0; + }); + + // Phase 5 gap-closure (D5-05, D5-13): wrap the base-class onKeypress so + // that `escape` and `return` keypresses are intercepted while the picker + // is in filter-input mode or has the help overlay open. Without this + // wrapper, `@clack/core@1.2.0`'s `Prompt.onKeypress` unconditionally + // flips `state` to `'cancel'` (escape alias) or `'submit'` (return) at + // the tail of its dispatch — AFTER our `key` listener has set + // `state='active'` — so the picker cancels/submits even when the + // filter-input or help overlay is the intended target of the key. + // + // Targeted override only: Ctrl+C (char === '\x03', info.name undefined) + // still reaches the base path and cancels the picker (INV-S2). Escape + // and return in the normal picker state (no filter, no help) also still + // reach the base path, preserving Phase 3.1's cancel-on-Esc and + // submit-on-Enter behavior. We re-emit `key` manually when we + // short-circuit so the existing in-constructor key listener still runs + // its mode-specific handling (clear query + exit filter on escape, exit + // filter but keep query on return, close help overlay on escape). + // + // We do NOT call `updateSettings({ aliases: {} })` from @clack/core — + // that would de-alias escape/return globally for every prompt the host + // process ever opens, a blast radius far larger than this targeted fix. + // + // Note on mechanics: @clack/core's Prompt binds its own onKeypress + // (`this.onKeypress = this.onKeypress.bind(this)`) in its constructor + // and attaches it as a listener in prompt(). Reassigning `this.onKeypress` + // here (before prompt() runs) installs OUR wrapper as the listener. + // The wrapper captures the original bound dispatcher and delegates to + // it for every key that doesn't match the escape/return + filter/help + // suppression condition. + const originalOnKeypress = ( + this as unknown as { onKeypress: (char: string | undefined, info: unknown) => void } + ).onKeypress.bind(this); + // Cast to `any` because @clack/core marks onKeypress as private — our + // targeted reassignment is a deliberate breach of the visibility + // modifier that matches the documented behavior of the class field. + ( + this as unknown as { onKeypress: (char: string | undefined, info: unknown) => void } + ).onKeypress = (char: string | undefined, info: unknown): void => { + const keyInfo = info as + | { name?: string; sequence?: string; shift?: boolean; ctrl?: boolean } + | undefined; + const activeFilter = this.filterSortByTab[this.activeTabIndex]; + const activeSubmittedFilter = + activeFilter?.active === true && activeFilter.query !== '' && !this.filterMode; + const suppressing = this.filterMode || this.helpOpen || activeSubmittedFilter; + const isEscape = keyInfo?.name === 'escape'; + const isReturn = keyInfo?.name === 'return'; + if (suppressing && (isEscape || (isReturn && (this.filterMode || this.helpOpen)))) { + if (this.state === 'error') this.state = 'active'; + // Emit 'key' so the existing constructor-installed listener runs + // the mode-specific logic and resets state to 'active'. The base + // `render()` isn't accessible from outside the class, but emitting + // 'value' or reassigning state='active' is enough to trigger the + // next render on the `@clack/core` event loop: the constructor's + // key handler assigns `this.state = 'active'` which the base class + // treats as a render-worthy state transition the next time render + // is invoked. To be safe and match the base's own tail, we also + // call the private `render` via a cast. + this.emit('key', char?.toLowerCase(), keyInfo); + (this as unknown as { render: () => void }).render(); + return; + } + originalOnKeypress(char, info); + }; + } + + // --------------------------------------------------------------------------- + // Tab navigation + // --------------------------------------------------------------------------- + + /** + * Phase 5 D5-11: compute visible items for tab — sort first, then filter. + * Sort is always applied (default `staleness-desc` is identical to pre-sort + * scan output for backward compat). Filter narrows only when `state.active` + * AND query is non-empty. + */ + public visibleItemsForTab(tabIdx: number): TokenCostResult[] { + const state = this.filterSortByTab[tabIdx]; + const tab = this.tabs[tabIdx]; + if (!state || !tab) return []; + const sorted = sortItems(tab.items, state.sort, this.now); + if (state.active && state.query !== '') { + return sorted.filter((x) => matchesQuery(x.item.name, state.query)); + } + return sorted; + } + + private _visibleActive(): TokenCostResult[] { + return this.visibleItemsForTab(this.activeTabIndex); + } + + // Phase 5 Plan 04 (D5-17/D5-18): assemble the unified PickerRow[] for a tab. + // Bucket visible items by `item.framework` in first-seen order (D5-11). Emit + // a sub-header before each bucket only when ≥ 2 distinct framework values + // exist; otherwise render flat (D5-18 "n/a" path). groupIds reflect only + // currently-visible members so group-toggle matches what's on screen. + public assembleRowsForTab(tabIdx: number): PickerRow[] { + const visible = this.visibleItemsForTab(tabIdx); + if (visible.length === 0) return []; + const order: string[] = []; + const buckets = new Map(); + for (const x of visible) { + const fw = + typeof x.item.framework === 'string' && x.item.framework !== '' + ? x.item.framework + : UNGROUPED_FRAMEWORK_LABEL; + if (!buckets.has(fw)) { + buckets.set(fw, []); + order.push(fw); + } + buckets.get(fw)!.push(x); + } + if (order.length < 2) { + return visible.map((item) => ({ kind: 'item' as const, item })); + } + const rows: PickerRow[] = []; + for (const fw of order) { + const groupItems = buckets.get(fw)!; + const groupIds = groupItems.map((g) => canonicalItemId(g.item)); + rows.push({ kind: 'sub-header', framework: fw, groupIds }); + for (const item of groupItems) rows.push({ kind: 'item', item }); + } + return rows; + } + + public hasFrameworkSubHeaders(tabIdx: number): boolean { + return this.assembleRowsForTab(tabIdx).some((r) => r.kind === 'sub-header'); + } + + private _rowsActive(): PickerRow[] { + return this.assembleRowsForTab(this.activeTabIndex); + } + + /** + * Phase 5 D5-03: on tab switch, clear the DEPARTING tab's filter (query + + * active flag). Sort mode on the departing tab is preserved per D5-09. + * Global `filterMode` is always exited on tab switch. Active tab's cursor + * is clamped to the new visible-slice bounds. + */ + private _onTabSwitch(): void { + const departing = this.filterSortByTab[this.activeTabIndex]; + if (departing) { + departing.active = false; + departing.query = ''; + } + this.filterMode = false; + } + + private _clampActiveCursor(): void { + const t = this.tabs[this.activeTabIndex]; + if (!t) return; + const rlen = this._rowsActive().length; + t.cursor = Math.min(t.cursor, Math.max(0, rlen - 1)); + } + + public nextTab(): void { + this._onTabSwitch(); + this.activeTabIndex = (this.activeTabIndex + 1) % this.tabs.length; + this._clampActiveCursor(); + } + + public prevTab(): void { + this._onTabSwitch(); + this.activeTabIndex = (this.activeTabIndex - 1 + this.tabs.length) % this.tabs.length; + this._clampActiveCursor(); + } + + public jumpToTab(index: number): void { + if (index < 0 || index >= this.tabs.length) return; // no-op (D3.1-02) + this._onTabSwitch(); + this.activeTabIndex = index; + this._clampActiveCursor(); + } + + // --------------------------------------------------------------------------- + // Cursor navigation within active tab + // --------------------------------------------------------------------------- + + private activeTab(): TabState { + return this.tabs[this.activeTabIndex]!; + } + + public cursorUp(): void { + const t = this.activeTab(); + t.cursor = Math.max(0, t.cursor - 1); + } + + public cursorDown(): void { + const t = this.activeTab(); + const rlen = this._rowsActive().length; + t.cursor = Math.min(Math.max(0, rlen - 1), t.cursor + 1); + } + + public cursorPageUp(): void { + const t = this.activeTab(); + const vh = computeViewportHeight({ + rowsOverride: this.viewportHeightOverride, + stdoutRows: this.stdoutRows, + bannerRows: bannerHeight({ + active: this.forcePartial, + protectedCount: this._countProtected(), + ascii: this.useAscii, + terminalCols: this.terminalCols, + }), + }); + t.cursor = Math.max(0, t.cursor - vh); + } + + public cursorPageDown(): void { + const t = this.activeTab(); + const vh = computeViewportHeight({ + rowsOverride: this.viewportHeightOverride, + stdoutRows: this.stdoutRows, + bannerRows: bannerHeight({ + active: this.forcePartial, + protectedCount: this._countProtected(), + ascii: this.useAscii, + terminalCols: this.terminalCols, + }), + }); + const rlen = this._rowsActive().length; + t.cursor = Math.min(Math.max(0, rlen - 1), t.cursor + vh); + } + + public cursorHome(): void { + this.activeTab().cursor = 0; + } + + public cursorEnd(): void { + const t = this.activeTab(); + const rlen = this._rowsActive().length; + t.cursor = Math.max(0, rlen - 1); + } + + // --------------------------------------------------------------------------- + // Selection operations + // --------------------------------------------------------------------------- + + public toggleCurrentRow(): void { + if (this._terminalTooSmall()) return; // D4-08 + const t = this.activeTab(); + // Phase 5 Plan 04 (D5-17): item → per-row toggle; sub-header → group + // select-all-or-clear (anyUnselected ⇒ add all; else remove all). + const row = this._rowsActive()[t.cursor]; + if (!row) return; + if (row.kind === 'item') { + // Phase 6 Plan 02 (D6-09): silent no-op on protected rows when + // --force-partial is OFF. Dim + [🔒] + below-cursor reason already + // communicate un-selectability; a beep/error would be noisy. + if (!this.forcePartial && isProtected(row.item.item)) return; + const id = canonicalItemId(row.item.item); + if (this.selectedIds.has(id)) this.selectedIds.delete(id); + else this.selectedIds.add(id); + this._assertNoProtectedSelected(); + return; + } + // Phase 6 Plan 02 (D6-12): framework-group toggle skips protected + // members when --force-partial is OFF. If every member is protected, + // the toggle is a no-op. + const { groupIds } = row; + const eligibleIds = this.forcePartial + ? groupIds + : groupIds.filter((id) => { + const it = this.itemsById.get(id); + return it === undefined || !isProtected(it.item); + }); + if (eligibleIds.length === 0) return; + const anyUnselected = eligibleIds.some((id) => !this.selectedIds.has(id)); + if (anyUnselected) for (const id of eligibleIds) this.selectedIds.add(id); + else for (const id of eligibleIds) this.selectedIds.delete(id); + this._assertNoProtectedSelected(); + } + + /** + * Phase 6 Plan 02 — post-mutation invariant: when --force-partial is + * OFF, no protected canonical ID may appear in `selectedIds` (D6-11). + * Under in-source vitest, fails loud to surface regressions; in + * production runtime, silently drops any stray protected IDs + * (belt-and-braces — server-side INV-S6 in `runBust` is the real gate). + */ + private _assertNoProtectedSelected(): void { + if (this.forcePartial) return; + const offenders: string[] = []; + for (const id of this.selectedIds) { + const it = this.itemsById.get(id); + if (it !== undefined && isProtected(it.item)) offenders.push(id); + } + if (offenders.length === 0) return; + if (import.meta.vitest !== undefined) { + throw new Error( + `TabbedGhostPicker invariant violated: protected id(s) entered selection ` + + `while forcePartial=false: ${offenders.join(', ')}`, + ); + } + for (const id of offenders) this.selectedIds.delete(id); + } + + /** + * D3.1-15: `a` scope is active tab only in v0.5. If every item in the active + * tab is already selected, deselect them all; otherwise select them all. + * + * Phase 5: when a filter is active on this tab, operate ONLY on currently + * VISIBLE items (GitHub/Gmail-style select-all-or-clear on the filtered + * group, per D5-17). Hidden selections are preserved. + */ + public toggleAllInActiveTab(): void { + if (this._terminalTooSmall()) return; // D4-08. + const t = this.activeTab(); + const state = this.filterSortByTab[this.activeTabIndex]; + const filterActive = state?.active === true && state.query !== ''; + const baseSource = filterActive ? this._visibleActive() : t.items; + // Phase 6 Plan 02 (D6-10): exclude protected items from the toggle-all + // candidate set when --force-partial is OFF. They're not selectable, + // so they shouldn't participate in the "select-all-or-clear" decision. + const source = this.forcePartial ? baseSource : baseSource.filter((i) => !isProtected(i.item)); + const ids = source.map((i) => canonicalItemId(i.item)); + const allSelected = ids.length > 0 && ids.every((id) => this.selectedIds.has(id)); + if (allSelected) { + for (const id of ids) this.selectedIds.delete(id); + } else { + for (const id of ids) this.selectedIds.add(id); + } + this._assertNoProtectedSelected(); + } + + /** + * Phase 4 D4-12: sum the tokens of the currently selected canonical ids. + * O(|selectedIds|) — catalog map is built once at construction. + */ + private _computeSelectionTotal(): number { + let total = 0; + for (const id of this.selectedIds) { + total += this.tokensById.get(id) ?? 0; + } + return total; + } + + /** + * Phase 4 D4-04: tokens for the currently-active tab only. + * Used in the per-tab header to render `(N/M · ≈ Xk)`. + */ + private _computeActiveTabTokens(): number { + const t = this.tabs[this.activeTabIndex]!; + let total = 0; + for (const item of t.items) { + const id = canonicalItemId(item.item); + if (this.selectedIds.has(id)) { + total += this.tokensById.get(id) ?? 0; + } + } + return total; + } + + /** + * Phase 6 Plan 03 (NEW-M3): count items flagged as framework-protected + * across all tabs. Extracted from the inline loop in `_renderFrame` so + * the same figure can be passed to `bannerHeight` without an O(items) + * scan on every PageUp/PageDown keystroke. + */ + private _countProtected(): number { + let count = 0; + for (const item of this.itemsById.values()) { + if (isProtected(item.item)) count++; + } + return count; + } + + /** + * Phase 4 D4-08: sub-minimum terminal cliff. Below 14 rows or 60 cols we + * render a banner and suppress row interactivity. Cancel keys stay live. + */ + private _terminalTooSmall(): boolean { + const rows = this.stdoutRows ?? 24; + const cols = this.terminalCols; + return rows < 14 || cols < 60; + } + + /** + * Phase 4 D4-06 / D4-09: register a throttled SIGWINCH handler on process.stdout. + * Safe to call multiple times — subsequent calls are no-ops while one is live. + * Always paired with _unregisterResize() on prompt exit. + */ + public _registerResize(): void { + if (this._resizeHandler !== null) return; + // Phase 9 Plan 02 (D5 / SC5): trailing-edge 50ms throttle coalesces + // SIGWINCH bursts on macOS Terminal.app drag-resize. Bind on both + // stdout 'resize' and process 'SIGWINCH' so piped stdio still ticks. + const handler = (): void => { + if (this._resizeThrottleTimer !== null) clearTimeout(this._resizeThrottleTimer); + this._resizeThrottleTimer = setTimeout(() => { + this._resizeThrottleTimer = null; + this._handleResize(); + }, 50); + }; + this._resizeHandler = handler; + process.stdout.on('resize', handler); + process.on('SIGWINCH', handler); + } + + public _unregisterResize(): void { + if (this._resizeHandler !== null) { + process.stdout.off('resize', this._resizeHandler); + process.off('SIGWINCH', this._resizeHandler); + this._resizeHandler = null; + } + if (this._resizeThrottleTimer !== null) { + clearTimeout(this._resizeThrottleTimer); + this._resizeThrottleTimer = null; + } + } + + /** + * Phase 4 D4-07: read the current stdout dimensions into the picker's cached + * values and force a re-render. State preservation is automatic because + * activeTabIndex, per-tab cursor, and selectedIds live on `this` — only the + * geometry changes. D4-08 sub-minimum branch is handled inside _renderFrame. + */ + public _handleResize(): void { + this.stdoutRows = process.stdout.rows; + this.terminalCols = process.stdout.columns ?? 80; + // Nudge the base class to redraw. @clack/core re-renders on state writes. + this.state = 'active'; + } + + // --------------------------------------------------------------------------- + // Submit / cancel + // --------------------------------------------------------------------------- + + public submit(): void { + // Mirror selectedIds Set into the base-class value array so consumers + // of this.value (if any) see the selection. + this.value = Array.from(this.selectedIds); + this.state = 'submit'; + } + + public cancel(): void { + this.state = 'cancel'; + } + + /** + * Phase 4 D4-06: register SIGWINCH on entry, unregister on exit (submit, + * cancel, or exception). Delegates the actual interactive loop to the base + * class's prompt() method. Returns the same Promise shape so + * openTabbedPicker's isCancel(result) check keeps working. + */ + public override async prompt(): Promise { + this._registerResize(); + try { + return await super.prompt(); + } finally { + this._unregisterResize(); + } + } + + // --------------------------------------------------------------------------- + // Rendering + // --------------------------------------------------------------------------- + + public _renderFrame(): string { + // WR-01 guard: @clack/core.MultiSelectPrompt installs its own `on('key', …)` + // and `on('cursor', …)` handlers in its constructor that mutate `this.value` + // via `toggleAll` / `toggleValue` using the flat-options list with `cursor` + // synced to the active tab's LOCAL cursor. Our subclass's handlers run + // AFTER the base, so `this.value` silently diverges from `this.selectedIds` + // between keypresses. The submit() path already overwrites `this.value` + // before state transitions, but any consumer that reads `this.value` + // mid-prompt (or any future @clack/core key binding we don't know about) + // would see incoherent state. Syncing at every render keeps `this.value` + // authoritative across the full lifetime of the prompt — it runs on every + // keypress and is cheap (Set → Array copy). + this.value = Array.from(this.selectedIds); + + // Phase 5 D5-13: help overlay render branch. When open, replace the + // picker frame entirely with `renderHelpOverlay(...)`. Selection, + // cursor, active tab, and filter/sort state are all untouched — they + // resume exactly on close (`?` or `Esc`). + if (this.helpOpen) { + return renderHelpOverlay({ + useAscii: this.useAscii, + rows: this.stdoutRows ?? 24, + cols: this.terminalCols, + }); + } + + // Phase 4 D4-08: sub-minimum terminal branch. We still draw a minimal frame + // so the user sees why interactivity is suppressed AND learns the escape + // hatch. Cancel keys (q / Ctrl+C / Esc) remain live — they are handled in + // the base class key dispatcher and are NOT gated by _terminalTooSmall(). + if (this._terminalTooSmall()) { + const warnGlyph = this.useAscii ? '!' : '⚠'; + return `${warnGlyph} Terminal too small (need ≥14r × 60c). Resize to continue or press q.`; + } + + const lines: string[] = []; + const t = this.activeTab(); + + // Phase 6 Plan 03 (D6-08, D6-14): top-of-TUI `--force-partial` banner. + // Renders on EVERY frame while the flag is on — never only-on-toggle — + // so the mode-wide unlock signal is never hidden. Glyph + text convey + // the warning independently of color (D6-20). + if (this.forcePartial) { + const bannerText = renderForcePartialBanner({ + active: true, + protectedCount: this._countProtected(), + ascii: this.useAscii, + }); + if (bannerText !== '') { + // Color the banner yellow when we're in Unicode / color mode. + // ASCII mode drops color to match the glyph-only signal on dumb + // terminals. Text alone already conveys the warning. + lines.push(this.useAscii ? bannerText : pc.yellow(bannerText)); + } + } + + // 1. Tab bar. + const tabDescriptors: TabDescriptor[] = this.tabs.map((tab) => ({ label: tab.label })); + lines.push( + renderTabBar({ + tabs: tabDescriptors, + activeIndex: this.activeTabIndex, + useAscii: this.useAscii, + terminalCols: this.terminalCols, + }), + ); + + // 2. Per-tab header: `{label} (N/M)` (D3.1-09 / SC3). + // N/M count reflects the underlying tab items (not the visible slice), + // consistent with Phase 3.1/4 semantics. + const selectedInTab = t.items.filter((i) => + this.selectedIds.has(canonicalItemId(i.item)), + ).length; + const totalInTab = t.items.length; + // Phase 4 D4-04: per-tab header extends to `(N/M · ≈ Xk)` when N > 0. + // When N === 0 we suppress the `· ≈ 0k` suffix to keep the header calm. + let header: string; + if (selectedInTab > 0) { + const activeTabTokens = this._computeActiveTabTokens(); + const activeApprox = formatTokensApprox(activeTabTokens, { ascii: this.useAscii }); + // activeApprox is e.g. '≈ 2k tokens' or '~ 2k tokens' or '350 tokens'. + // For the per-tab header we want just the leading approx value without + // the trailing ' tokens' word — strip it to keep the header compact. + const compact = activeApprox.replace(/\s*tokens$/, ''); + if (compact === '') { + header = `${t.label} (${selectedInTab}/${totalInTab})`; + } else { + header = `${t.label} (${selectedInTab}/${totalInTab} · ${compact})`; + } + } else { + header = `${t.label} (${selectedInTab}/${totalInTab})`; + } + // Phase 5 D5-12: append sort label ONLY when sort mode != default. + const activeState = this.filterSortByTab[this.activeTabIndex]; + if (activeState && activeState.sort !== 'staleness-desc') { + const suffix = activeState.sort === 'tokens-desc' ? 'tokens' : 'name'; + header = `${header} · sort:${suffix}`; + } + lines.push(pc.bold(header)); + + // 3–5. Viewport window + ↑/↓ N more (D3.1-06). Plan 04: render PickerRow[]. + // Phase 6 Plan 03 (D6-08): deduct 1 row from the viewport when the + // `--force-partial` banner is visible so the rest of the Phase 3.1 + // formula (`Math.max(8, rows-10)`) stays honest. + const visible = this._visibleActive(); + const rows = this._rowsActive(); + const vh = computeViewportHeight({ + rowsOverride: this.viewportHeightOverride, + stdoutRows: this.stdoutRows, + bannerRows: bannerHeight({ + active: this.forcePartial, + protectedCount: this._countProtected(), + ascii: this.useAscii, + terminalCols: this.terminalCols, + }), + }); + // Clamp cursor into bounds for a safe windowRows call even if prior key + // handling left a stale cursor. + if (rows.length === 0) { + t.cursor = 0; + } else if (t.cursor >= rows.length) { + t.cursor = rows.length - 1; + } + const win = windowRows({ rows, cursor: t.cursor, viewportHeight: vh }); + + // Phase 5 D5-07: empty-result placeholder when filter excludes all rows. + if (visible.length === 0 && activeState?.active === true && activeState.query !== '') { + lines.push(pc.dim('No matches. Press Esc to clear.')); + } else { + if (win.aboveCount > 0) { + const up = this.useAscii ? '^' : '↑'; + lines.push(pc.dim(`${up} ${win.aboveCount} more above`)); + } + + const cursorGlyph = this.useAscii ? '>' : '›'; + // Phase 9 Plan 02 (D4 / SC4): resolve glyphs once per frame. + const glyphs = this.useAscii ? GLYPHS_ASCII : GLYPHS_UNICODE; + for (let i = 0; i < win.slice.length; i++) { + const absIdx = win.sliceStart + i; + const row = win.slice[i]!; + const isCursor = absIdx === t.cursor; + const cursorMark = isCursor ? cursorGlyph : ' '; + if (row.kind === 'sub-header') { + const dashes = this.useAscii ? '--' : '──'; + lines.push(pc.dim(`${cursorMark} ${dashes} ${row.framework} ${dashes}`)); + } else { + const id = canonicalItemId(row.item.item); + const isSelected = this.selectedIds.has(id); + const marker = isSelected ? glyphs.selected : glyphs.unselected; + const label = formatRowLabel(row.item, this.useAscii, this.now); + // Phase 6 Plan 02 (D6-04 / D6-20): protected rows render dim with + // a [🔒] glyph (or [L] in ASCII) BEFORE the selection checkbox. + // When --force-partial is ON, render normally — the banner (plan + // 03) carries the mode-wide unlock signal. Glyph + dim always + // co-occur: never color-alone. + const isLocked = !this.forcePartial && isProtected(row.item.item); + // Phase 6 Plan 04 (D6-06): prepend ⚠ (or !) for multi-config MCP + // rows. Advisory only — does NOT affect selection. Glyph order + // per CONTEXT discretion: [🔒] / [x] / ⚠ / name, so the warning + // sits between the checkbox and the label. + const warnPrefix = renderMcpWarningPrefix(row.item.item, { ascii: this.useAscii }); + if (isLocked) { + const prefix = renderProtectedPrefix(row.item.item, { ascii: this.useAscii }); + const rowBody = `${prefix}${marker} ${warnPrefix}${label}`; + lines.push(`${cursorMark}${dimLine(rowBody, { ascii: this.useAscii })}`); + } else { + lines.push(`${cursorMark} ${marker} ${warnPrefix}${label}`); + } + } + } + + if (win.belowCount > 0) { + const down = this.useAscii ? 'v' : '↓'; + lines.push(pc.dim(`${down} ${win.belowCount} more below`)); + } + } + + // 6. Hint / filter-input line. + // Phase 6 Plan 02 (D6-05): the below-cursor hint slot is SHARED with + // Phase 5's filter input and help hint — no stacking. Protection + // reason takes priority: when the focused row is an item, is + // protected, and --force-partial is OFF, render the scanner-provided + // reason verbatim instead of the Phase 5 filter/help hint. Hidden + // behind filterMode (the user is actively typing a query — filter + // input wins) to preserve Phase 5 interactivity. + const focusedRow = rows[t.cursor]; + const protectionHint = + !this.filterMode && + !this.forcePartial && + focusedRow !== undefined && + focusedRow.kind === 'item' + ? protectedHintLine(focusedRow.item.item, { ascii: this.useAscii }) + : null; + // Phase 6 Plan 04 (D6-07 / D6-21): multi-config MCP hint. Priority + // (after Plan 02): filter-input > protection > multi-config > help. + // Only evaluated when protection hint is absent so the two never stack. + const mcpAlsoInHint = + !this.filterMode && + protectionHint === null && + focusedRow !== undefined && + focusedRow.kind === 'item' + ? alsoInHintLine(focusedRow.item.item, { ascii: this.useAscii }) + : null; + if (this.filterMode) { + // Phase 5 D5-01: footer hint becomes the filter input. The trailing + // underscore is an ASCII cursor glyph (D5-21). Defense-in-depth sanitize + // at render time too (T-05-01) so any future code path that bypasses + // the append-time sanitizer still can't inject terminal escapes here. + const echoQuery = sanitizeFilterQuery(activeState?.query ?? ''); + lines.push(`Filter: ${echoQuery}_`); + } else if (protectionHint !== null) { + lines.push(pc.dim(protectionHint)); + } else if (mcpAlsoInHint !== null) { + lines.push(pc.dim(mcpAlsoInHint)); + } else { + const leftArrow = this.useAscii ? '<-' : '←'; + const rightArrow = this.useAscii ? '->' : '→'; + const upArrow = this.useAscii ? '^' : '↑'; + const downArrow = this.useAscii ? 'v' : '↓'; + const dot = this.useAscii ? '|' : '·'; + lines.push( + pc.dim( + `Tab ${leftArrow} ${rightArrow} tabs ${dot} ${upArrow}${downArrow} nav ${dot} / search ${dot} ? help ${dot} Space toggle ${dot} a tab-all ${dot} Enter ${rightArrow} ${dot} q cancel`, + ), + ); + } + + // 7. Global count + live token counter on a SINGLE line (D4-03) — OR + // Phase 5 D5-01 filtered-count line when the filter is active on the + // current tab OR the user is typing into the filter input. + const totalItems = this.tabs.reduce((sum, tab) => sum + tab.items.length, 0); + const filterActiveHere = this.filterMode || activeState?.active === true; + + if (filterActiveHere) { + // Phase 5 D5-01 + D5-20: `Filtered: M of N visible · X selected [(incl. hidden)]?` + // where M = visible count on active tab, N = total items on active tab, + // X = ALL selections across all tabs (including hidden). Append + // `(incl. hidden)` ONLY when at least one selected id is NOT in the + // currently-visible slice of ANY tab. + const M = visible.length; + const N = t.items.length; + const X = this.selectedIds.size; + + // Compute the set of visible canonical ids across all tabs to determine + // whether any selections are hidden. This is O(sum of visible lengths) + // per render, which is bounded by total catalog size — same cost class + // as the existing per-tab selection scan. + const visibleIds = new Set(); + for (let i = 0; i < this.tabs.length; i++) { + const vi = this.visibleItemsForTab(i); + for (const item of vi) visibleIds.add(canonicalItemId(item.item)); + } + let hiddenSelected = 0; + for (const id of this.selectedIds) { + if (!visibleIds.has(id)) hiddenSelected++; + } + const dotFilter = this.useAscii ? '|' : '·'; + const hiddenSuffix = hiddenSelected > 0 ? ' (incl. hidden)' : ''; + lines.push(`Filtered: ${M} of ${N} visible ${dotFilter} ${X} selected${hiddenSuffix}`); + } else { + const counterSuffix = this.renderTokenCounter(); + // counterSuffix is '' or '≈ Zk tokens' or '350 tokens' (or '~ Zk tokens' in ASCII mode). + // Rewrite it to `≈ Zk tokens saved` / '350 tokens saved' / ''. + const counterDisplay = counterSuffix === '' ? '' : `${counterSuffix} saved`; + const dotGlobal = this.useAscii ? '|' : '·'; + // D81-02 (C1b): when a custom renderFooter is provided (restore path), + // substitute its output for the hard-coded ghost-side template. Ghost + // side leaves renderFooter undefined → byte-identical behavior. + const globalLine = + this.renderFooter !== undefined + ? this.renderFooter(this.selectedIds.size, totalItems) + : counterDisplay === '' + ? `${this.selectedIds.size} of ${totalItems} selected across all tabs` + : `${this.selectedIds.size} of ${totalItems} selected across all tabs ${dotGlobal} ${counterDisplay}`; + lines.push(globalLine); + } + + return lines.join('\n'); + } +} + +// --------------------------------------------------------------------------- +// openTabbedPicker helper +// --------------------------------------------------------------------------- + +/** + * Opens the tabbed picker for the provided ghost items. + * + * Returns: + * - { kind: 'empty-inventory' } if ghosts.length === 0 (no prompt opened) + * - { kind: 'cancel' } if user pressed Ctrl+C / Esc / q + * - { kind: 'selected', ids } on Enter with 0..N items selected + */ +export async function openTabbedPicker(input: TabbedPickerInput): Promise { + // D-13: Empty state — caller should skip picker entirely (matches Phase 2). + if (input.ghosts.length === 0) { + return { kind: 'empty-inventory' }; + } + + const picker = new TabbedGhostPicker(input); + const result = await picker.prompt(); + + if (isCancel(result)) { + return { kind: 'cancel' }; + } + // When the user submits, picker.selectedIds holds the canonical source of truth. + return { kind: 'selected', ids: new Set(picker.selectedIds) }; +} + +// --------------------------------------------------------------------------- +// In-source tests +// --------------------------------------------------------------------------- + +if (import.meta.vitest) { + const { describe, it, expect } = import.meta.vitest; + + /** Minimal TokenCostResult factory. */ + function makeGhost(overrides: { + name: string; + category?: string; + path?: string; + tokens?: number; + mtimeMs?: number; + framework?: string | null; + }): TokenCostResult { + return { + item: { + name: overrides.name, + category: (overrides.category ?? 'agent') as TokenCostResult['item']['category'], + scope: 'global', + projectPath: null, + path: overrides.path ?? `/fake/${overrides.name}`, + ...(overrides.mtimeMs !== undefined ? { mtimeMs: overrides.mtimeMs } : {}), + ...(overrides.framework !== undefined ? { framework: overrides.framework } : {}), + }, + tier: 'definite-ghost', + lastUsed: null, + invocationCount: 0, + tokenEstimate: + overrides.tokens !== undefined + ? { tokens: overrides.tokens, confidence: 'estimated', source: 'test' } + : null, + }; + } + + /** + * Build a picker instance without invoking .prompt() — construction only + * touches the base class's input/output stream setup; the readline + * interface is created lazily inside prompt(). So constructing is safe. + */ + function makePicker( + ghostsOrInput: readonly TokenCostResult[] | TabbedPickerInput, + ): TabbedGhostPicker { + const input: TabbedPickerInput = Array.isArray(ghostsOrInput) + ? { ghosts: ghostsOrInput, useAscii: true, stdoutRows: 24, terminalCols: 120 } + : (ghostsOrInput as TabbedPickerInput); + return new TabbedGhostPicker(input); + } + + // Phase 9 Plan 02 (D5 / SC5) — SIGWINCH listener lifecycle tests. + describe('SIGWINCH listener lifecycle (T-09-05 mitigation)', () => { + it('register + unregister is balanced: process SIGWINCH listener count returns to baseline', () => { + const before = process.listenerCount('SIGWINCH'); + const picker = makePicker([makeGhost({ name: 'a1', category: 'agent', tokens: 1 })]); + picker._registerResize(); + expect(process.listenerCount('SIGWINCH')).toBe(before + 1); + picker._unregisterResize(); + expect(process.listenerCount('SIGWINCH')).toBe(before); + }); + + it('double-register is idempotent (second call is a no-op)', () => { + const before = process.listenerCount('SIGWINCH'); + const picker = makePicker([makeGhost({ name: 'a1', category: 'agent', tokens: 1 })]); + picker._registerResize(); + picker._registerResize(); + expect(process.listenerCount('SIGWINCH')).toBe(before + 1); + picker._unregisterResize(); + expect(process.listenerCount('SIGWINCH')).toBe(before); + }); + + it('double-unregister is safe (second call is a no-op)', () => { + const before = process.listenerCount('SIGWINCH'); + const picker = makePicker([makeGhost({ name: 'a1', category: 'agent', tokens: 1 })]); + picker._registerResize(); + picker._unregisterResize(); + picker._unregisterResize(); + expect(process.listenerCount('SIGWINCH')).toBe(before); + }); + }); + + describe('TabbedGhostPicker', () => { + it('Test 1: constructor partitions three categories into three tabs with correct labels', () => { + const ghosts = [ + makeGhost({ name: 'a1', category: 'agent', tokens: 100 }), + makeGhost({ name: 's1', category: 'skill', tokens: 200 }), + makeGhost({ name: 'c1', category: 'command', tokens: 50 }), + ]; + const picker = makePicker(ghosts); + expect(picker.tabs.length).toBe(3); + // CATEGORY_ORDER is agent, skill, mcp-server, memory, command, hook — so + // the three non-empty categories render in that order (hook would be + // filtered out by the Phase 3.2 SC6 skip even if present). + expect(picker.tabs[0]!.label).toBe('AGENTS'); + expect(picker.tabs[1]!.label).toBe('SKILLS'); + expect(picker.tabs[2]!.label).toBe('COMMANDS'); + }); + + it('Test 2: empty categories are dropped (input has 6 requested cats but only 3 have items)', () => { + // Include items in only 3 of the 6 categories. + const ghosts = [ + makeGhost({ name: 'a1', category: 'agent', tokens: 100 }), + makeGhost({ name: 'mcp1', category: 'mcp-server', tokens: 500 }), + makeGhost({ name: 'cmd1', category: 'command', tokens: 30 }), + ]; + const picker = makePicker(ghosts); + expect(picker.tabs.length).toBe(3); + const ids = picker.tabs.map((t) => t.categoryId); + expect(ids).toEqual(['agent', 'mcp-server', 'command']); + }); + + it('Phase 3.2 SC6: hook-category items are filtered out of tabs entirely', () => { + // Input has 4 categories including hooks; tab list should exclude HOOKS. + const ghosts = [ + makeGhost({ name: 'a1', category: 'agent', tokens: 100 }), + makeGhost({ name: 'mcp1', category: 'mcp-server', tokens: 500 }), + makeGhost({ name: 'cmd1', category: 'command', tokens: 30 }), + makeGhost({ name: 'hook1', category: 'hook', tokens: 2000 }), + makeGhost({ name: 'hook2', category: 'hook', tokens: 2500 }), + ]; + const picker = makePicker(ghosts); + expect(picker.tabs.length).toBe(3); + const ids = picker.tabs.map((t) => t.categoryId); + expect(ids).not.toContain('hook'); + expect(ids).toEqual(['agent', 'mcp-server', 'command']); + }); + + it('Phase 3.2 SC6: WR-02 exhaustiveness guard still fires for unknown categories', () => { + // A category that is NOT in CATEGORY_ORDER must still throw. This protects + // against a future typo where someone adds 'bookmark' (etc.) to the scanner + // without wiring the tab. The hook-skip does NOT loosen this guard — the + // skip is AFTER the knownCategories check. + const rogueGhost = makeGhost({ + name: 'x1', + category: 'not-a-category', + tokens: 100, + }); + expect(() => makePicker([rogueGhost])).toThrow(/unknown category/); + }); + + it('Phase 3.2 SC6: input of ONLY hook items throws the empty-tabs guard', () => { + const ghosts = [ + makeGhost({ name: 'hook1', category: 'hook', tokens: 100 }), + makeGhost({ name: 'hook2', category: 'hook', tokens: 200 }), + ]; + expect(() => makePicker(ghosts)).toThrow(/no non-empty categories/); + }); + + it('Test 3: Space toggles current row canonical ID in selectedIds', () => { + const ghost = makeGhost({ name: 'only', category: 'agent', tokens: 100 }); + const picker = makePicker([ghost]); + expect(picker.selectedIds.size).toBe(0); + picker.toggleCurrentRow(); + expect(picker.selectedIds.size).toBe(1); + const expectedId = 'agent|global||/fake/only'; + expect(picker.selectedIds.has(expectedId)).toBe(true); + // Second toggle deselects. + picker.toggleCurrentRow(); + expect(picker.selectedIds.size).toBe(0); + }); + + it("Test 4: 'a' toggles all items in active tab only (items in other tabs untouched)", () => { + const ghosts = [ + makeGhost({ name: 'a1', category: 'agent', tokens: 100 }), + makeGhost({ name: 'a2', category: 'agent', tokens: 80 }), + makeGhost({ name: 's1', category: 'skill', tokens: 200 }), + makeGhost({ name: 's2', category: 'skill', tokens: 150 }), + ]; + const picker = makePicker(ghosts); + // activeTabIndex = 0 → AGENTS. Toggle-all should select both agents only. + picker.toggleAllInActiveTab(); + expect(picker.selectedIds.size).toBe(2); + expect(picker.selectedIds.has('agent|global||/fake/a1')).toBe(true); + expect(picker.selectedIds.has('agent|global||/fake/a2')).toBe(true); + expect(picker.selectedIds.has('skill|global||/fake/s1')).toBe(false); + expect(picker.selectedIds.has('skill|global||/fake/s2')).toBe(false); + // Toggle again → all deselected in active tab. + picker.toggleAllInActiveTab(); + expect(picker.selectedIds.size).toBe(0); + }); + + it('Test 5: Tab increments activeTabIndex with wrap (after last tab → 0)', () => { + const ghosts = [ + makeGhost({ name: 'a1', category: 'agent', tokens: 100 }), + makeGhost({ name: 's1', category: 'skill', tokens: 200 }), + makeGhost({ name: 'c1', category: 'command', tokens: 50 }), + ]; + const picker = makePicker(ghosts); + expect(picker.activeTabIndex).toBe(0); + picker.nextTab(); + expect(picker.activeTabIndex).toBe(1); + picker.nextTab(); + expect(picker.activeTabIndex).toBe(2); + picker.nextTab(); + expect(picker.activeTabIndex).toBe(0); // wrapped + }); + + it('Test 6: Shift-Tab wraps backwards (from index 0 → last)', () => { + const ghosts = [ + makeGhost({ name: 'a1', category: 'agent', tokens: 100 }), + makeGhost({ name: 's1', category: 'skill', tokens: 200 }), + makeGhost({ name: 'c1', category: 'command', tokens: 50 }), + ]; + const picker = makePicker(ghosts); + expect(picker.activeTabIndex).toBe(0); + picker.prevTab(); + expect(picker.activeTabIndex).toBe(2); // wrapped to last + picker.prevTab(); + expect(picker.activeTabIndex).toBe(1); + }); + + it("Test 7: numeric '1' → index 0; '4' with only 3 tabs → no-op", () => { + const ghosts = [ + makeGhost({ name: 'a1', category: 'agent', tokens: 100 }), + makeGhost({ name: 's1', category: 'skill', tokens: 200 }), + makeGhost({ name: 'c1', category: 'command', tokens: 50 }), + ]; + const picker = makePicker(ghosts); + picker.nextTab(); // move off default 0 + expect(picker.activeTabIndex).toBe(1); + picker.jumpToTab(0); // '1' + expect(picker.activeTabIndex).toBe(0); + picker.jumpToTab(3); // '4' — out of range, no-op + expect(picker.activeTabIndex).toBe(0); + picker.jumpToTab(2); // '3' + expect(picker.activeTabIndex).toBe(2); + }); + + it('Test 8: per-tab cursor preserved — set cursor in tab 0, switch away, switch back → restored', () => { + const ghosts = [ + makeGhost({ name: 'a1', category: 'agent', tokens: 100 }), + makeGhost({ name: 'a2', category: 'agent', tokens: 90 }), + makeGhost({ name: 'a3', category: 'agent', tokens: 80 }), + makeGhost({ name: 's1', category: 'skill', tokens: 200 }), + makeGhost({ name: 's2', category: 'skill', tokens: 150 }), + ]; + const picker = makePicker(ghosts); + // activeTabIndex=0 (AGENTS). Move cursor to row 2. + picker.cursorDown(); + picker.cursorDown(); + expect(picker.tabs[0]!.cursor).toBe(2); + // Switch to tab 1 (SKILLS). Its cursor starts at 0. + picker.nextTab(); + expect(picker.activeTabIndex).toBe(1); + expect(picker.tabs[1]!.cursor).toBe(0); + // Move cursor in SKILLS to row 1. + picker.cursorDown(); + expect(picker.tabs[1]!.cursor).toBe(1); + // Switch back to AGENTS → its cursor is still 2. + picker.prevTab(); + expect(picker.activeTabIndex).toBe(0); + expect(picker.tabs[0]!.cursor).toBe(2); + }); + + it('openTabbedPicker empty-inventory short-circuits without constructing picker', async () => { + const result = await openTabbedPicker({ ghosts: [], useAscii: true }); + expect(result.kind).toBe('empty-inventory'); + }); + + it('cross-tab selection persists across tab navigation (both ArrowRight/Left and Tab/Shift-Tab bindings)', () => { + // Phase 3.1 Plan 04 Task 3 (SC2 + cross-tab state): + // select 2 AGENTS → → (next tab) → select 1 SKILL → ← (prev tab) → + // all 3 canonical IDs survive in selectedIds. Repeat with Tab/Shift-Tab + // → identical final state. + // + // The class dispatches `Tab` key and `ArrowRight` cursor action to the + // SAME method (nextTab), and `Shift-Tab` + `ArrowLeft` to prevTab. The + // binding equivalence is structural in the constructor's key/cursor + // dispatch; this test confirms the observable invariant (final + // selectedIds contents) is identical for both sequences. + function buildFixture(): readonly TokenCostResult[] { + return [ + makeGhost({ name: 'a1', category: 'agent', tokens: 100 }), + makeGhost({ name: 'a2', category: 'agent', tokens: 80 }), + makeGhost({ name: 's1', category: 'skill', tokens: 200 }), + makeGhost({ name: 's2', category: 'skill', tokens: 150 }), + ]; + } + // Sequence A: ArrowRight / ArrowLeft (→ / ← bindings). + { + const picker = makePicker(buildFixture()); + expect(picker.tabs.length).toBe(2); + // activeTabIndex=0 (AGENTS) + picker.toggleCurrentRow(); // select a1 (top-of-tab) + picker.cursorDown(); + picker.toggleCurrentRow(); // select a2 + picker.nextTab(); // → : ArrowRight binding + expect(picker.activeTabIndex).toBe(1); + picker.toggleCurrentRow(); // select s1 (top-of-tab 1) + picker.prevTab(); // ← : ArrowLeft binding + expect(picker.activeTabIndex).toBe(0); + expect(picker.selectedIds.size).toBe(3); + expect(picker.selectedIds.has('agent|global||/fake/a1')).toBe(true); + expect(picker.selectedIds.has('agent|global||/fake/a2')).toBe(true); + expect(picker.selectedIds.has('skill|global||/fake/s1')).toBe(true); + } + // Sequence B: Tab / Shift-Tab bindings. + // nextTab/prevTab are the SAME methods Tab/Shift-Tab dispatch to — + // the point of this block is the contract assertion that the final + // selectedIds shape is identical across the two binding families. + { + const picker = makePicker(buildFixture()); + picker.toggleCurrentRow(); // select a1 via Space + picker.cursorDown(); + picker.toggleCurrentRow(); // select a2 via Space + picker.nextTab(); // Tab binding + expect(picker.activeTabIndex).toBe(1); + picker.toggleCurrentRow(); // select s1 + picker.prevTab(); // Shift-Tab binding + expect(picker.activeTabIndex).toBe(0); + expect(picker.selectedIds.size).toBe(3); + expect(picker.selectedIds.has('agent|global||/fake/a1')).toBe(true); + expect(picker.selectedIds.has('agent|global||/fake/a2')).toBe(true); + expect(picker.selectedIds.has('skill|global||/fake/s1')).toBe(true); + } + }); + + it('WR-01: picker.value stays coherent with selectedIds after render + submit', () => { + // Regression guard for the base-class double-dispatch footgun: + // @clack/core.MultiSelectPrompt's key bindings mutate `this.value` + // independently of our `this.selectedIds` set. `_renderFrame()` now + // resynchronises `this.value` from `selectedIds` at every render so + // the two never silently diverge. `submit()` still overwrites + // `this.value` as a final safety net. + const ghosts = [ + makeGhost({ name: 'a1', category: 'agent', tokens: 100 }), + makeGhost({ name: 'a2', category: 'agent', tokens: 80 }), + makeGhost({ name: 's1', category: 'skill', tokens: 200 }), + ]; + const picker = makePicker(ghosts); + + // Simulate toggling two items across two tabs via our subclass's + // own mutators (the fake "happy path" that does not trigger base + // bindings — but the render-time sync must still mirror the state). + picker.toggleCurrentRow(); // a1 (tab 0 cursor 0) + picker.cursorDown(); + picker.toggleCurrentRow(); // a2 (tab 0 cursor 1) + picker.nextTab(); // tab 1 + picker.toggleCurrentRow(); // s1 + + // Render once — this is the step that synchronises `this.value`. + picker._renderFrame(); + + const valueSorted = [...picker.value!].sort(); + const selectedSorted = Array.from(picker.selectedIds).sort(); + expect(valueSorted).toEqual(selectedSorted); + expect(valueSorted.length).toBe(3); + + // Submit() also forces the sync as the final safety net. + picker.submit(); + const submitValueSorted = [...picker.value!].sort(); + expect(submitValueSorted).toEqual(selectedSorted); + }); + + it('WR-02: constructor throws on ghost whose category is not in CATEGORY_ORDER', () => { + // Exhaustiveness guard against a future domain-type expansion landing + // without updating CATEGORY_ORDER — silently dropping the category + // would be a data-loss regression. makeGhost's `category` param is + // typed `string` and then cast to the union internally, so we can + // exercise a runtime value the static union rules out. + const rogueGhost = makeGhost({ + name: 'rogue', + category: 'nonexistent-category', + tokens: 100, + }); + expect(() => makePicker([rogueGhost])).toThrow(/unknown category 'nonexistent-category'/); + }); + + it('_renderFrame produces non-empty output with tab bar, header, rows, hints, and global count', () => { + const ghosts = [ + makeGhost({ name: 'a1', category: 'agent', tokens: 100 }), + makeGhost({ name: 's1', category: 'skill', tokens: 200 }), + ]; + const picker = makePicker(ghosts); + const frame = picker._renderFrame(); + // Tab bar contains both labels. + expect(frame).toContain('AGENTS'); + expect(frame).toContain('SKILLS'); + // Per-tab header with 0/1 selection. + expect(frame).toContain('AGENTS (0/1)'); + // Row contains the cursor glyph (we used useAscii=true in makePicker). + expect(frame).toContain('>'); + expect(frame).toContain('[ ]'); + // Global count. + expect(frame).toContain('0 of 2 selected across all tabs'); + // Hint line (ASCII arrows from useAscii=true). + expect(frame).toContain('Tab'); + expect(frame).toContain('/ search'); + expect(frame).toContain('? help'); + expect(frame).toContain('Space toggle'); + expect(frame).toContain('q cancel'); + }); + + describe('D81-02 renderFooter prop', () => { + it('ghost path (renderFooter undefined): global-line byte-identical to existing template', () => { + const ghosts = [ + makeGhost({ name: 'a1', category: 'agent', tokens: 100 }), + makeGhost({ name: 's1', category: 'skill', tokens: 200 }), + ]; + const picker = makePicker(ghosts); + const frame = picker._renderFrame(); + expect(frame).toContain('0 of 2 selected across all tabs'); + // Canonical restore template must NOT appear on ghost path. + expect(frame).not.toContain('archived'); + }); + + it('restore path (renderFooter defined): substitutes injected template, ghost template absent', () => { + const ghosts = [ + makeGhost({ name: 'a1', category: 'agent', tokens: 100 }), + makeGhost({ name: 's1', category: 'skill', tokens: 200 }), + ]; + const picker = makePicker({ + ghosts, + useAscii: true, + stdoutRows: 24, + terminalCols: 120, + renderTokenCounter: () => '', + renderFooter: (n, m) => `${n} selected \u00B7 ${m} archived`, + }); + const frame = picker._renderFrame(); + expect(frame).toContain('0 selected \u00B7 2 archived'); + // Ghost template must be fully replaced — not appended. + expect(frame).not.toContain('of 2 selected across all tabs'); + }); + }); + + describe('Phase 4 live token counter', () => { + it('footer drops token suffix when selection is empty', () => { + const ghosts = [ + makeGhost({ name: 'a1', category: 'agent', tokens: 1500 }), + makeGhost({ name: 's1', category: 'skill', tokens: 2000 }), + ]; + const picker = makePicker(ghosts); + const frame = picker._renderFrame(); + expect(frame).toContain('0 of 2 selected across all tabs'); + expect(frame).not.toContain('tokens saved'); + // Per-tab header with N=0 MUST NOT include the subtotal segment. + expect(frame).toContain('AGENTS (0/1)'); + expect(frame).not.toContain('AGENTS (0/1 ·'); + expect(frame).not.toContain('AGENTS (0/1 |'); + }); + + it('footer shows ≈ Zk tokens saved after toggling one 1500-token item (ASCII mode: ~ 2k)', () => { + const ghosts = [ + makeGhost({ name: 'a1', category: 'agent', tokens: 1500 }), + makeGhost({ name: 's1', category: 'skill', tokens: 2000 }), + ]; + const picker = makePicker(ghosts); // useAscii=true from makePicker default + picker.toggleCurrentRow(); + const frame = picker._renderFrame(); + expect(frame).toContain('1 of 2 selected across all tabs | ~ 2k tokens saved'); + expect(frame).toContain('AGENTS (1/1 · ~ 2k)'); + }); + + it('toggleAllInActiveTab updates both the tab header subtotal and the global footer', () => { + const ghosts = [ + makeGhost({ name: 'a1', category: 'agent', tokens: 1000 }), + makeGhost({ name: 'a2', category: 'agent', tokens: 500 }), + makeGhost({ name: 'a3', category: 'agent', tokens: 250 }), + makeGhost({ name: 's1', category: 'skill', tokens: 4000 }), + ]; + const picker = makePicker(ghosts); + picker.toggleAllInActiveTab(); + const frame = picker._renderFrame(); + expect(frame).toContain('AGENTS (3/3 · ~ 2k)'); + expect(frame).toContain('3 of 4 selected across all tabs | ~ 2k tokens saved'); + }); + + it('cross-tab selection sums into the global footer but only the active tab shows its own subtotal in the header', () => { + const ghosts = [ + makeGhost({ name: 'a1', category: 'agent', tokens: 3000 }), + makeGhost({ name: 's1', category: 'skill', tokens: 2000 }), + ]; + const picker = makePicker(ghosts); + picker.toggleCurrentRow(); + picker.nextTab(); + picker.toggleCurrentRow(); + const frame = picker._renderFrame(); + expect(frame).toContain('SKILLS (1/1 · ~ 2k)'); + expect(frame).toContain('2 of 2 selected across all tabs | ~ 5k tokens saved'); + expect(frame).not.toContain('AGENTS (1/1 · ~ 3k)'); + }); + + it('items with null tokenEstimate contribute 0 to both subtotal and footer', () => { + const ghosts = [ + makeGhost({ name: 'a1', category: 'agent' }), + makeGhost({ name: 'a2', category: 'agent', tokens: 1500 }), + ]; + const picker = makePicker(ghosts); + // Tab items sort descending by tokens, so a2 (1500) is at cursor 0 and + // a1 (null → 0) is at cursor 1. Navigate to a1 then toggle. + picker.cursorDown(); + picker.toggleCurrentRow(); + const frame = picker._renderFrame(); + // With a single 0-token selection the subtotal is 0 → header shows + // bare N/M (0 formats to empty); footer shows count line without + // "tokens saved". + expect(frame).toContain('AGENTS (1/2)'); + expect(frame).toContain('1 of 2 selected across all tabs'); + expect(frame).not.toContain('tokens saved'); + }); + }); + + describe('Phase 4 resize + sub-minimum terminal', () => { + it('sub-minimum terminal (stdoutRows=10) returns the banner as the entire frame', () => { + const ghosts = [ + makeGhost({ name: 'a1', category: 'agent', tokens: 100 }), + makeGhost({ name: 's1', category: 'skill', tokens: 200 }), + ]; + const picker = new TabbedGhostPicker({ + ghosts, + useAscii: true, + stdoutRows: 10, + terminalCols: 120, + }); + const frame = picker._renderFrame(); + expect(frame).toContain('! Terminal too small'); + expect(frame).toContain('press q'); + expect(frame).not.toContain('AGENTS ('); + expect(frame).not.toContain('selected across all tabs'); + }); + + it('sub-minimum terminal (terminalCols=40) returns the banner', () => { + const ghosts = [makeGhost({ name: 'a1', category: 'agent', tokens: 100 })]; + const picker = new TabbedGhostPicker({ + ghosts, + useAscii: true, + stdoutRows: 24, + terminalCols: 40, + }); + expect(picker._renderFrame()).toContain('Terminal too small'); + }); + + it('sub-minimum terminal suppresses Space/a interactivity (selection set unchanged)', () => { + const ghosts = [ + makeGhost({ name: 'a1', category: 'agent', tokens: 100 }), + makeGhost({ name: 'a2', category: 'agent', tokens: 200 }), + ]; + const picker = new TabbedGhostPicker({ + ghosts, + useAscii: true, + stdoutRows: 10, + terminalCols: 120, + }); + expect(picker.selectedIds.size).toBe(0); + picker.toggleCurrentRow(); + expect(picker.selectedIds.size).toBe(0); + picker.toggleAllInActiveTab(); + expect(picker.selectedIds.size).toBe(0); + }); + + it('Unicode banner glyph when useAscii=false', () => { + const ghosts = [makeGhost({ name: 'a1', category: 'agent', tokens: 100 })]; + const picker = new TabbedGhostPicker({ + ghosts, + useAscii: false, + stdoutRows: 10, + terminalCols: 120, + }); + expect(picker._renderFrame()).toContain('⚠ Terminal too small'); + }); + + it('caller-provided renderTokenCounter override is honored (D3.1-12 seam preserved)', () => { + const ghosts = [makeGhost({ name: 'a1', category: 'agent', tokens: 100 })]; + const picker = new TabbedGhostPicker({ + ghosts, + useAscii: true, + stdoutRows: 24, + terminalCols: 120, + renderTokenCounter: () => 'CUSTOM_FOOTER', + }); + const frame = picker._renderFrame(); + expect(frame).toContain('CUSTOM_FOOTER'); + }); + + it('_registerResize/_unregisterResize add then remove exactly one stdout resize listener', () => { + const ghosts = [makeGhost({ name: 'a1', category: 'agent', tokens: 100 })]; + const picker = new TabbedGhostPicker({ + ghosts, + useAscii: true, + stdoutRows: 24, + terminalCols: 120, + }); + const before = process.stdout.listenerCount('resize'); + picker._registerResize(); + expect(process.stdout.listenerCount('resize')).toBe(before + 1); + picker._unregisterResize(); + expect(process.stdout.listenerCount('resize')).toBe(before); + }); + }); + + // --------------------------------------------------------------------- + // Phase 5: filter + sort integration + // --------------------------------------------------------------------- + + /** + * Fire a 'key' event at the picker's registered listener. `info.name` is + * the node:readline key name (e.g. 'escape', 'return', 'backspace', + * 'tab'). `char` is the typed character or undefined for named keys. + */ + function fireKey( + picker: TabbedGhostPicker, + char: string | undefined, + info?: { name?: string; shift?: boolean }, + ): void { + (picker as unknown as { emit: (event: string, ...args: unknown[]) => boolean }).emit( + 'key', + char, + info, + ); + } + + describe('Phase 5 filter + sort — Task 1 state + visibility', () => { + it('defaultFilterSortState is set per tab on construction', () => { + const ghosts = [ + makeGhost({ name: 'a1', category: 'agent' }), + makeGhost({ name: 's1', category: 'skill' }), + ]; + const picker = makePicker(ghosts); + expect(picker.filterSortByTab.length).toBe(2); + for (const st of picker.filterSortByTab) { + expect(st).toEqual({ query: '', active: false, sort: 'staleness-desc' }); + } + expect(picker.filterMode).toBe(false); + }); + + it('visibleItemsForTab narrows by name substring (D5-01/D5-02)', () => { + const ghosts = [ + makeGhost({ name: 'alpha', category: 'agent', tokens: 100 }), + makeGhost({ name: 'beta', category: 'agent', tokens: 200 }), + makeGhost({ name: 'gamma-alpha', category: 'agent', tokens: 50 }), + ]; + const picker = makePicker(ghosts); + picker.filterSortByTab[0]!.active = true; + picker.filterSortByTab[0]!.query = 'ALPHA'; + const names = picker.visibleItemsForTab(0).map((x) => x.item.name); + expect(names).toEqual(expect.arrayContaining(['alpha', 'gamma-alpha'])); + expect(names).not.toContain('beta'); + }); + + it('filter does NOT drop selected ids for hidden rows (D5-06)', () => { + const ghosts = [ + makeGhost({ name: 'alpha', category: 'agent', tokens: 100 }), + makeGhost({ name: 'beta', category: 'agent', tokens: 200 }), + ]; + const picker = makePicker(ghosts); + // Select both. + picker.toggleAllInActiveTab(); + expect(picker.selectedIds.size).toBe(2); + // Filter to only 'alpha' — 'beta' is hidden. + picker.filterSortByTab[0]!.active = true; + picker.filterSortByTab[0]!.query = 'alpha'; + expect(picker._renderFrame()).toBeTruthy(); + // Selections preserved. + expect(picker.selectedIds.size).toBe(2); + }); + + it('visibleItemsForTab sorts first then filters (D5-11)', () => { + const ghosts = [ + makeGhost({ name: 'aa', category: 'agent', tokens: 10 }), + makeGhost({ name: 'ab', category: 'agent', tokens: 30 }), + makeGhost({ name: 'ac', category: 'agent', tokens: 20 }), + ]; + const picker = makePicker(ghosts); + picker.filterSortByTab[0]!.sort = 'tokens-desc'; + picker.filterSortByTab[0]!.active = true; + picker.filterSortByTab[0]!.query = 'a'; + const names = picker.visibleItemsForTab(0).map((x) => x.item.name); + // Tokens desc: ab(30), ac(20), aa(10). Filter 'a' matches all. + expect(names).toEqual(['ab', 'ac', 'aa']); + }); + + it('per-tab sort persists across tab switch (D5-09)', () => { + const ghosts = [ + makeGhost({ name: 'a1', category: 'agent' }), + makeGhost({ name: 's1', category: 'skill' }), + ]; + const picker = makePicker(ghosts); + picker.filterSortByTab[0]!.sort = 'tokens-desc'; + picker.nextTab(); + picker.prevTab(); + expect(picker.filterSortByTab[0]!.sort).toBe('tokens-desc'); + }); + + it('tab switch resets the departing tab filter query + active flag (D5-03); sort mode preserved', () => { + const ghosts = [ + makeGhost({ name: 'alpha', category: 'agent' }), + makeGhost({ name: 's1', category: 'skill' }), + ]; + const picker = makePicker(ghosts); + picker.filterSortByTab[0]!.active = true; + picker.filterSortByTab[0]!.query = 'alpha'; + picker.filterSortByTab[0]!.sort = 'tokens-desc'; + picker.filterMode = true; + picker.nextTab(); + expect(picker.filterMode).toBe(false); + expect(picker.filterSortByTab[0]!.query).toBe(''); + expect(picker.filterSortByTab[0]!.active).toBe(false); + // Sort preserved. + expect(picker.filterSortByTab[0]!.sort).toBe('tokens-desc'); + }); + + it('toggleAllInActiveTab with active filter toggles only VISIBLE items (D5-17)', () => { + const ghosts = [ + makeGhost({ name: 'alpha', category: 'agent' }), + makeGhost({ name: 'beta', category: 'agent' }), + makeGhost({ name: 'alphasecond', category: 'agent' }), + ]; + const picker = makePicker(ghosts); + picker.filterSortByTab[0]!.active = true; + picker.filterSortByTab[0]!.query = 'alpha'; + picker.toggleAllInActiveTab(); + // Only 'alpha' + 'alphasecond' are visible → both selected. 'beta' not selected. + expect(picker.selectedIds.has('agent|global||/fake/alpha')).toBe(true); + expect(picker.selectedIds.has('agent|global||/fake/alphasecond')).toBe(true); + expect(picker.selectedIds.has('agent|global||/fake/beta')).toBe(false); + }); + }); + + describe('Phase 5 filter + sort — Task 2 key bindings + footer', () => { + it("'/' then typed chars: filterMode = true, query grows, visible slice narrows", () => { + const ghosts = [ + makeGhost({ name: 'alpha', category: 'agent' }), + makeGhost({ name: 'beta', category: 'agent' }), + ]; + const picker = makePicker(ghosts); + fireKey(picker, '/'); + expect(picker.filterMode).toBe(true); + fireKey(picker, 'a'); + fireKey(picker, 'l'); + expect(picker.filterSortByTab[0]!.query).toBe('al'); + const names = picker.visibleItemsForTab(0).map((x) => x.item.name); + expect(names).toEqual(['alpha']); + }); + + it('Backspace in filter mode shrinks query', () => { + const ghosts = [makeGhost({ name: 'alpha', category: 'agent' })]; + const picker = makePicker(ghosts); + fireKey(picker, '/'); + fireKey(picker, 'a'); + fireKey(picker, 'b'); + expect(picker.filterSortByTab[0]!.query).toBe('ab'); + fireKey(picker, undefined, { name: 'backspace' }); + expect(picker.filterSortByTab[0]!.query).toBe('a'); + fireKey(picker, undefined, { name: 'backspace' }); + expect(picker.filterSortByTab[0]!.query).toBe(''); + expect(picker.filterSortByTab[0]!.active).toBe(false); + }); + + it('Esc in filter mode clears query AND exits mode (D5-05)', () => { + const ghosts = [makeGhost({ name: 'alpha', category: 'agent' })]; + const picker = makePicker(ghosts); + fireKey(picker, '/'); + fireKey(picker, 'a'); + expect(picker.filterMode).toBe(true); + expect(picker.filterSortByTab[0]!.query).toBe('a'); + fireKey(picker, undefined, { name: 'escape' }); + expect(picker.filterMode).toBe(false); + expect(picker.filterSortByTab[0]!.query).toBe(''); + expect(picker.filterSortByTab[0]!.active).toBe(false); + }); + + it('Enter in filter mode exits mode but preserves query (D5-05)', () => { + const ghosts = [ + makeGhost({ name: 'alpha', category: 'agent' }), + makeGhost({ name: 'beta', category: 'agent' }), + ]; + const picker = makePicker(ghosts); + fireKey(picker, '/'); + fireKey(picker, 'a'); + fireKey(picker, 'l'); + fireKey(picker, undefined, { name: 'return' }); + expect(picker.filterMode).toBe(false); + expect(picker.filterSortByTab[0]!.query).toBe('al'); + expect(picker.filterSortByTab[0]!.active).toBe(true); + // List stays narrowed. + const names = picker.visibleItemsForTab(0).map((x) => x.item.name); + expect(names).toEqual(['alpha']); + }); + + it('Esc after submitted filter clears filter without canceling and restores saved cursor', () => { + const ghosts = [ + makeGhost({ name: 'alpha', category: 'agent' }), + makeGhost({ name: 'beta', category: 'agent' }), + makeGhost({ name: 'alpine', category: 'agent' }), + ]; + const picker = makePicker(ghosts); + picker.tabs[0]!.cursor = 2; + fireKey(picker, '/'); + fireKey(picker, 'a'); + fireKey(picker, 'l'); + fireKey(picker, undefined, { name: 'return' }); + expect(picker.filterMode).toBe(false); + expect(picker.filterSortByTab[0]!.active).toBe(true); + expect(picker.tabs[0]!.savedCursorPreFilter).toBe(2); + + fireKey(picker, undefined, { name: 'escape' }); + expect(picker.filterSortByTab[0]!.query).toBe(''); + expect(picker.filterSortByTab[0]!.active).toBe(false); + expect(picker.tabs[0]!.cursor).toBe(2); + expect(picker.state).toBe('active'); + }); + + it("'s' cycles sort per-tab (4 presses returns to tokens-desc, D5-10)", () => { + const ghosts = [makeGhost({ name: 'a1', category: 'agent' })]; + const picker = makePicker(ghosts); + expect(picker.filterSortByTab[0]!.sort).toBe('staleness-desc'); + fireKey(picker, 's'); + expect(picker.filterSortByTab[0]!.sort).toBe('tokens-desc'); + fireKey(picker, 's'); + expect(picker.filterSortByTab[0]!.sort).toBe('name-asc'); + fireKey(picker, 's'); + expect(picker.filterSortByTab[0]!.sort).toBe('staleness-desc'); + fireKey(picker, 's'); + expect(picker.filterSortByTab[0]!.sort).toBe('tokens-desc'); + }); + + it("'s' while in filter mode is appended to query (NOT treated as sort)", () => { + const ghosts = [makeGhost({ name: 'spam', category: 'agent' })]; + const picker = makePicker(ghosts); + fireKey(picker, '/'); + fireKey(picker, 's'); + expect(picker.filterSortByTab[0]!.query).toBe('s'); + expect(picker.filterSortByTab[0]!.sort).toBe('staleness-desc'); + }); + + it("footer shows 'Filtered: M of N visible · X selected' when filter active", () => { + const ghosts = [ + makeGhost({ name: 'alpha', category: 'agent' }), + makeGhost({ name: 'zzz', category: 'agent' }), + makeGhost({ name: 'yyy', category: 'agent' }), + ]; + const picker = makePicker(ghosts); // useAscii=true → '|' separator + fireKey(picker, '/'); + fireKey(picker, 'a'); + const frame = picker._renderFrame(); + // Only 'alpha' contains 'a' → M=1, N=3, X=0 + expect(frame).toContain('Filtered: 1 of 3 visible | 0 selected'); + }); + + it("footer shows '(incl. hidden)' when a selected id is not in any visible slice", () => { + const ghosts = [ + makeGhost({ name: 'alpha', category: 'agent' }), + makeGhost({ name: 'beta', category: 'agent' }), + ]; + const picker = makePicker(ghosts); + // Select 'beta' (cursor on first item which is 'alpha'; sort staleness + // with no mtimeMs means both are equally stale — preserve input order, + // so 'alpha' is at cursor 0). + picker.cursorDown(); + picker.toggleCurrentRow(); // select 'beta' + // Now filter to 'alp' — beta hidden. + fireKey(picker, '/'); + fireKey(picker, 'a'); + fireKey(picker, 'l'); + fireKey(picker, 'p'); + const frame = picker._renderFrame(); + expect(frame).toContain('1 selected (incl. hidden)'); + }); + + it("footer omits '(incl. hidden)' when all selections are in a visible slice", () => { + const ghosts = [ + makeGhost({ name: 'alpha', category: 'agent' }), + makeGhost({ name: 'beta', category: 'agent' }), + ]; + const picker = makePicker(ghosts); + picker.toggleCurrentRow(); // select 'alpha' (cursor 0) + fireKey(picker, '/'); + fireKey(picker, 'a'); + const frame = picker._renderFrame(); + // Both 'alpha' and 'beta' start with no mtimeMs → staleness-desc leaves + // input order. 'alpha' is visible, 'beta' is hidden but not selected. + expect(frame).toContain('1 selected'); + expect(frame).not.toContain('(incl. hidden)'); + }); + + it('sort label appears on header only when non-default (D5-12)', () => { + const ghosts = [makeGhost({ name: 'a1', category: 'agent' })]; + const picker = makePicker(ghosts); + // Default: no sort label. + expect(picker._renderFrame()).not.toContain('sort:'); + fireKey(picker, 's'); // → tokens-desc + expect(picker._renderFrame()).toContain('sort:tokens'); + fireKey(picker, 's'); // → name-asc + expect(picker._renderFrame()).toContain('sort:name'); + fireKey(picker, 's'); // → staleness-desc (default) + expect(picker._renderFrame()).not.toContain('sort:'); + }); + + it('sanitization: pasted ANSI bytes are stripped from echoed query (T-05-01)', () => { + const ghosts = [makeGhost({ name: 'foo', category: 'agent' })]; + const picker = makePicker(ghosts); + fireKey(picker, '/'); + // Simulate pasting "\x1b[31mfoo" one character at a time through the + // filter-append path. Each single-char codepoint < 32 is rejected by + // the append guard. This covers the append-time mitigation. The + // render-time mitigation additionally re-sanitizes, so if a future + // code path bypasses append and writes a raw escape to state.query, + // the rendered echo still has no escapes. + // Direct state poisoning to exercise the render-time sanitizer: + picker.filterSortByTab[0]!.query = '\x1b[31mfoo'; + picker.filterSortByTab[0]!.active = true; + const frame = picker._renderFrame(); + // Echoed as 'Filter: foo_' — no ANSI in output (the plain "foo_" + // sequence must appear). + expect(frame).toContain('Filter: foo_'); + // Raw ESC byte must NOT be present. + // eslint-disable-next-line no-control-regex + expect(/\x1b/.test(frame)).toBe(false); + }); + + it('cursor clamps when s-cycle changes visible ordering', () => { + const ghosts = [ + makeGhost({ name: 'a1', category: 'agent', tokens: 1 }), + makeGhost({ name: 'a2', category: 'agent', tokens: 2 }), + makeGhost({ name: 'a3', category: 'agent', tokens: 3 }), + ]; + const picker = makePicker(ghosts); + picker.cursorDown(); + picker.cursorDown(); + expect(picker.tabs[0]!.cursor).toBe(2); + // Apply a filter that narrows to a single item. + fireKey(picker, '/'); + fireKey(picker, 'a'); + fireKey(picker, '2'); + // Visible slice has 1 item — cursor clamps to 0. + const frame = picker._renderFrame(); + expect(frame).toContain('Filtered: 1 of 3 visible'); + expect(picker.tabs[0]!.cursor).toBe(0); + }); + }); + + describe('Phase 5 help overlay — Plan 03 (D5-13..D5-16)', () => { + it("'?' toggles helpOpen true/false", () => { + const ghosts = [makeGhost({ name: 'a1', category: 'agent' })]; + const picker = makePicker(ghosts); + expect(picker.helpOpen).toBe(false); + fireKey(picker, '?'); + expect(picker.helpOpen).toBe(true); + fireKey(picker, '?'); + expect(picker.helpOpen).toBe(false); + }); + + it('Esc closes the overlay (does NOT mutate filter query)', () => { + const ghosts = [ + makeGhost({ name: 'alpha', category: 'agent' }), + makeGhost({ name: 'beta', category: 'agent' }), + ]; + const picker = makePicker(ghosts); + // Set a filter first (Enter exits filter mode but keeps query active). + fireKey(picker, '/'); + fireKey(picker, 'a'); + fireKey(picker, 'l'); + fireKey(picker, undefined, { name: 'return' }); + expect(picker.filterSortByTab[0]!.query).toBe('al'); + expect(picker.filterSortByTab[0]!.active).toBe(true); + // Open help. + fireKey(picker, '?'); + expect(picker.helpOpen).toBe(true); + // Close with Esc — filter state intact. + fireKey(picker, undefined, { name: 'escape' }); + expect(picker.helpOpen).toBe(false); + expect(picker.filterSortByTab[0]!.query).toBe('al'); + expect(picker.filterSortByTab[0]!.active).toBe(true); + }); + + it('while help is open, printable keys / / / s / Space / arrows are all no-ops', () => { + const ghosts = [ + makeGhost({ name: 'a1', category: 'agent' }), + makeGhost({ name: 'a2', category: 'agent' }), + makeGhost({ name: 's1', category: 'skill' }), + ]; + const picker = makePicker(ghosts); + const beforeCursor = picker.tabs[0]!.cursor; + const beforeTab = picker.activeTabIndex; + const beforeSort = picker.filterSortByTab[0]!.sort; + const beforeSelected = picker.selectedIds.size; + fireKey(picker, '?'); + expect(picker.helpOpen).toBe(true); + // Printable keys that normally navigate / toggle / sort / filter: + fireKey(picker, '/'); + fireKey(picker, 's'); + fireKey(picker, 'a'); + fireKey(picker, '2'); + fireKey(picker, 'x'); + // Cursor actions (space/arrows/enter/tab). + (picker as unknown as { emit: (e: string, ...a: unknown[]) => boolean }).emit( + 'cursor', + 'down', + ); + (picker as unknown as { emit: (e: string, ...a: unknown[]) => boolean }).emit( + 'cursor', + 'space', + ); + (picker as unknown as { emit: (e: string, ...a: unknown[]) => boolean }).emit( + 'cursor', + 'right', + ); + // Nothing mutated. + expect(picker.helpOpen).toBe(true); + expect(picker.tabs[0]!.cursor).toBe(beforeCursor); + expect(picker.activeTabIndex).toBe(beforeTab); + expect(picker.filterSortByTab[0]!.sort).toBe(beforeSort); + expect(picker.selectedIds.size).toBe(beforeSelected); + expect(picker.filterMode).toBe(false); + }); + + it('open+close overlay preserves selectedIds, activeTabIndex, per-tab cursor, filter/sort (D5-13)', () => { + const ghosts = [ + makeGhost({ name: 'a1', category: 'agent' }), + makeGhost({ name: 'a2', category: 'agent' }), + makeGhost({ name: 's1', category: 'skill' }), + makeGhost({ name: 's2', category: 'skill' }), + ]; + const picker = makePicker(ghosts); + // Build some state: select a1, move cursor, switch tab, cycle sort. + picker.toggleCurrentRow(); // select a1 + picker.cursorDown(); + picker.nextTab(); // tab=1 (SKILLS) + picker.toggleCurrentRow(); // select s1 + fireKey(picker, 's'); // tokens-desc on skills + const snapSelected = new Set(picker.selectedIds); + const snapTab = picker.activeTabIndex; + const snapCursor0 = picker.tabs[0]!.cursor; + const snapCursor1 = picker.tabs[1]!.cursor; + const snapSort0 = picker.filterSortByTab[0]!.sort; + const snapSort1 = picker.filterSortByTab[1]!.sort; + // Open + close via `?`. + fireKey(picker, '?'); + fireKey(picker, '?'); + expect(picker.helpOpen).toBe(false); + expect(Array.from(picker.selectedIds).sort()).toEqual(Array.from(snapSelected).sort()); + expect(picker.activeTabIndex).toBe(snapTab); + expect(picker.tabs[0]!.cursor).toBe(snapCursor0); + expect(picker.tabs[1]!.cursor).toBe(snapCursor1); + expect(picker.filterSortByTab[0]!.sort).toBe(snapSort0); + expect(picker.filterSortByTab[1]!.sort).toBe(snapSort1); + }); + + it('cursor `cancel` action is still honored while help is open (INV-S2 / T-05-02)', () => { + const ghosts = [makeGhost({ name: 'a1', category: 'agent' })]; + const picker = makePicker(ghosts); + fireKey(picker, '?'); + expect(picker.helpOpen).toBe(true); + // Drive the cursor dispatcher with 'cancel'; the gate must NOT swallow it. + let cancelRan = false; + const origCancel = picker.cancel.bind(picker); + (picker as unknown as { cancel: () => void }).cancel = () => { + cancelRan = true; + // Do NOT call origCancel — it flips state and would race the test. + // Reference origCancel to satisfy TS no-unused-expressions lint. + void origCancel; + }; + (picker as unknown as { emit: (e: string, ...a: unknown[]) => boolean }).emit( + 'cursor', + 'cancel', + ); + expect(cancelRan).toBe(true); + }); + + it('_renderFrame returns help overlay content when helpOpen is true', () => { + const ghosts = [makeGhost({ name: 'a1', category: 'agent' })]; + const picker = makePicker(ghosts); + fireKey(picker, '?'); + const frame = picker._renderFrame(); + // Heading + representative keybind. + expect(frame).toContain('Navigation'); + expect(frame).toContain('Selection'); + expect(frame).toContain('View'); + expect(frame).toContain('Exit'); + // Does NOT render the tab bar / per-tab header while overlay is up. + expect(frame).not.toContain('AGENTS (0/1)'); + }); + + it('`?` opens help from inside filter mode (Claude Discretion routing)', () => { + const ghosts = [makeGhost({ name: 'alpha', category: 'agent' })]; + const picker = makePicker(ghosts); + fireKey(picker, '/'); + expect(picker.filterMode).toBe(true); + fireKey(picker, '?'); + expect(picker.helpOpen).toBe(true); + // Filter mode was not implicitly closed — user returns to filter on close. + expect(picker.filterMode).toBe(true); + }); + }); + + describe('Phase 5 framework-group toggle — Plan 04 (D5-17..D5-20)', () => { + it('assembleRowsForTab emits sub-headers when visible items span ≥ 2 frameworks', () => { + const ghosts = [ + makeGhost({ name: 'm1', category: 'mcp-server', tokens: 500, framework: 'FrameA' }), + makeGhost({ name: 'm2', category: 'mcp-server', tokens: 400, framework: 'FrameA' }), + makeGhost({ name: 'm3', category: 'mcp-server', tokens: 300, framework: 'FrameB' }), + makeGhost({ name: 'm4', category: 'mcp-server', tokens: 200, framework: 'FrameB' }), + ]; + const picker = makePicker(ghosts); + const rows = picker.assembleRowsForTab(0); + // 2 sub-headers + 4 items = 6 rows. + expect(rows.length).toBe(6); + expect(rows[0]!.kind).toBe('sub-header'); + expect(rows[3]!.kind).toBe('sub-header'); + // Sub-headers carry groupIds of the currently-visible members. + const sh0 = rows[0] as { kind: 'sub-header'; framework: string; groupIds: string[] }; + expect(sh0.framework).toBe('FrameA'); + expect(sh0.groupIds.length).toBe(2); + expect(picker.hasFrameworkSubHeaders(0)).toBe(true); + }); + + it('no sub-headers emitted when tab has only one framework value', () => { + const ghosts = [ + makeGhost({ name: 'a1', category: 'agent', tokens: 100, framework: 'Solo' }), + makeGhost({ name: 'a2', category: 'agent', tokens: 80, framework: 'Solo' }), + ]; + const picker = makePicker(ghosts); + expect(picker.hasFrameworkSubHeaders(0)).toBe(false); + const rows = picker.assembleRowsForTab(0); + expect(rows.every((r) => r.kind === 'item')).toBe(true); + expect(rows.length).toBe(2); + }); + + it('no sub-headers when framework field is absent on all items (D5-18 n/a path)', () => { + const ghosts = [ + makeGhost({ name: 'a1', category: 'agent', tokens: 100 }), + makeGhost({ name: 'a2', category: 'agent', tokens: 80 }), + ]; + const picker = makePicker(ghosts); + expect(picker.hasFrameworkSubHeaders(0)).toBe(false); + }); + + it('null framework values are grouped together as their own bucket', () => { + // Mix: two framework=FrameA, two framework=null → two buckets → sub-headers. + const ghosts = [ + makeGhost({ name: 'a1', category: 'agent', tokens: 400, framework: 'FrameA' }), + makeGhost({ name: 'a2', category: 'agent', tokens: 300, framework: 'FrameA' }), + makeGhost({ name: 'u1', category: 'agent', tokens: 200, framework: null }), + makeGhost({ name: 'u2', category: 'agent', tokens: 100, framework: null }), + ]; + const picker = makePicker(ghosts); + expect(picker.hasFrameworkSubHeaders(0)).toBe(true); + const rows = picker.assembleRowsForTab(0); + const headers = rows.filter( + (r): r is { kind: 'sub-header'; framework: string; groupIds: string[] } => + r.kind === 'sub-header', + ); + expect(headers.length).toBe(2); + }); + + it('Space on sub-header selects all group items when none selected (D5-17)', () => { + const ghosts = [ + makeGhost({ name: 'm1', category: 'mcp-server', tokens: 500, framework: 'FrameA' }), + makeGhost({ name: 'm2', category: 'mcp-server', tokens: 400, framework: 'FrameA' }), + makeGhost({ name: 'm3', category: 'mcp-server', tokens: 300, framework: 'FrameB' }), + ]; + const picker = makePicker(ghosts); + // Cursor defaults to 0 → first sub-header (FrameA). + expect(picker.assembleRowsForTab(0)[0]!.kind).toBe('sub-header'); + picker.toggleCurrentRow(); + expect(picker.selectedIds.size).toBe(2); + expect(picker.selectedIds.has('mcp-server|global||m1|/fake/m1')).toBe(true); + expect(picker.selectedIds.has('mcp-server|global||m2|/fake/m2')).toBe(true); + // FrameB item untouched. + expect(picker.selectedIds.has('mcp-server|global||m3|/fake/m3')).toBe(false); + }); + + it('Space on sub-header with partial selection selects all (anyUnselected path)', () => { + const ghosts = [ + makeGhost({ name: 'm1', category: 'mcp-server', tokens: 500, framework: 'FrameA' }), + makeGhost({ name: 'm2', category: 'mcp-server', tokens: 400, framework: 'FrameA' }), + makeGhost({ name: 'm3', category: 'mcp-server', tokens: 300, framework: 'FrameB' }), + ]; + const picker = makePicker(ghosts); + // Pre-select m1 only (1 of 2 in FrameA). + picker.selectedIds.add('mcp-server|global||m1|/fake/m1'); + // Cursor on FrameA sub-header. + picker.toggleCurrentRow(); + // anyUnselected ⇒ add all group members (m1 already there, m2 added). + expect(picker.selectedIds.has('mcp-server|global||m1|/fake/m1')).toBe(true); + expect(picker.selectedIds.has('mcp-server|global||m2|/fake/m2')).toBe(true); + }); + + it('Space on sub-header with full group selection deselects all (select-or-clear)', () => { + const ghosts = [ + makeGhost({ name: 'm1', category: 'mcp-server', tokens: 500, framework: 'FrameA' }), + makeGhost({ name: 'm2', category: 'mcp-server', tokens: 400, framework: 'FrameA' }), + makeGhost({ name: 'm3', category: 'mcp-server', tokens: 300, framework: 'FrameB' }), + ]; + const picker = makePicker(ghosts); + picker.selectedIds.add('mcp-server|global||m1|/fake/m1'); + picker.selectedIds.add('mcp-server|global||m2|/fake/m2'); + // Cursor on FrameA sub-header — all group items selected → clear. + picker.toggleCurrentRow(); + expect(picker.selectedIds.has('mcp-server|global||m1|/fake/m1')).toBe(false); + expect(picker.selectedIds.has('mcp-server|global||m2|/fake/m2')).toBe(false); + }); + + it('cursor walks PickerRow[] including sub-headers uniformly', () => { + const ghosts = [ + makeGhost({ name: 'm1', category: 'mcp-server', tokens: 500, framework: 'FrameA' }), + makeGhost({ name: 'm2', category: 'mcp-server', tokens: 400, framework: 'FrameA' }), + makeGhost({ name: 'm3', category: 'mcp-server', tokens: 300, framework: 'FrameB' }), + ]; + const picker = makePicker(ghosts); + // Rows: [subA, m1, m2, subB, m3] → 5 rows. Down 4× lands on m3. + expect(picker.tabs[0]!.cursor).toBe(0); + picker.cursorDown(); // 1 (m1) + picker.cursorDown(); // 2 (m2) + picker.cursorDown(); // 3 (subB) + picker.cursorDown(); // 4 (m3) + expect(picker.tabs[0]!.cursor).toBe(4); + // One more should clamp (5 rows total, last index = 4). + picker.cursorDown(); + expect(picker.tabs[0]!.cursor).toBe(4); + }); + + it('Space on item row keeps existing per-item toggle behavior', () => { + const ghosts = [ + makeGhost({ name: 'm1', category: 'mcp-server', tokens: 500, framework: 'FrameA' }), + makeGhost({ name: 'm2', category: 'mcp-server', tokens: 400, framework: 'FrameA' }), + makeGhost({ name: 'm3', category: 'mcp-server', tokens: 300, framework: 'FrameB' }), + ]; + const picker = makePicker(ghosts); + // Move cursor to m1 (row 1). + picker.cursorDown(); + picker.toggleCurrentRow(); + expect(picker.selectedIds.size).toBe(1); + expect(picker.selectedIds.has('mcp-server|global||m1|/fake/m1')).toBe(true); + }); + + it('toggleAllInActiveTab operates on items only (sub-headers do not participate)', () => { + const ghosts = [ + makeGhost({ name: 'm1', category: 'mcp-server', tokens: 500, framework: 'FrameA' }), + makeGhost({ name: 'm2', category: 'mcp-server', tokens: 400, framework: 'FrameA' }), + makeGhost({ name: 'm3', category: 'mcp-server', tokens: 300, framework: 'FrameB' }), + ]; + const picker = makePicker(ghosts); + picker.toggleAllInActiveTab(); + // All three ITEMS selected; sub-header identity never enters selectedIds. + expect(picker.selectedIds.size).toBe(3); + for (const id of picker.selectedIds) { + expect(id.startsWith('mcp-server|global||')).toBe(true); + } + }); + + it('assembleRowsForTab respects sort mode (name-asc changes group order)', () => { + // Construct so tokens-desc order puts FrameZ items first (bigger tokens), + // then FrameA items. With name-asc the first item seen is aaa (FrameA), + // so FrameA sub-header should appear first. + const ghosts = [ + makeGhost({ name: 'zzz', category: 'mcp-server', tokens: 900, framework: 'FrameZ' }), + makeGhost({ name: 'yyy', category: 'mcp-server', tokens: 800, framework: 'FrameZ' }), + makeGhost({ name: 'aaa', category: 'mcp-server', tokens: 100, framework: 'FrameA' }), + makeGhost({ name: 'bbb', category: 'mcp-server', tokens: 50, framework: 'FrameA' }), + ]; + const picker = makePicker(ghosts); + // Default sort staleness-desc: tie-broken by stable original order; but the + // constructor pre-sorts by tokens-desc, so FrameZ items first. First + // sub-header should be FrameZ. + const defaultRows = picker.assembleRowsForTab(0); + const firstHeader = defaultRows.find( + (r): r is { kind: 'sub-header'; framework: string; groupIds: string[] } => + r.kind === 'sub-header', + ); + expect(firstHeader?.framework).toBe('FrameZ'); + // Switch to name-asc. + picker.filterSortByTab[0]!.sort = 'name-asc'; + const nameAscRows = picker.assembleRowsForTab(0); + const firstHeaderAsc = nameAscRows.find( + (r): r is { kind: 'sub-header'; framework: string; groupIds: string[] } => + r.kind === 'sub-header', + ); + // With name-asc the first visible item is 'aaa' → FrameA → FrameA header first. + expect(firstHeaderAsc?.framework).toBe('FrameA'); + }); + + it('filter narrows groupIds to currently-visible items (D5-11)', () => { + const ghosts = [ + makeGhost({ name: 'alpha', category: 'mcp-server', tokens: 500, framework: 'FrameA' }), + makeGhost({ name: 'beta', category: 'mcp-server', tokens: 400, framework: 'FrameA' }), + makeGhost({ name: 'gamma', category: 'mcp-server', tokens: 300, framework: 'FrameB' }), + makeGhost({ name: 'delta', category: 'mcp-server', tokens: 200, framework: 'FrameB' }), + ]; + const picker = makePicker(ghosts); + // Filter to 'a' — matches alpha (FrameA), beta (FrameA), gamma (FrameB), delta (FrameB) + // Actually 'al' narrows to just alpha and delta via substring, but let's use a precise query. + picker.filterSortByTab[0]!.active = true; + picker.filterSortByTab[0]!.query = 'alpha'; + const rows = picker.assembleRowsForTab(0); + // Only one visible item → 1 framework value → no sub-header. + expect(picker.hasFrameworkSubHeaders(0)).toBe(false); + expect(rows.length).toBe(1); + expect(rows[0]!.kind).toBe('item'); + }); + + it('per-tab counter + subtotal update in same pass as group toggle (D5-20)', () => { + const ghosts = [ + makeGhost({ name: 'm1', category: 'mcp-server', tokens: 500, framework: 'FrameA' }), + makeGhost({ name: 'm2', category: 'mcp-server', tokens: 400, framework: 'FrameA' }), + makeGhost({ name: 'm3', category: 'mcp-server', tokens: 300, framework: 'FrameB' }), + ]; + const picker = makePicker(ghosts); + expect(picker.selectedIds.size).toBe(0); + // Space on FrameA header. + picker.toggleCurrentRow(); + // Render and confirm the per-tab header shows (2/3) and counter reflects 900 tokens. + const frame = picker._renderFrame(); + expect(frame).toMatch(/\(2\/3/); + // Token sum of FrameA group is 900. + expect(picker.selectedIds.size).toBe(2); + }); + }); + }); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index df2519d..9e4a44e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,6 +6,12 @@ settings: catalogs: default: + '@clack/core': + specifier: ^1.2.0 + version: 1.2.0 + '@clack/prompts': + specifier: ^1.2.0 + version: 1.2.0 '@eslint/js': specifier: ^10.0.1 version: 10.0.1 @@ -92,6 +98,12 @@ importers: '@ccaudit/terminal': specifier: workspace:* version: link:../../packages/terminal + '@clack/prompts': + specifier: 'catalog:' + version: 1.2.0 + '@praha/byethrow': + specifier: 'catalog:' + version: 0.10.1(typescript@6.0.2) '@types/node': specifier: 'catalog:' version: 25.5.2 @@ -143,6 +155,12 @@ importers: '@ccaudit/internal': specifier: workspace:* version: link:../internal + '@clack/core': + specifier: 'catalog:' + version: 1.2.0 + '@clack/prompts': + specifier: 'catalog:' + version: 1.2.0 '@types/node': specifier: 'catalog:' version: 25.5.2 @@ -206,6 +224,12 @@ packages: resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} engines: {node: '>=18'} + '@clack/core@1.2.0': + resolution: {integrity: sha512-qfxof/3T3t9DPU/Rj3OmcFyZInceqj/NVtO9rwIuJqCUgh32gwPjpFQQp/ben07qKlhpwq7GzfWpST4qdJ5Drg==} + + '@clack/prompts@1.2.0': + resolution: {integrity: sha512-4jmztR9fMqPMjz6H/UZXj0zEmE43ha1euENwkckKKel4XpSfokExPo5AiVStdHSAlHekz4d0CA/r45Ok1E4D3w==} + '@colors/colors@1.5.0': resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==} engines: {node: '>=0.1.90'} @@ -810,6 +834,15 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fast-string-truncated-width@1.2.1: + resolution: {integrity: sha512-Q9acT/+Uu3GwGj+5w/zsGuQjh9O1TyywhIwAxHudtWrgF09nHOPrvTLhQevPbttcxjr/SNN7mJmfOw/B1bXgow==} + + fast-string-width@1.1.0: + resolution: {integrity: sha512-O3fwIVIH5gKB38QNbdg+3760ZmGz0SZMgvwJbA1b2TGXceKE6A2cOlfogh1iw8lr049zPyd7YADHy+B7U4W9bQ==} + + fast-wrap-ansi@0.1.6: + resolution: {integrity: sha512-HlUwET7a5gqjURj70D5jl7aC3Zmy4weA1SHUfM0JFI0Ptq987NH2TwbBFLoERhfwk+E+eaq4EK3jXoT+R3yp3w==} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -1130,6 +1163,9 @@ packages: siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + sisteransi@1.0.5: + resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -1390,6 +1426,18 @@ snapshots: '@bcoe/v8-coverage@1.0.2': {} + '@clack/core@1.2.0': + dependencies: + fast-wrap-ansi: 0.1.6 + sisteransi: 1.0.5 + + '@clack/prompts@1.2.0': + dependencies: + '@clack/core': 1.2.0 + fast-string-width: 1.1.0 + fast-wrap-ansi: 0.1.6 + sisteransi: 1.0.5 + '@colors/colors@1.5.0': optional: true @@ -1922,6 +1970,16 @@ snapshots: fast-levenshtein@2.0.6: {} + fast-string-truncated-width@1.2.1: {} + + fast-string-width@1.1.0: + dependencies: + fast-string-truncated-width: 1.2.1 + + fast-wrap-ansi@0.1.6: + dependencies: + fast-string-width: 1.1.0 + fdir@6.5.0(picomatch@4.0.4): optionalDependencies: picomatch: 4.0.4 @@ -2209,6 +2267,8 @@ snapshots: siginfo@2.0.0: {} + sisteransi@1.0.5: {} + source-map-js@1.2.1: {} stackback@0.0.2: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 8305da4..1490b1f 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -4,6 +4,8 @@ packages: catalog: # CLI Runtime (all bundled as devDeps) + '@clack/core': ^1.2.0 + '@clack/prompts': ^1.2.0 gunshi: ^0.29.3 valibot: ^1.3.1 tinyglobby: ^0.2.15