Skip to content

v1.5.0 — interactive archive picker, restore subsets, framework protection#5

Merged
fabio-dee merged 5 commits into
mainfrom
release/v1.5.0
Apr 27, 2026
Merged

v1.5.0 — interactive archive picker, restore subsets, framework protection#5
fabio-dee merged 5 commits into
mainfrom
release/v1.5.0

Conversation

@fabio-dee

@fabio-dee fabio-dee commented Apr 26, 2026

Copy link
Copy Markdown
Owner

Summary

  • Interactive TUI for selective ghost archival (ccaudit ghost --interactive): tabbed picker across agents, skills, MCP, commands, and memory with live token counter, fuzzy filter, sort cycle, framework group toggle, help overlay, and SIGWINCH-safe pagination.
  • Scoped restore flows: restore --interactive / --name <pattern> / --all-matching, walking every manifest deduplicated by archive_path.
  • Framework-as-unit protection: members of partially-used frameworks are unselectable / skipped unless --force-partial is set on both --dry-run and --dangerously-bust-ghosts.
  • purge-archive command: classifier-driven drain of ~/.claude/ccaudit/archived/ (reclaim → drop-occupied → drop-stale → skip). Default dry-run; real purge requires --yes.
  • JSON envelope additions: bust.summary.freedTokens (subset-accurate), manifest.header.planned_ops (filtered count), restore.selection_filter, restore.skipped[], restore.filtered_stale_count, purge.summary.*, purge.failures[].
  • Safety invariants INV-S1..INV-S6 added to CLAUDE.md as fixture-backed integration tests.

All v1.4.0 invariants preserved: nothing is deleted, every mutation is manifest-logged, every bust is gated by a SHA-256 inventory checkpoint.

Test plan

  • pnpm verify green (1733 passed, 8 skipped, 2 todo across 145 files)
  • Format check clean (oxfmt --check)
  • Subset-bust → restore round-trip integration tests (safety-invariants-restore-roundtrip.test.ts)
  • MCP byte-preservation across subset bust (INV-S1)
  • SIGINT during TUI produces zero manifest writes (INV-S2)
  • Framework protection blocks partial busts without --force-partial (INV-S6)
  • purge-archive classifier behaviors (purge-archive.test.ts)
  • Tabbed-picker pagination / filter / sort / help-overlay flows
  • Manual QA via opt-in tmux e2e suite (tmux-e2e-manual-qa.test.ts)

Summary by CodeRabbit

v1.5.0 Release

  • New Features

    • Interactive archival with --interactive flag and terminal UI picker for subset selection
    • Interactive restore mode with --interactive, --name, and --all-matching options
    • purge-archive command to permanently remove archived items
    • Tabbed picker with pagination, color-blind-safe glyphs, and ASCII fallback
    • Framework protection warnings for partially-used frameworks
    • CCAUDIT_NO_INTERACTIVE environment variable to disable interactive flows
  • Improvements

    • Enhanced JSON envelopes with selection filters and skip tracking
    • Memory file cleanup during restore operations
    • Refined help output formatting
    • Better handling of stale and already-restored items
  • Bug Fixes

    • Corrected token accounting for subset operations
    • Fixed manifests display in help text

@coderabbitai

coderabbitai Bot commented Apr 26, 2026

Copy link
Copy Markdown

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

This release (v1.5.0) introduces interactive archival via TUI picker, adds purge-archive destructive operation, expands restore with interactive/fuzzy-matching modes, implements framework-as-unit protection, supports multi-config MCP awareness, and adds comprehensive test coverage for these new features across CLI, terminal UI, and remediation layers.

Changes

Cohort / File(s) Summary
Documentation & Versioning
CHANGELOG.md, CLAUDE.md, README.md, apps/ccaudit/package.json
v1.5.0 release documentation and version bump; changelog details new interactive archival/restore/purge capabilities, framework protection, MCP multi-config support, and dry-run defaults; safety invariants documented for archive workflow reversibility, restore subset selection, and token accounting under subset mode.
Bundle Baseline & Verification Scripts
apps/ccaudit/scripts/bundle-baseline*.txt, apps/ccaudit/scripts/bundle-size-check.mjs, apps/ccaudit/scripts/bundle-smoke-test.mjs
New bundle size verification infrastructure: baseline tracking files for multiple phases with phase-specific budgets, smoke test ensuring bundle existence and --help executability, and size-check enforcing gzipped byte constraints with phase-local gates.
CLI Shared Args & Command Index
apps/ccaudit/src/cli/_shared-args.ts, apps/ccaudit/src/cli/index.ts
Output argument keys renamed to camelCase with toKebab: true to avoid help-rendering issues; new purgeArchiveCommand added to CLI subcommands.
Ghost Command (Interactive)
apps/ccaudit/src/cli/commands/ghost.ts
Adds --interactive flag with TTY guard, TUI picker integration, preflight Claude-process detection with retry loop, empty-inventory short-circuit, subset busting via CCAUDIT_SELECT_IDS, and auto-open interactive prompt for eligible TTY runs.
Restore Command (Expanded Modes)
apps/ccaudit/src/cli/commands/restore.ts
Introduces --interactive/-i, --name <pattern> (fuzzy single-match), and --all-matching <pattern> (bulk); adds manifest deduplication, stale-archive filtering, JSON fields selectionFilter, skipped[], filteredStaleCount; implements picker integration and mutual-flag exclusion.
Purge Archive Command (New)
apps/ccaudit/src/cli/commands/purge-archive.ts
New command for destructive archive draining; classifies ops as reclaim/drop/skip; enforces --dry-run default with mutual exclusivity against --yes; writes append-only purge manifests with detailed failure reporting.
Other Commands
apps/ccaudit/src/cli/commands/inventory.ts, apps/ccaudit/src/cli/commands/mcp.ts, apps/ccaudit/src/cli/commands/trend.ts
Minor help-text refinements for --json flags.
Remediation: Manifest & Archive Operations
packages/internal/src/remediation/manifest.ts, packages/internal/src/remediation/bust.ts, packages/internal/src/remediation/change-plan.ts
SelectionFilter and subset header support; millisecond-precision manifest timestamps with random suffix; command category in change plan; filterChangePlan for subset selection; manifest discovery includes purge-*.jsonl.
Remediation: Checkpoint & Atomic Write
packages/internal/src/remediation/checkpoint.ts, packages/internal/src/remediation/atomic-write.ts
Canonical item IDs for deduplication (canonicalItemId); atomicWriteText for manifest-path operations; ghost-hash golden fixture.
Remediation: Bust with Subset & MCP Patching
packages/internal/src/remediation/bust.ts
Subset busting with selectedItems filtering; skipCeremony flag; command-category archive execution; patchMcpConfigText for byte-preserving root-scope JSON surgery; subset-accurate freedTokens calculation.
Remediation: Restore Dedup & Filtering
packages/internal/src/remediation/restore.ts
Manifest dedup with dedupManifestOps, stale-archive filtering via filterRestoreableItems, purge-manifest suppression, candidate collection/matching (collectRestoreableItems, matchByName), subset execution with JSON envelope metadata.
Remediation: Purge Archive Logic
packages/internal/src/remediation/purge.ts, packages/internal/src/remediation/archive-move.ts
Core purge implementation: classifyArchiveOps for reclaim/drop/skip reasoning, executePurge with dry-run/real modes, manifest writer integration, batch failure isolation; reusable moveArchiveToSource helper.
Remediation Barrel Exports
packages/internal/src/remediation/index.ts
Expanded exports for change-plan filtering, checkpoint canonicalization, atomic text writes, purge domain (classify/execute/manifest), restore dedup helpers.
Scanner: Config Refs & Protection
packages/internal/src/scanner/_config-refs.ts, packages/internal/src/scanner/_present-path.ts, packages/internal/src/scanner/types.ts
New config-reference aggregation (computeConfigRefs, compareConfigRef) for multi-config MCP awareness; path presentation helper; FrameworkProtection and protection/configRefs metadata on inventory items.
Scanner: Annotation & MCP/Memory/Hooks
packages/internal/src/scanner/annotate.ts, packages/internal/src/scanner/scan-mcp.ts, packages/internal/src/scanner/scan-memory.ts, packages/internal/src/scanner/scan-hooks.ts
Framework protection propagation; MCP scanner now renders config paths and aggregates configRefs; hook scanner adds mcp_tool leaf type with collision-safe naming.
Scanner Barrel Exports
packages/internal/src/scanner/index.ts
Re-exports for path presentation, config-ref computation, and FrameworkProtection.
Token Formatting
packages/internal/src/token/format.ts, packages/internal/src/token/index.ts
New footer formatters formatTokensApprox and sumSelectionTokens for live picker token display.
Terminal: TUI Infrastructure
packages/terminal/src/index.ts, packages/terminal/package.json
Re-export all TUI exports; add @clack/core and @clack/prompts dev dependencies.
Terminal: Filter/Sort & Glyphs
packages/terminal/src/tui/_filter-sort.ts, packages/terminal/src/tui/_glyphs.ts, packages/terminal/src/tui/_glyph-capability.ts
Phase 5 picker filter/sort state and matching; Unicode/ASCII glyph sets with environment selection; ASCII-only capability detection.
Terminal: Picker UI Rendering
packages/terminal/src/tui/_tab-bar.ts, packages/terminal/src/tui/_help-overlay.ts, packages/terminal/src/tui/_protection-render.ts, packages/terminal/src/tui/_mcp-warning-render.ts, packages/terminal/src/tui/_force-partial-banner.ts
Tab-bar rendering with ellipsis truncation; help overlay with keybinding groups; framework-protection prefix/hint rendering; multi-config MCP warning prefix and "Also in" formatting; --force-partial banner.
Terminal: Preflight Copy & Retries
packages/terminal/src/tui/_preflight-copy.ts
Shared "Claude Code is running" messaging and retry loop with entry/bust phase–specific prompts.
Terminal: Change Plan Rendering
packages/terminal/src/tables/change-plan.ts
commands category added to ARCHIVE section output.
Top-Level Exports
packages/internal/src/index.ts, packages/internal/src/types.ts
Expanded scanner exports (path presentation, config-refs, protection); remediation exports (change-plan filtering, checkpoint canonicalization, atomic text, purge domain, restore dedup); token formatters; CLI env helper; GhostItem augmented with protection and configRefs.
Test Infrastructure & Fixtures
apps/ccaudit/src/__tests__/_test-helpers.ts, apps/ccaudit/src/__tests__/fixtures/*
Comprehensive test helpers: TTY/picker synchronization, fake ps shimming, manifest discovery, MCP/framework/restore-interactive fixture builders, multi-config/multi-framework construction, tmux E2E harness.
Integration Tests: Archival Round-Trip
apps/ccaudit/src/__tests__/command-archival.test.ts, apps/ccaudit/src/__tests__/ghost-select-ids.test.ts, apps/ccaudit/src/__tests__/empty-inventory.test.ts
Command category archival, subset busting via CCAUDIT_SELECT_IDS, empty-inventory short-circuit validation.
Integration Tests: Interactive & Smoke
apps/ccaudit/src/__tests__/interactive-smoke.test.ts, apps/ccaudit/src/__tests__/no-interactive-env.test.ts, apps/ccaudit/src/__tests__/help-output.test.ts, apps/ccaudit/src/__tests__/preflight-retry.test.ts
Guard-path validation (TTY, --interactive + --json rejection, CCAUDIT_NO_INTERACTIVE refusal), help output cleanup (no negatable leaks), preflight retry with synthetic dirty diagnostics.
Integration Tests: Restore Modes
apps/ccaudit/src/__tests__/restore-*.test.ts
Interactive roundtrip, --all-matching bulk, --name fuzzy matching, ambiguity errors, flag mutual exclusion, MEMORY roundtrip, stale filtering, purge suppression, source-exists skipping, corrupt-manifest handling, JSON envelope contracts.
Integration Tests: Picker & TUI
apps/ccaudit/src/__tests__/tabbed-picker-*.test.ts
Pagination (500+ items), filtering/sorting, live token counter, tab navigation, sort cycling, help overlay, protection rendering, framework grouping, overflow handling, MCP multi-config warning, force-partial banner, terminal-too-short gate, SIGWINCH resilience, ASCII glyph fallback.
Integration Tests: Safety & Purge
apps/ccaudit/src/__tests__/safety-invariants-*.test.ts, apps/ccaudit/src/__tests__/purge-archive.test.ts
MCP subset-busting byte preservation, TUI abort manifest safety, restore round-trip dedup, framework-as-unit protection, purge classification/execution, dry-run idempotency, partial-failure isolation.
Integration Tests: Misc
apps/ccaudit/src/__tests__/regime-detection-cc-2-1-117-compat.test.ts, apps/ccaudit/src/__tests__/scanner-multi-config-mcp.test.ts, apps/ccaudit/src/__tests__/tmux-e2e-manual-qa.test.ts, eslint.config.ts
MCP regime-resolution golden test, multi-config aggregation, tmux-backed E2E manual-QA flows, .claude ignore for ESLint.
Root Verification Script
package.json
verify script now runs bundle smoke-test and phase-specific size-check gates before test suite.
JSON Schema
docs/JSON-SCHEMA.md
Documents v1.5 envelopes: restore adds selectionFilter, skipped[], filteredStaleCount; new purge top-level with purge summary/failures/manifest metadata; bust gains selection_filter, totalPlannedTokens field clarification, and subset token-accounting rules.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~65 minutes

Possibly related PRs

Poem

🐰 Burrow deep through archives, with picker and plea,
Restore what was lost, or purge what should be.
With subset selection and safety gates round,
v1.5 hops forward on solid ground!

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch release/v1.5.0

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 12

Note

Due to the large number of review comments, Critical, Major severity comments were prioritized as inline comments.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
apps/ccaudit/src/__tests__/fixtures/tmux-e2e.md (1)

119-120: ⚠️ Potential issue | 🟡 Minor

Remove stray literal at EOF (Line 120).

Line 120 contains an orphaned 120 token that will render as unintended content in the markdown fixture.

🧹 Proposed fix
 Those remain human QA rows.
-120
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/ccaudit/src/__tests__/fixtures/tmux-e2e.md` around lines 119 - 120,
Remove the stray orphaned literal "120" at the end of the tmux-e2e.md fixture
(the token at EOF); delete that trailing numeric token so the file ends with the
intended markdown content/newline and re-run the tests to confirm no unintended
content is rendered.
apps/ccaudit/src/cli/commands/restore.ts (1)

649-656: ⚠️ Potential issue | 🟡 Minor

Update the restore summaries to account for command restores.

counts.unarchived now includes command items, but these labels still say "agents/skills". A restore that only replays command archives will be reported under the wrong category.

Also applies to: 765-776

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/ccaudit/src/cli/commands/restore.ts` around lines 649 - 656, Update the
restore summary text to correctly reflect that counts.unarchived may include
commands: change the hardcoded "agents/skills" label in the lines that push
summaries using c.unarchived (and the similar block at the other occurrence) to
a more accurate label such as "agents/skills/commands" or a generic "items" so
command restores are reported correctly; locate the pushes that reference
c.unarchived (and the corresponding block around lines 765-776) and replace the
label text only, leaving the existing counts and note interpolation intact.
🟡 Minor comments (14)
apps/ccaudit/src/__tests__/restore-flag-mutual-exclusion.test.ts-48-64 (1)

48-64: ⚠️ Potential issue | 🟡 Minor

Add the missing --interactive --all-matching combination.

The file header asserts that "combining any two of the three mode-selecting flags is a hard error", but only 2 of the 3 pairs are exercised: --interactive+--name and --name+--all-matching. The third pair (--interactive+--all-matching) is uncovered, which would let a regression in that specific branch of validateRestoreFlagExclusion slip through this guard.

🧪 Suggested third case
+    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');
+    });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/ccaudit/src/__tests__/restore-flag-mutual-exclusion.test.ts` around
lines 48 - 64, Add a third test case that asserts the CLI errors when combining
--interactive and --all-matching: mirror the existing tests that call
runCcauditCli(tmpHome, ['restore', ...], { env: { PATH: ... } }) but pass
['restore', '--interactive', '--all-matching', 'foo']; assert exitCode is 1 and
stderr contains 'flags are mutually exclusive'. Place the new it(...) alongside
the other two tests in restore-flag-mutual-exclusion.test.ts so
validateRestoreFlagExclusion's --interactive + --all-matching branch is covered.
apps/ccaudit/src/__tests__/restore-json-envelope.test.ts-1-14 (1)

1-14: ⚠️ Potential issue | 🟡 Minor

Docstring describes a non-existent envelope nesting that contradicts what the test actually reads.

The header comment (lines 5–10) claims fields are either top-level (command, exit_code) or nested under data (data.status, data.selection_filter, data.skipped). The test file itself reveals the truth: the envelope has a meta wrapper for command/exit-code, and status, selection_filter, and skipped sit at the root level—no data wrapper. Since the suite is green, the docstring is the stale half; align it to the actual envelope shape so future readers don't get misdirected.

Also note: the docstring uses snake_case exit_code while the actual field is camelCase exitCode in the envelope.

📝 Correct docstring rewrite
- * Asserts the v1.5 additive fields land in the envelope:
- *   - top-level `command === 'restore'`, `exit_code === 0`
- *   - `data.status === 'success'`
- *   - `data.selection_filter.mode === 'subset'`
- *   - `data.selection_filter.ids` has exactly 2 entries (pencil-dev + pencil-review)
- *   - `Array.isArray(data.skipped)` is true (empty on the happy path, but present)
+ * Asserts the v1.5 additive fields land in the envelope:
+ *   - `meta.command === 'restore'`, `meta.exitCode === 0`
+ *   - `status === 'success'`
+ *   - `selection_filter.mode === 'subset'`
+ *   - `selection_filter.ids` has exactly 2 entries (pencil-dev + pencil-review)
+ *   - `Array.isArray(skipped)` is true (empty on the happy path, but present)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/ccaudit/src/__tests__/restore-json-envelope.test.ts` around lines 1 -
14, Update the docstring at the top of restore-json-envelope.test.ts to match
the actual envelope shape the test reads: state that command and exitCode are
inside a meta wrapper (use camelCase exitCode), and that status, selectionFilter
(with mode and ids), and skipped are at the root (not under a data wrapper).
Mention selectionFilter.mode === 'subset' and that selectionFilter.ids contains
exactly two entries, and that skipped is an array (possibly empty). Ensure the
wording replaces references to a non-existent data wrapper and to snake_case
exit_code.
apps/ccaudit/src/__tests__/scanner-multi-config-mcp.test.ts-91-94 (1)

91-94: ⚠️ Potential issue | 🟡 Minor

Tautological "sortedness" assertion — [...arr] preserves order, so toEqual always passes.

const sorted = [...it.configRefs!] is a shallow copy, not a sorted copy, so expect(it.configRefs).toEqual(sorted) is structurally equivalent to expect(arr).toEqual(arr) and cannot fail. The comment "Sorted output is stable across invocations" describes what should be tested, but the assertion doesn't test it.

🐛 Proposed fix — actually validate stability across invocations
-      const items = await scanMcpServers(fixture.userConfigPath, fixture.projectPaths);
+      const items = await scanMcpServers(fixture.userConfigPath, fixture.projectPaths);
+      // Stability: a second scan produces the same configRefs in the same order.
+      const items2 = await scanMcpServers(fixture.userConfigPath, fixture.projectPaths);
       const quintetItems = items.filter((i) => i.name === 'quintet');
+      const quintetItems2 = items2.filter((i) => i.name === 'quintet');
       expect(quintetItems.length).toBeGreaterThanOrEqual(1);
-      for (const it of quintetItems) {
+      for (let idx = 0; idx < quintetItems.length; idx++) {
+        const it = quintetItems[idx]!;
         // At minimum: project-local + user scopes both present.
         expect(it.configRefs!.length).toBeGreaterThanOrEqual(2);
-        // Sorted output is stable across invocations.
-        const sorted = [...it.configRefs!];
-        expect(it.configRefs).toEqual(sorted);
+        // Stable across invocations (same input → same output order).
+        expect(it.configRefs).toEqual(quintetItems2[idx]?.configRefs);
       }

If the intent is to validate bucket order (project-local → user → system) instead of just stability, assert that explicitly via path predicates as the first test in this file already does on lines 53-56.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/ccaudit/src/__tests__/scanner-multi-config-mcp.test.ts` around lines 91
- 94, The assertion using const sorted = [...it.configRefs!] is tautological
because it's only a shallow copy and will always equal the original; replace it
with a real stability/sorting check: either create a deterministically sorted
version (e.g., sort([...it.configRefs!]) using the same comparator used by the
production code and assert expect(it.configRefs).toEqual(sortedVersion), or call
the code that produces configRefs twice and assert the two results are equal (to
verify stability across invocations); alternatively, assert the explicit bucket
order (project-local → user → system) via path predicates like the earlier test
does, referencing the test variable it.configRefs to locate where to change the
expectation.
apps/ccaudit/scripts/bundle-size-check.mjs-62-72 (1)

62-72: ⚠️ Potential issue | 🟡 Minor

Validate CCAUDIT_PHASE_BUDGET_BYTES like the baselines.

Both baseline parses are guarded with Number.isFinite(...) || x <= 0, but phaseBudget is not. If CCAUDIT_PHASE_BUDGET_BYTES is set to a non-numeric value (or empty string — ?? only catches undefined), parseInt returns NaN, and phaseDelta > NaN is always false. The phase-local size gate then silently passes regardless of how large the bundle grew.

🛡️ Proposed fix
-  const phaseBudget = Number.parseInt(process.env.CCAUDIT_PHASE_BUDGET_BYTES ?? '10240', 10);
+  const phaseBudgetRaw = process.env.CCAUDIT_PHASE_BUDGET_BYTES;
+  const phaseBudget = Number.parseInt(
+    phaseBudgetRaw && phaseBudgetRaw.length > 0 ? phaseBudgetRaw : '10240',
+    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;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/ccaudit/scripts/bundle-size-check.mjs` around lines 62 - 72, The phase
budget parsed from CCAUDIT_PHASE_BUDGET_BYTES is not validated, so non-numeric
or empty values produce NaN and silently disable the check; update the parsing
of phaseBudget (where Number.parseInt is used and the CCAUDIT_PHASE_BUDGET_BYTES
env var is read) to mirror the baseline validation: after parsing, check if
Number.isFinite(phaseBudget) and phaseBudget > 0, and if not, assign the same
default (10240) before using phaseBudget in the comparison and logging.
apps/ccaudit/src/__tests__/tabbed-picker-live-counter.test.ts-474-480 (1)

474-480: ⚠️ Potential issue | 🟡 Minor

Tautological assertion — the OR clause makes this check vacuously true.

afterResize.includes('Terminal too small') was already asserted to be true on lines 464-467, so the disjunction here always passes regardless of afterSecondSpace's contents. The check fails to validate the intended invariant: that the banner remains rendered after the suppressed Space input. If a future regression caused the banner to disappear after a key press in sub-minimum state, this test would not catch it.

🐛 Proposed fix to actually verify banner persistence after suppressed input
       const afterSecondSpace = stripAnsi(stdoutBuf.slice(beforeLen));
-      // Banner still present (interactivity suppressed).
+      // Banner still present (interactivity suppressed). Either the post-Space
+      // slice re-rendered the banner OR the diff renderer produced no new
+      // frame at all (zero new content) — both prove suppression.
+      const fullAfter = stripAnsi(stdoutBuf);
       expect(
-        afterSecondSpace.includes('Terminal too small') ||
-          afterResize.includes('Terminal too small'),
-        `expected banner after suppressed Space; stdoutSlice:\n${afterSecondSpace}`,
+        afterSecondSpace.includes('Terminal too small') || afterSecondSpace.trim() === '',
+        `expected banner re-rendered or zero new bytes after suppressed Space; got:\n${afterSecondSpace}`,
+      ).toBe(true);
+      // Defensive: counter must NOT have incremented (Space was suppressed).
+      expect(
+        fullAfter.includes('1 of 3 selected across all tabs') &&
+          !fullAfter.includes('2 of 3 selected across all tabs'),
+        `Space should have been suppressed in sub-minimum state`,
       ).toBe(true);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/ccaudit/src/__tests__/tabbed-picker-live-counter.test.ts` around lines
474 - 480, The assertion is tautological because afterResize was already
asserted true; update the test in tabbed-picker-live-counter.test (look for
variables stdoutBuf, beforeLen, afterResize, afterSecondSpace) to explicitly
assert that afterSecondSpace includes the banner text—i.e., replace the
disjunction with a direct check that afterSecondSpace.includes('Terminal too
small') (or assert both afterResize and afterSecondSpace separately) so the test
verifies the banner persists after the suppressed Space input.
apps/ccaudit/src/__tests__/restore-interactive-roundtrip.test.ts-134-145 (1)

134-145: ⚠️ Potential issue | 🟡 Minor

Inverted polling condition — the "wait for picker readiness" loop is dead code in both this test and the referenced safety-invariants-tui-abort.test.ts.

The condition on line 138 reads while (spawned.child.exitCode !== null && ...), meaning while the child has exited. Since the child just spawned and is waiting on stdin, exitCode === null, so the loop condition is false on the first check and the loop body never executes. The exponential-backoff polling is dead code; only the 400ms grace period on lines 142–144 actually waits.

The comment states the intent clearly: "poll child.exitCode === null with exponential backoff." The reference test safety-invariants-tui-abort.test.ts (lines 139 and 284) has the identical inverted condition exitCode !== null and an explicit comment at line 127 confirming intent: "We poll child.exitCode === null". Both tests work only because the grace period (200–300ms in the reference, 400ms here) happens to suffice for the picker to mount. Under CI slowdown or slower picker initialization, both tests will race.

🐛 Proposed fix
         const maxWaitMs = 5_000;
         const startMs = Date.now();
         let delayMs = 100;
-        while (spawned.child.exitCode !== null && Date.now() - startMs < maxWaitMs) {
+        while (spawned.child.exitCode === null && Date.now() - startMs < maxWaitMs) {
           await new Promise((r) => setTimeout(r, delayMs));
           delayMs = Math.min(delayMs * 2, 500);
+          if (Date.now() - startMs > 600) break;
         }
         if (spawned.child.exitCode === null) {
           await new Promise((r) => setTimeout(r, 400));
         }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/ccaudit/src/__tests__/restore-interactive-roundtrip.test.ts` around
lines 134 - 145, The loop condition is inverted so the exponential-backoff
polling never runs; change the while in the test block that references
spawned.child.exitCode (and variables maxWaitMs, startMs, delayMs) to loop while
spawned.child.exitCode === null && Date.now() - startMs < maxWaitMs so it polls
until the child exits or timeout, keeping the same backoff logic and retaining
the final short grace sleep (await new Promise(...)) for safety.
README.md-468-468 (1)

468-468: ⚠️ Potential issue | 🟡 Minor

Wording: "MCP re-enable ops" is inaccurate.

Purge classifies archive ops only; the ops it skips are MCP disable ops (the name → ccaudit-disabled:name key-rename written by bust), not "re-enable" ops (which would only ever be produced by a future restore-side mutation). The same phrasing appears verbatim in docs/JSON-SCHEMA.md line 186 — both should be corrected together for consistency.

📝 Proposed wording
- Scope is **archive ops only**. Flag ops (memory frontmatter) and MCP re-enable ops are untouched by this command. Each executed mutation is recorded as an append-only `archive_purge` op in a fresh `purge-<ts>-<rand>.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.
+ Scope is **archive ops only**. Flag ops (memory frontmatter) and disable ops (MCP key-rename) are untouched by this command. Each executed mutation is recorded as an append-only `archive_purge` op in a fresh `purge-<ts>-<rand>.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.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@README.md` at line 468, Update the incorrect phrasing "MCP re-enable ops" to
"MCP disable ops" and add the clarifying parenthetical used in the comment;
locate the exact sentence in README.md and the identical sentence in
docs/JSON-SCHEMA.md (search for the string "MCP re-enable ops"), and change it
to something like "MCP disable ops (the `name → ccaudit-disabled:name`
key-rename written by `bust`)" so the docs correctly state that purge skips MCP
disable ops rather than re-enable ops.
docs/JSON-SCHEMA.md-186-186 (1)

186-186: ⚠️ Potential issue | 🟡 Minor

Wording: "disable (MCP re-enable)" is the wrong gloss.

The op-type that purge skips is disable (the bust-time MCP key-rename name → ccaudit-disabled:name). "Re-enable" is what restore performs later, and is also untouched by purge — but the parenthetical here confusingly labels the disable op as a "re-enable" op. The README mirrors this same wording at line 468. Suggest:

📝 Proposed wording
-**Scope.** Archive ops only. Flag (memory frontmatter) and disable (MCP re-enable) ops are **never** touched by purge. `classifyArchiveOps` filters by `op_type === 'archive'`.
+**Scope.** Archive ops only. Flag ops (memory frontmatter) and disable ops (MCP key-rename) are **never** touched by purge. `classifyArchiveOps` filters by `op_type === 'archive'`.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/JSON-SCHEMA.md` at line 186, The parenthetical "disable (MCP re-enable)"
is incorrect — the op purge skips is disable (the MCP bust-time key-rename `name
→ ccaudit-disabled:name`) and "re-enable" is performed later by restore; update
the wording in the sentence that mentions classifyArchiveOps and the mirrored
sentence in the README to replace the parenthetical with a correct gloss such as
"(MCP key-rename `name → ccaudit-disabled:name`)" or simply "(disable)" and
ensure `disable` and `restore` are named distinctly so readers cannot confuse
the two.
CHANGELOG.md-8-8 (1)

8-8: ⚠️ Potential issue | 🟡 Minor

Minor: inconsistent date separator between version headers.

Line 8 uses [1.5.0] - 2026-04-26 (hyphen) while Line 87 uses [1.5.0-beta.0] — 2026-04-19 (em-dash). Keep a Changelog uses a hyphen. The em-dash on the beta header is the only one in the file (the rest of the existing entries — 1.4.0, 1.3.1, 1.3.0, 1.2.1 — all use a hyphen).

📝 Suggested fix
-## [1.5.0-beta.0] — 2026-04-19
+## [1.5.0-beta.0] - 2026-04-19

Also applies to: 87-87

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@CHANGELOG.md` at line 8, Replace the em-dash in the beta header with a hyphen
so changelog version headers use a consistent separator: update the header for
"[1.5.0-beta.0] — 2026-04-19" to use a hyphen ("-") like "[1.5.0] - 2026-04-26",
ensuring all version headers (e.g., the "[1.5.0-beta.0]" header) match the
hyphen style used elsewhere.
apps/ccaudit/src/__tests__/tmux-e2e-manual-qa.test.ts-35-41 (1)

35-41: ⚠️ Potential issue | 🟡 Minor

Top-level beforeAll dist guard fires even when the suite is opt-in-skipped.

beforeAll is registered at module scope, not inside the describe.skipIf(... || !optIn) block at Line 64, so running this file without CCAUDIT_TMUX_E2E=1 and without a built dist/ will still throw from the guard. Move the dist-existence check inside the describe (or gate it by optIn) so the file is a no-op for normal pnpm test runs without a fresh build.

🛡️ Suggested fix
-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' || !optIn)(
   '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.`,
+        );
+      }
+    });

Also applies to: 64-66

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/ccaudit/src/__tests__/tmux-e2e-manual-qa.test.ts` around lines 35 - 41,
The top-level beforeAll guard that checks existsSync(distPath) must not run
unconditionally; move that dist existence check into the describe.skipIf(...)
block (or wrap it with the same optIn condition) so it only executes when the
suite is opted-in; specifically relocate the beforeAll that references distPath
and existsSync into the describe.skipIf(... || !optIn) scope (or add an early if
(!optIn) return around it) so describe.skipIf, optIn, beforeAll, distPath and
existsSync are aligned and the file is a no-op when not opt-in.
apps/ccaudit/src/cli/commands/purge-archive.ts-105-109 (1)

105-109: ⚠️ Potential issue | 🟡 Minor

Mutual-exclusion failure path bypasses --json envelope and history recording.

When both --dry-run and --yes are set, the command writes a plain stderr message and exits without honoring outMode.json or invoking safeRecordHistory. Compare with the classification (Lines 160-191) and execute (Lines 202-239) failure paths, which both emit a JSON envelope on outMode.json and record history before exiting. JSON consumers will see no parsable output, and the audit trail at ~/.claude/ccaudit/history.jsonl will miss this invocation.

🛡️ Suggested fix
     // D6 safety gate: --yes and --dry-run are mutually exclusive.
     if (dryRunFlag && yesFlag) {
-      process.stderr.write('flags are mutually exclusive: --dry-run, --yes\n');
+      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,
+              },
+            }),
+          ) + '\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);
     }

Note: this requires moving the homeDir/historyStartMs/safeRecordHistory setup (Lines 115-131) above the mutual-exclusion check.

Based on learnings: "History writes must never crash the main command; recordHistory must be wrapped in try/catch at the outermost layer; on failure emit one stderr warning and continue".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/ccaudit/src/cli/commands/purge-archive.ts` around lines 105 - 109, Move
the homeDir/historyStartMs/safeRecordHistory setup so it runs before the
mutual-exclusion check of dryRunFlag and yesFlag, and change the
mutual-exclusion failure path to emit the same JSON envelope when outMode.json
is set and to invoke safeRecordHistory before exiting; ensure the call to
recordHistory (safeRecordHistory) is wrapped in try/catch so it cannot crash the
command—on failure write a single stderr warning and continue—use the existing
symbols dryRunFlag, yesFlag, outMode.json, safeRecordHistory, homeDir, and
historyStartMs to locate and update the logic.
apps/ccaudit/src/__tests__/tmux-e2e-manual-qa.test.ts-67-93 (1)

67-93: ⚠️ Potential issue | 🟡 Minor

Silent return when tmux is missing causes tests to falsely report green.

Each test starts with if (!(await hasTmux())) return;. Without tmux installed, every assertion is skipped and the test reports as passed — which is indistinguishable from real coverage. Promote this to an explicit skip so the absence is visible in the report:

beforeAll(async () => {
  if (!(await hasTmux())) {
    // Vitest 4.1: skip the whole describe at runtime
    ctx.skip();
  }
});

…or pivot to a top-level describe.skipIf(...) augmented by a synchronous tmux probe. As written, a contributor on a machine without tmux gets a green run that hides regressions.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/ccaudit/src/__tests__/tmux-e2e-manual-qa.test.ts` around lines 67 - 93,
Tests silently return when tmux is missing because each case uses "if (!(await
hasTmux())) return;", so change the suite to explicitly skip when tmux is
absent: add a beforeAll that awaits hasTmux() and calls ctx.skip() if false (or
replace the top-level block with a describe.skipIf / synchronous tmux probe) so
missing tmux produces a visible skipped test instead of silently passing; locate
uses of hasTmux() in the tmux-e2e-manual-qa.test.ts file (the individual it
blocks and the session helpers like startTmuxE2E) and remove the per-test early
returns after implementing the centralized beforeAll/describe.skipIf guard.
apps/ccaudit/src/__tests__/safety-invariants-framework.test.ts-221-230 (1)

221-230: ⚠️ Potential issue | 🟡 Minor

Test C leaks the beforeEach-allocated tmpdir.

beforeEach (Line 63) creates an ccaudit-inv-s6-* tmpdir with the single-framework fixture, then Test C overwrites the tmpHome binding with a new ccaudit-inv-s6-multi-* tmpdir at Lines 222-230. The original directory is now unreferenced and the afterEach rm(tmpHome, ...) (Line 75) cleans up only the multi-framework one. Every run of Test C leaks the baseline tmpdir under os.tmpdir().

🛡️ Suggested fix
   it('Test C — INV-S6 multi-framework: ...', async () => {
-    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;
-    })();
+    // Discard the single-framework baseline staged by beforeEach and reuse the
+    // same tmpHome to avoid leaking the unreferenced fixture directory.
+    await rm(tmpHome, { recursive: true, force: true });
+    tmpHome = await mkdtemp(path.join(tmpdir(), 'ccaudit-inv-s6-multi-'));
+    await buildFakePs(tmpHome);
+    await createMultiFrameworkFixture(tmpHome, [
+      { prefix: 'gsd', usedMembers: ['planner'], ghostMembers: ['researcher', 'verifier'] },
+      { prefix: 'sc', usedMembers: ['analyze'], ghostMembers: ['audit', 'improve'] },
+    ]);

Alternative: extend createMultiFrameworkFixture to be additive against an existing tmpHome (or wipe .claude/ first) and skip the second mkdtemp.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/ccaudit/src/__tests__/safety-invariants-framework.test.ts` around lines
221 - 230, The test leaks the tmpdir because beforeEach created a
ccaudit-inv-s6-* tmpHome and Test C reassigns tmpHome by calling mkdtemp again;
instead reuse the beforeEach tmpHome by removing the mkdtemp/new tmpHome
allocation in Test C and call createMultiFrameworkFixture(tmpHome, [...]) (or,
if you prefer the alternate approach, make createMultiFrameworkFixture accept an
existing tmpHome and mutate it) so only the single tmpHome created in beforeEach
is referenced and cleaned up by afterEach (refer to the tmpHome variable,
beforeEach/afterEach hooks, mkdtemp call in Test C, and
createMultiFrameworkFixture).
apps/ccaudit/src/__tests__/fixtures/manual-qa-followups.ts-223-224 (1)

223-224: ⚠️ Potential issue | 🟡 Minor

Use real ISO timestamps in the fixture headers.

These checkpoint_timestamp values are filename-safe dashed strings, not ISO-8601 UTC timestamps. Any test path that parses header times will now diverge from production manifests.

As per coding guidelines, "Use ISO 8601 UTC strings (new Date().toISOString()) for time values".

Also applies to: 285-286

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/ccaudit/src/__tests__/fixtures/manual-qa-followups.ts` around lines 223
- 224, The fixture header uses filename-safe dashed timestamps for
checkpoint_timestamp (e.g., checkpoint_timestamp: '2026-04-22T09-00-00-000Z')
instead of a real ISO-8601 UTC string; update each checkpoint_timestamp entry in
this fixture (manual-qa-followups.ts) to a proper ISO 8601 UTC format produced
by new Date().toISOString() (for example '2026-04-22T09:00:00.000Z'), and make
the same change for the other occurrence of checkpoint_timestamp in this file.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 555e64e8-8245-4110-ae30-6a7bcb37be2f

📥 Commits

Reviewing files that changed from the base of the PR and between d4f61dc and c534da3.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (117)
  • CHANGELOG.md
  • CLAUDE.md
  • README.md
  • apps/ccaudit/package.json
  • apps/ccaudit/scripts/bundle-baseline-03.2.txt
  • apps/ccaudit/scripts/bundle-baseline-phase-05.txt
  • apps/ccaudit/scripts/bundle-baseline-phase-06.txt
  • apps/ccaudit/scripts/bundle-baseline-phase-09.txt
  • apps/ccaudit/scripts/bundle-baseline.txt
  • apps/ccaudit/scripts/bundle-size-check.mjs
  • apps/ccaudit/scripts/bundle-smoke-test.mjs
  • apps/ccaudit/src/__tests__/__fixtures__/regime-post-2-1-117/.claude.json
  • apps/ccaudit/src/__tests__/__fixtures__/regime-post-2-1-117/.claude/settings.json
  • apps/ccaudit/src/__tests__/__fixtures__/regime-pre-2-1-116/.claude.json
  • apps/ccaudit/src/__tests__/__fixtures__/regime-pre-2-1-116/.claude/settings.json
  • apps/ccaudit/src/__tests__/__fixtures__/restore-interactive/bust-2026-04-17T10-00-00-000Z-aaaa.jsonl
  • apps/ccaudit/src/__tests__/__fixtures__/restore-interactive/bust-2026-04-18T10-00-00-000Z-bbbb.jsonl
  • apps/ccaudit/src/__tests__/_test-helpers.ts
  • apps/ccaudit/src/__tests__/command-archival.test.ts
  • apps/ccaudit/src/__tests__/empty-inventory.test.ts
  • apps/ccaudit/src/__tests__/fixtures/ghost-500-items.ts
  • apps/ccaudit/src/__tests__/fixtures/manual-qa-followups.md
  • apps/ccaudit/src/__tests__/fixtures/manual-qa-followups.ts
  • apps/ccaudit/src/__tests__/fixtures/tmux-e2e.md
  • apps/ccaudit/src/__tests__/fixtures/tmux-e2e.ts
  • apps/ccaudit/src/__tests__/ghost-select-ids.test.ts
  • apps/ccaudit/src/__tests__/help-output.test.ts
  • apps/ccaudit/src/__tests__/interactive-smoke.test.ts
  • apps/ccaudit/src/__tests__/no-interactive-env.test.ts
  • apps/ccaudit/src/__tests__/pagination-500.test.ts
  • apps/ccaudit/src/__tests__/preflight-retry.test.ts
  • apps/ccaudit/src/__tests__/purge-archive.test.ts
  • apps/ccaudit/src/__tests__/regime-detection-cc-2-1-117-compat.test.ts
  • apps/ccaudit/src/__tests__/restore-all-matching.test.ts
  • apps/ccaudit/src/__tests__/restore-command.test.ts
  • apps/ccaudit/src/__tests__/restore-flag-mutual-exclusion.test.ts
  • apps/ccaudit/src/__tests__/restore-interactive-memory-roundtrip.test.ts
  • apps/ccaudit/src/__tests__/restore-interactive-roundtrip.test.ts
  • apps/ccaudit/src/__tests__/restore-interactive-source-exists.test.ts
  • apps/ccaudit/src/__tests__/restore-json-envelope.test.ts
  • apps/ccaudit/src/__tests__/restore-name-ambiguity.test.ts
  • apps/ccaudit/src/__tests__/restore-stale-filter.test.ts
  • apps/ccaudit/src/__tests__/safety-invariants-framework.test.ts
  • apps/ccaudit/src/__tests__/safety-invariants-mcp.test.ts
  • apps/ccaudit/src/__tests__/safety-invariants-restore-roundtrip.test.ts
  • apps/ccaudit/src/__tests__/safety-invariants-tui-abort.test.ts
  • apps/ccaudit/src/__tests__/scanner-multi-config-mcp.test.ts
  • apps/ccaudit/src/__tests__/tabbed-picker-50-item-fixture.test.ts
  • apps/ccaudit/src/__tests__/tabbed-picker-bust-parity.test.ts
  • apps/ccaudit/src/__tests__/tabbed-picker-filter.test.ts
  • apps/ccaudit/src/__tests__/tabbed-picker-force-partial-banner.test.ts
  • apps/ccaudit/src/__tests__/tabbed-picker-framework-group.test.ts
  • apps/ccaudit/src/__tests__/tabbed-picker-help-overlay.test.ts
  • apps/ccaudit/src/__tests__/tabbed-picker-live-counter.test.ts
  • apps/ccaudit/src/__tests__/tabbed-picker-mcp-multi-config.test.ts
  • apps/ccaudit/src/__tests__/tabbed-picker-overflow.test.ts
  • apps/ccaudit/src/__tests__/tabbed-picker-protection.test.ts
  • apps/ccaudit/src/__tests__/tabbed-picker-sort-cycle.test.ts
  • apps/ccaudit/src/__tests__/tabbed-picker-tab-nav-keys.test.ts
  • apps/ccaudit/src/__tests__/tabbed-picker-terminal-too-short.test.ts
  • apps/ccaudit/src/__tests__/tmux-e2e-manual-qa.test.ts
  • apps/ccaudit/src/cli/_shared-args.ts
  • apps/ccaudit/src/cli/commands/ghost.ts
  • apps/ccaudit/src/cli/commands/inventory.ts
  • apps/ccaudit/src/cli/commands/mcp.ts
  • apps/ccaudit/src/cli/commands/purge-archive.ts
  • apps/ccaudit/src/cli/commands/restore.ts
  • apps/ccaudit/src/cli/commands/trend.ts
  • apps/ccaudit/src/cli/index.ts
  • docs/JSON-SCHEMA.md
  • package.json
  • packages/internal/src/cli/env.ts
  • packages/internal/src/cli/index.ts
  • packages/internal/src/index.ts
  • packages/internal/src/remediation/__fixtures__/ghost-hash-golden.json
  • packages/internal/src/remediation/_archive-move.ts
  • packages/internal/src/remediation/atomic-write.ts
  • packages/internal/src/remediation/bust.ts
  • packages/internal/src/remediation/change-plan.ts
  • packages/internal/src/remediation/checkpoint.ts
  • packages/internal/src/remediation/index.ts
  • packages/internal/src/remediation/manifest.ts
  • packages/internal/src/remediation/purge.ts
  • packages/internal/src/remediation/reclaim.ts
  • packages/internal/src/remediation/restore.ts
  • packages/internal/src/remediation/savings.ts
  • packages/internal/src/scanner/_config-refs.ts
  • packages/internal/src/scanner/_present-path.ts
  • packages/internal/src/scanner/annotate.ts
  • packages/internal/src/scanner/index.ts
  • packages/internal/src/scanner/scan-hooks.ts
  • packages/internal/src/scanner/scan-mcp.ts
  • packages/internal/src/scanner/types.ts
  • packages/internal/src/token/format.ts
  • packages/internal/src/token/index.ts
  • packages/internal/src/types.ts
  • packages/terminal/package.json
  • packages/terminal/src/index.ts
  • packages/terminal/src/tables/change-plan.ts
  • packages/terminal/src/tui/_filter-sort.ts
  • packages/terminal/src/tui/_force-partial-banner.ts
  • packages/terminal/src/tui/_glyph-capability.ts
  • packages/terminal/src/tui/_glyphs.ts
  • packages/terminal/src/tui/_help-overlay.ts
  • packages/terminal/src/tui/_mcp-warning-render.ts
  • packages/terminal/src/tui/_preflight-copy.ts
  • packages/terminal/src/tui/_protection-render.ts
  • packages/terminal/src/tui/_tab-bar.ts
  • packages/terminal/src/tui/_tui-mode.ts
  • packages/terminal/src/tui/_viewport.ts
  • packages/terminal/src/tui/auto-open-prompt.ts
  • packages/terminal/src/tui/confirmation.ts
  • packages/terminal/src/tui/index.ts
  • packages/terminal/src/tui/select-ghosts.ts
  • packages/terminal/src/tui/select-restore.ts
  • packages/terminal/src/tui/tabbed-picker.ts
  • pnpm-workspace.yaml

Comment thread apps/ccaudit/src/__tests__/fixtures/manual-qa-followups.ts
Comment thread apps/ccaudit/src/__tests__/restore-interactive-memory-roundtrip.test.ts Outdated
Comment thread apps/ccaudit/src/__tests__/tabbed-picker-help-overlay.test.ts Outdated
Comment thread apps/ccaudit/src/cli/commands/ghost.ts
Comment thread apps/ccaudit/src/cli/commands/restore.ts Outdated
Comment thread packages/internal/src/remediation/purge.ts Outdated
Comment thread packages/internal/src/remediation/reclaim.ts
Comment thread packages/internal/src/remediation/restore.ts
Comment thread packages/internal/src/remediation/restore.ts
Comment thread packages/internal/src/scanner/scan-mcp.ts

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 11

♻️ Duplicate comments (1)
packages/internal/src/scanner/scan-mcp.ts (1)

353-415: ⚠️ Potential issue | 🔴 Critical

Windows CI is still failing on these in-source tests.

The pipeline shows the assertion at Line 394 still red on windows-latest, 22 (refs[0] receives C:/Users/.../scan-mcp-refs-*/claude.json instead of .mcp.json). The integration counterpart in apps/ccaudit/src/__tests__/scanner-multi-config-mcp.test.ts was guarded with describe.skipIf(process.platform === 'win32'), but this in-source describe block was not — so Windows CI keeps failing on the same presentPath rendering gap.

Pick one path forward:

  • Preferred: fix _present-path.ts to normalize Windows separators (resolve + path.relative against projPath/home, then convert backslashes to /) so .mcp.json and ~/... rendering work cross-platform.
  • Tactical: gate this describe with describe.skipIf(process.platform === 'win32')(...) to mirror the integration test until presentPath is fixed.
🩹 Tactical skip-on-Windows (mirrors the integration test)
-  describe('scanMcpServers — configRefs (Phase 6, D6-02)', () => {
+  describe.skipIf(process.platform === 'win32')('scanMcpServers — configRefs (Phase 6, D6-02)', () => {
     let tmpDir: string;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/internal/src/scanner/scan-mcp.ts` around lines 353 - 415, The tests
fail on Windows because _present-path.ts doesn't normalize backslashes, so
presentPath produces Windows-style absolute paths instead of project-relative
strings like '.mcp.json'; update the presentPath implementation (the function
presentPath in _present-path.ts) to resolve then path.relative against the
projPath/home bucket and convert any backslashes to forward slashes (e.g.,
replace all '\\' with '/') before returning, ensuring project-local refs render
as '.mcp.json' and ~/... rendering is consistent across platforms.
🧹 Nitpick comments (31)
packages/internal/src/scanner/scan-memory.ts (1)

88-90: Optional: add a Windows-path test for the new normalization.

The change correctly normalizes Windows separators in the emitted name so manifest IDs/display strings are stable across platforms. The existing T40 tests, however, only exercise POSIX paths, so the new replace(/\\/g, '/') is a no-op in CI. Consider adding a small unit test for scanImportChain (or a focused helper) that passes a Windows-style projRoot/imp.path pair and asserts the resulting name contains forward slashes only — this guards against regressions if the normalization is later moved or removed.

Note also that on POSIX, \ is technically a valid filename character; the unconditional backslash→slash replace would mangle such names. In practice this is a non-issue for Claude memory imports, so flagging only as a minor caveat.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/internal/src/scanner/scan-memory.ts` around lines 88 - 90, Add a
unit test for scanImportChain (or the helper that computes relPath/name) that
supplies a Windows-style projRoot and imp.path (e.g., containing backslashes)
and asserts the produced name uses only forward slashes; specifically exercise
the branch that computes relPath using path.relative and the replace(/\\/g, '/')
normalization so the test fails if backslashes remain or the normalization is
removed, and place the test next to other T40-style scanner tests referencing
scanImportChain, projRoot, imp.path, relPath, and name.
apps/ccaudit/scripts/bundle-size-check.mjs (1)

14-14: Drop the hardcoded 15360B from log/error strings.

BUDGET_BYTES is already a constant; mirror what the phase block does and interpolate it so the two stay in sync if the budget ever changes.

♻️ Suggested change
-console.log(`[bundle-size] actual=${actual}B baseline=${baseline}B delta=${delta}B budget=15360B`);
+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})`);
+  console.error(`[bundle-size] FAIL: delta exceeds budget (${delta} > ${BUDGET_BYTES})`);
   process.exit(1);
 }

Also applies to: 42-42, 44-46

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/ccaudit/scripts/bundle-size-check.mjs` at line 14, Replace the hardcoded
"15360B" in log/error messages with the BUDGET_BYTES constant so they stay in
sync; locate occurrences that currently embed "15360B" (the messages printed
when comparing size to budget and the phase block logs) and interpolate or
format them to use BUDGET_BYTES (and an appropriate unit string like "B")
instead of the literal number, keeping the wording the same but deriving the
value from the BUDGET_BYTES constant.
apps/ccaudit/src/__tests__/fixtures/tmux-e2e.ts (2)

228-233: Consider adding a timeout to the tmux wrapper.

execFile here has no timeout, so a wedged tmux invocation (e.g., a hung server, an unresponsive pane) will block until vitest's outer test timeout, producing a less actionable failure. Since waitForText already does its own polling/timeout, individual tmux calls being long-running is unexpected and worth bounding defensively.

♻️ Proposed addition
 async function tmux(args: readonly string[]): Promise<TmuxRunResult> {
   const { stdout, stderr } = await execFile('tmux', [...args], {
     maxBuffer: 10 * 1024 * 1024,
+    timeout: 10_000,
   });
   return { stdout, stderr };
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/ccaudit/src/__tests__/fixtures/tmux-e2e.ts` around lines 228 - 233, The
tmux wrapper (function tmux) calls execFile without a timeout which can hang
tests; update tmux to accept an optional timeout parameter (or use a sensible
default like 5000–10000ms) and pass it into execFile options (e.g., { timeout,
maxBuffer: ... }), so individual tmux invocations are bounded; ensure the
function signature (tmux and TmuxRunResult usage sites) is updated accordingly
and propagate/handle the timeout error so tests fail fast with a clear message.

142-146: Redundant double stripAnsi on the default path.

When opts.stripAnsi is undefined/true, capture() is called with ansi: false, which already strips ANSI internally and returns clean text. The branch on Line 146 then runs stripAnsi(raw) again on already-stripped output. Functionally a no-op, but it does extra regex passes per poll iteration and obscures intent.

♻️ Proposed simplification
-      const raw = await this.capture({
-        startLine: opts.startLine ?? -200,
-        ansi: opts.stripAnsi === false,
-      });
-      lastCapture = opts.stripAnsi === false ? raw : stripAnsi(raw);
+      lastCapture = await this.capture({
+        startLine: opts.startLine ?? -200,
+        ansi: opts.stripAnsi === false,
+      });

capture() already honors the ansi flag, so the caller doesn't need to re-strip.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/ccaudit/src/__tests__/fixtures/tmux-e2e.ts` around lines 142 - 146, The
code double-strips ANSI: capture() already respects the ansi flag derived from
opts.stripAnsi, so remove the redundant stripAnsi(raw) call and simply assign
lastCapture = raw; update the block around the this.capture(...) call to stop
re-running stripAnsi and rely on capture's ansi handling (refer to
this.capture(...) and lastCapture / opts.stripAnsi).
packages/internal/src/scanner/scan-hooks.ts (2)

534-555: Extend privacy assertion to the tool_name and name fallback paths.

The mcp_tool privacy invariant is only asserted on the tool field path (line 497). The fallback branches (tool_name, name) flow through the same shortHash, so there's no real risk today, but adding a noCommandLeak check here would lock the invariant against future refactors that might (e.g.) inadvertently copy name onto the emitted item's name field — note that the inventory item itself has a name property, so a regression where leafObj['name'] ends up assigned instead of hashed wouldn't be caught by the existing assertions.

Proposed addition
       const items = await extractHookItemsFromFile(settingsPath, 'global', null);
       expect(items).toHaveLength(2);
+      expect(noCommandLeak(items, 'mcp__a__x')).toBe(true);
+      expect(noCommandLeak(items, 'mcp__b__y')).toBe(true);
       for (const item of items) {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/internal/src/scanner/scan-hooks.ts` around lines 534 - 555, The
mcp_tool privacy invariant currently only asserts noCommandLeak on the 'tool'
path; extend that assertion to the fallback branches that use 'tool_name' and
'name' so the same shortHash privacy guarantee is enforced there too. Locate the
mcp_tool handling in scan-hooks.ts (the branch that computes shortHash and
currently calls noCommandLeak for the 'tool' field) and add the same
noCommandLeak check when the code selects tool_name or name as the identifier
(the logic that produces item.name / emitted item's name); ensure you call
noCommandLeak with the same parameters used for the 'tool' path so regressions
that copy raw name into item.name are caught.

160-167: Address potential name collision when matcher contains :tool as a substring.

Claude Code hook matchers can include colon characters when treated as regex patterns. A command leaf with matcher like "Bash:tool" would generate PreToolUse:Bash:tool:<hash>, identical to an mcp_tool leaf with matcher "Bash". While unlikely in practice, this could cause name collisions and deduplication conflicts.

Defensively, either:

  1. Sanitize : from matcherPart, or
  2. Reposition the discriminator to prevent mid-string collision (e.g., ${event}:tool@${matcherPart}:${hash})
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/internal/src/scanner/scan-hooks.ts` around lines 160 - 167, The
constructed `name` can collide when matcher contains ":tool" (e.g., matcherPart
"Bash:tool") because the command branch produces
`${event}:${matcherPart}:${hash}` which can equal the mcp_tool branch; update
the `name` generation in scan-hooks (symbols: matcherPart, leafType, name,
event, hash) to avoid mid-string collisions by either stripping or encoding
colons from matcherPart (e.g., replace ":" with "_" or percent-encode) OR move
the discriminator out of the matcher segment (e.g.,
`${event}:tool@${matcherPart}:${hash}` for tool leaves) and apply the same
change consistently to any consumers that parse or dedupe names so existing
comparisons keep working.
packages/terminal/src/tui/_glyphs.ts (1)

9-13: Minor doc/code mismatch on NO_COLOR semantics.

Line 11 says NO_COLOR triggers ASCII for "any value", but the implementation (correctly, per no-color.org) requires a non-empty string and the dedicated test at lines 109-114 enforces that. Worth tightening the comment so future readers don't "fix" the empty-string check.

📝 Proposed doc tweak
- *   2. `NO_COLOR` env var set (any value; convention is truthy-presence)
+ *   2. `NO_COLOR` env var set to a non-empty value (per no-color.org)

Also applies to: 72-77

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/terminal/src/tui/_glyphs.ts` around lines 9 - 13, The comment
describing the ASCII fallback in packages/terminal/src/tui/_glyphs.ts is
misleading about NO_COLOR semantics (it says "any value" but the code and tests
require a non-empty string); update the comment text near the ASCII fallback
triggers to state that NO_COLOR must be a non-empty string to trigger ASCII
mode, and make the same wording change for the second occurrence that documents
the same behavior; reference the NO_COLOR env check and the opts.noColor
documentation so the comment aligns with the implementation and existing tests.
packages/internal/src/remediation/change-plan.ts (1)

313-330: Minor: hard-coded canonical id strings in tests will silently rot if canonicalItemId ever changes shape.

The literal templates agent|global||/tmp/a1 and mcp-server|global||m1|/tmp/m1 reproduce the pipe-delimited internals of canonicalItemId. If that format gets a new field or separator, these tests break far from the change site and the failure message won't mention the canonical-id contract.

Cheap defense: build the id via canonicalItemId(...) directly so the tests pin behavior, not a string format.

Sketch
-      const idA1 = `agent|global||/tmp/a1`;
-      const idM1 = `mcp-server|global||m1|/tmp/m1`;
+      const idA1 = canonicalItemId({ category: 'agent', scope: 'global', projectPath: null, name: 'a1', path: '/tmp/a1' });
+      const idM1 = canonicalItemId({ category: 'mcp-server', scope: 'global', projectPath: null, name: 'm1', path: '/tmp/m1' });

(Same idea for the other tests in the block.)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/internal/src/remediation/change-plan.ts` around lines 313 - 330,
Replace the hard-coded pipe-delimited id strings (e.g., idA1 and idM1) with
calls to the canonicalItemId helper so tests rely on the canonicalId builder
rather than string format; locate the test block that uses makeResult and change
the id construction for idA1, idM1 (and the other similar ids in this file) to
call canonicalItemId with the same fields used when creating the makeResult item
(category, scope, projectPath, name, path) so the test will follow any future
shape changes to canonicalItemId.
packages/terminal/src/tables/change-plan.ts (1)

75-89: Optional: the commands row arrow doesn't column-align with agents/skills.

agents/skills are 6 chars; commands is 8. With the current two-space gap before , the arrow column shifts right two positions on the commands row only. Cosmetic, but if you want the ARCHIVE block to look like an aligned table (which today's two rows already do), pad the noun to a fixed width.

Proposed fix
     if (plan.counts.agents > 0) {
       lines.push(
-        `  ${String(plan.counts.agents).padStart(3)} agents  → ~/.claude/ccaudit/archived/agents/`,
+        `  ${String(plan.counts.agents).padStart(3)} ${'agents'.padEnd(8)}  → ~/.claude/ccaudit/archived/agents/`,
       );
     }
     if (plan.counts.skills > 0) {
       lines.push(
-        `  ${String(plan.counts.skills).padStart(3)} skills  → ~/.claude/ccaudit/archived/skills/`,
+        `  ${String(plan.counts.skills).padStart(3)} ${'skills'.padEnd(8)}  → ~/.claude/ccaudit/archived/skills/`,
       );
     }
     if (plan.counts.commands > 0) {
       lines.push(
-        `  ${String(plan.counts.commands).padStart(3)} commands  → ~/.claude/ccaudit/archived/commands/`,
+        `  ${String(plan.counts.commands).padStart(3)} ${'commands'.padEnd(8)}  → ~/.claude/ccaudit/archived/commands/`,
       );
     }

Note: the existing test at line 382 uses /1 commands\s+→/ so it'd still match; line 320 ' 2 agents' would need either updating or relaxing if you take this.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/terminal/src/tables/change-plan.ts` around lines 75 - 89, The three
lines that push agent/skill/command summary strings (the lines.push calls that
read plan.counts.agents, plan.counts.skills and plan.counts.commands) produce
misaligned arrows because the noun lengths differ; fix by formatting the noun
field to a fixed width before the two-space gap (e.g., pad the noun to the same
width for "agents"/"skills"/"commands" using String(...).padEnd or template
construction) so the "→" column aligns across all rows; update the lines.push
expressions for the agents/skills/commands rows (or introduce a small helper to
format "N <noun>" with a constant noun column width) and run/adjust any tests
that assert spacing if needed.
apps/ccaudit/src/__tests__/restore-flag-mutual-exclusion.test.ts (1)

48-64: Optional: third mutual-exclusion combination is not covered.

You assert --interactive --name and --name --all-matching, but not --interactive --all-matching. Cheap to add for completeness — if validateRestoreFlagExclusion ever evolves into per-pair logic instead of a single 3-way guard, the missing case will silently regress.

Proposed addition
+    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');
+    });

As per coding guidelines: "Integration tests live under apps/ccaudit/src/__tests__/" — the file location is correct.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/ccaudit/src/__tests__/restore-flag-mutual-exclusion.test.ts` around
lines 48 - 64, Add the missing mutual-exclusion test for the `restore` command
covering the `--interactive` + `--all-matching` pair: add a new test in
restore-flag-mutual-exclusion.test.ts that calls `runCcauditCli(tmpHome,
['restore','--interactive','--all-matching','foo'], ...)`, asserts exitCode is 1
and stderr contains "flags are mutually exclusive"; this completes coverage for
the three pairwise combinations so future changes to
`validateRestoreFlagExclusion` (or similar validation logic) won’t silently
regress.
apps/ccaudit/src/__tests__/restore-corrupt-manifest.test.ts (1)

86-90: Use definite-assignment-aware pattern instead of parsed!.

The parsed! non-null after writing inside an expect(...).not.toThrow() callback works but loses type narrowing for the toHaveProperty line if anyone refactors the assertion away. Minor — assigning before the expect makes intent clearer.

-      let parsed: Record<string, unknown>;
-      expect(() => {
-        parsed = JSON.parse(stdout) as Record<string, unknown>;
-      }, `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');
+      let parsed: Record<string, unknown> = {};
+      try {
+        parsed = JSON.parse(stdout) as Record<string, unknown>;
+      } catch (e) {
+        throw new Error(`stdout must be valid JSON, got:\n${stdout}\n${(e as Error).message}`);
+      }
+      expect(parsed).toHaveProperty('meta');
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/ccaudit/src/__tests__/restore-corrupt-manifest.test.ts` around lines 86
- 90, Assign the parsed value before asserting to avoid using the non-null
assertion; create a parsing function (e.g., const parseFn = () =>
JSON.parse(stdout) as Record<string, unknown>) and use
expect(parseFn).not.toThrow(), then set parsed = parseFn() and call
expect(parsed).toHaveProperty('meta'). This keeps JSON.parse, parsed, stdout,
and the expect(...).not.toThrow()/toHaveProperty assertions but removes the need
for parsed! and preserves type narrowing if the test is refactored.
packages/terminal/src/tui/_force-partial-banner.ts (1)

36-41: Banner suffix joins without space when needed — current code is correct, just locking it.

${BASE_TEXT}${suffix} works because ZERO_PROTECTED_SUFFIX already starts with a leading space. If anyone edits the constant later and drops that space, output runs together. Worth a tiny test to lock this:

     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);
+      expect(out).toMatch(/\.\s+\(no protected/); // ensure space before '('
     });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/terminal/src/tui/_force-partial-banner.ts` around lines 36 - 41,
renderForcePartialBanner currently concatenates BASE_TEXT and
ZERO_PROTECTED_SUFFIX relying on the suffix to include a leading space; change
renderForcePartialBanner to always insert a single separating space when suffix
is non-empty (e.g., compute suffixNormalized = suffix.trim() and return
`${BASE_TEXT} ${suffixNormalized}` when suffixNormalized is truthy, otherwise
just BASE_TEXT) so future edits to ZERO_PROTECTED_SUFFIX can't remove the space,
and add a small unit test for renderForcePartialBanner that asserts the banner
contains a space between BASE_TEXT and the suffix when protectedCount is zero
(use the renderForcePartialBanner function and ZERO_PROTECTED_SUFFIX/BASE_TEXT
constants to construct the test).
apps/ccaudit/src/__tests__/_test-helpers.ts (2)

10-32: Doc references undefined maxWaitMs.

The comment talks about "until maxWaitMs elapses" / "loop runs until maxWaitMs", but the function only takes graceMs and uses an internal earlyExitMs = 1_000 ceiling. Either rename the doc to mention earlyExitMs, or expose maxWaitMs as a parameter for consistency with waitForMarker.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/ccaudit/src/__tests__/_test-helpers.ts` around lines 10 - 32, The
comment for waitForPicker mentions a non-existent maxWaitMs; update the code to
make docs and signature consistent by adding an optional maxWaitMs parameter (or
renaming references to earlyExitMs). Specifically, modify the waitForPicker
function signature to accept maxWaitMs?: number (default e.g. 1000) instead of
hardcoding earlyExitMs, replace the internal earlyExitMs usage with maxWaitMs,
and update the JSDoc text to reference maxWaitMs (or, if you prefer the opposite
approach, change the doc text to reference earlyExitMs and mention graceMs for
clarity); mirror the same naming/behavior used by waitForMarker to keep
consistency.

253-258: Dead void killed — drop or actually swallow the rejection path.

killed is set in the timeout branch but the promise has already reject(...)-ed there, so by the time close fires the consumer no longer sees this resolve. The void killed is a no-op marker. Either delete it, or guard the resolve so a post-rejection close doesn't double-settle (Node's Promise will silently swallow that, but it's still confusing).

Suggested cleanup
     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 });
+      if (killed) return; // already rejected via timeout
+      resolve({ stdout, stderr, exitCode: code, durationMs: Date.now() - start });
     });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/ccaudit/src/__tests__/_test-helpers.ts` around lines 253 - 258, The
no-op "void killed" in the child.on('close', ...) handler should be removed and
the resolve should be guarded so we don't attempt to resolve after the promise
was already rejected on timeout; update the handler around child.on('close',
(code) => { ... resolve({ stdout, stderr, exitCode: code, durationMs: ... }) })
to check the existing killed flag (or use a new settled boolean) and only call
resolve when not killed/not already settled (i.e., if (!killed) resolve(...));
keep clearTimeout(timer) as-is and ensure the timeout branch still calls
reject(...) and sets killed/settled consistently.
apps/ccaudit/src/__tests__/tabbed-picker-protection.test.ts (2)

76-80: Hoist the dynamic node:fs/promises import.

Dynamic await import(...) for a Node built-in inside beforeEach adds churn for no benefit; static import keeps tree-shake/lint behavior consistent with the other test files in the suite.

+import { writeFile, utimes } from 'node:fs/promises';
 ...
-      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);
+      await writeFile(plainPath, '# plain ghost\n', 'utf8');
+      await utimes(plainPath, sixtyDaysAgo, sixtyDaysAgo);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/ccaudit/src/__tests__/tabbed-picker-protection.test.ts` around lines 76
- 80, Replace the dynamic import(await import('node:fs/promises')) inside the
test setup with a top-level static import of the built-in module so tests behave
consistently; locate the dynamic import usage in the test that assigns to fs
(used with sixtyDaysAgo and plainPath) and change it to reference a statically
imported fs from 'node:fs/promises' so the beforeEach no longer performs a
runtime import.

87-106: Unicode assertion is vulnerable to flakiness due to piped stdio bypassing color detection.

shouldUseAscii() in packages/terminal/src/tui/_glyph-capability.ts (line 48) gates on stdout.hasColors?.() === false, which returns true for piped streams regardless of FORCE_COLOR=1. Since runCcauditGhost() spawns with stdio: ['pipe', 'pipe', 'pipe'], the child's process.stdout.hasColors() will return false, triggering ASCII fallback mode and replacing [🔒] with [L]. The test explicitly disables NO_COLOR (see baseEnv() comment) to avoid this, but FORCE_COLOR=1 and CCAUDIT_FORCE_TTY=1 alone cannot override the piped stdio detection. Consider mocking or controlling the stdout capability object in the test harness to guarantee hasColors() returns true, or accept the flake risk if the test runner's environment reliably inherits color support.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/ccaudit/src/__tests__/tabbed-picker-protection.test.ts` around lines 87
- 106, The test is flaky because glyph selection
(packages/terminal/src/tui/_glyph-capability.ts -> shouldUseAscii) uses
stdout.hasColors() which returns false for piped stdio from runCcauditGhost(),
causing Unicode "[🔒]" to become ASCII "[L]". Fix the test by forcing the child
process to report color support or mocking the capability: either spawn with a
pseudo-tty so stdout.hasColors() returns true (adjust runCcauditGhost() spawn
options) or, inside the test, inject/mutate the child's stdout.hasColors to a
function that returns true (or stub shouldUseAscii behavior) before the app
renders; reference runCcauditGhost(), baseEnv(), and shouldUseAscii() when
making the change so the test reliably sees Unicode glyphs.
apps/ccaudit/src/__tests__/restore-interactive-roundtrip.test.ts (2)

63-110: Inline spawnRestoreInteractive is fine for now, but consider hoisting.

Three v1.5 restore-interactive tests already pattern-match this same boilerplate (spawn restore --interactive with CCAUDIT_FORCE_TTY=1, COLUMNS/LINES, NO_COLOR). When you add a 4th, promote this to _test-helpers.ts alongside runCcauditGhost (e.g. runCcauditCommand(tmpHome, 'restore', flags, opts)) so the env, PATH, and timeout policy stay identical across files. Not blocking.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/ccaudit/src/__tests__/restore-interactive-roundtrip.test.ts` around
lines 63 - 110, The tests duplicate spawnRestoreInteractive boilerplate (env
vars like CCAUDIT_FORCE_TTY, COLUMNS, LINES, NO_COLOR, PATH setup and timeout
policy); hoist this helper into a shared test helper module (e.g.,
_test-helpers.ts) and replace local uses with a common function such as
runCcauditCommand(tmpHome, 'restore', flags, opts) or reuse runCcauditGhost
style helper; ensure the hoisted helper preserves the same behavior/signature as
the current spawnRestoreInteractive (returns { child, done } and enforces the
15_000ms timeout, env keys, cwd and stdio) so all tests share identical
environment and timeout semantics.

144-152: Confirm-prompt flip via ArrowLeft is brittle to clack default changes.

The script depends on select-restore.ts:120 having initialValue: false. If that ever flips to true, ArrowLeft will move No→Yes→ but the prompt is already on Yes and this becomes No, silently. Consider asserting on a stdout marker between steps (e.g., the prompt label) or pressing y if the prompt accepts shortcut keys, so the test fails loudly rather than restoring nothing.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/ccaudit/src/__tests__/restore-interactive-roundtrip.test.ts` around
lines 144 - 152, The test in restore-interactive-roundtrip.test.ts relies on
cursor movement (sendKeys(spawned.child, ['\x1b[D']) ) assuming
select-restore.ts sets initialValue: false, which makes the ArrowLeft brittle;
update the test to either assert a visible stdout marker for the confirmation
prompt (check the prompt label/string after awaiting the grace timeout) before
toggling, or send an explicit confirmation shortcut key (e.g., 'y' or 'n')
instead of ArrowLeft so the intent is clear and the test fails loudly if the
prompt state changes; reference the test's use of sendKeys(spawned.child, ...)
and the select-restore.ts initialValue behavior when making the change.
apps/ccaudit/src/__tests__/tabbed-picker-help-overlay.test.ts (1)

92-103: Optional: replace fixed setTimeout(500) with waitForMarker for symmetry with the second test.

Test 2 (lines 162–167) waits on (Press ? or Esc to close) to confirm the overlay is rendered before asserting; test 1 here uses a fixed 500 ms wait. If a slow CI runner takes longer than 500 ms to render the overlay, the expect(afterOpen).toContain('Selection') failure mode will be misleading (a diff against partial transcript) rather than a clear timeout. Using waitForMarker here keeps both tests symmetric and gives loud failures when the overlay never opens.

♻️ Suggested fix
-    await sendKeys(spawned.child, ['?']);
-    await new Promise((r) => setTimeout(r, 500));
-    const afterOpen = stripAnsi(stdoutBuf);
+    await sendKeys(spawned.child, ['?']);
+    await waitForMarker(
+      () => stripAnsi(stdoutBuf),
+      () => spawned.child.exitCode !== null,
+      '(Press ? or Esc to close)',
+    );
+    const afterOpen = stripAnsi(stdoutBuf);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/ccaudit/src/__tests__/tabbed-picker-help-overlay.test.ts` around lines
92 - 103, Replace the fixed await new Promise((r) => setTimeout(r, 500)) pause
with the same waitForMarker usage used in the second test: after
sendKeys(spawned.child, ['?']) call await waitForMarker(stdoutBuf, '(Press ? or
Esc to close)') so the test waits deterministically for the overlay to render
before calling stripAnsi(stdoutBuf) and asserting; update the test to
import/ensure waitForMarker is available and remove the hardcoded timeout to
make it symmetric with the second test.
apps/ccaudit/src/__tests__/safety-invariants-restore-roundtrip.test.ts (1)

165-170: Nit: assertion message reports unfiltered count.

manifests.length in the assertion message is the raw readdir length (which can include non-.jsonl artifacts), but the assertion itself runs against the filtered list. If the filter ever drops something, the failure message will be confusing.

♻️ Suggested fix
-      const manifests = await readdir(manifestsDir);
-      expect(
-        manifests.filter((m) => m.endsWith('.jsonl')),
-        `expected 2 manifests, got ${manifests.length}: ${manifests.join(', ')}`,
-      ).toHaveLength(2);
+      const allEntries = await readdir(manifestsDir);
+      const jsonlManifests = allEntries.filter((m) => m.endsWith('.jsonl'));
+      expect(
+        jsonlManifests,
+        `expected 2 jsonl manifests, got ${jsonlManifests.length}: ${jsonlManifests.join(', ')} (all entries: ${allEntries.join(', ')})`,
+      ).toHaveLength(2);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/ccaudit/src/__tests__/safety-invariants-restore-roundtrip.test.ts`
around lines 165 - 170, The assertion message uses the unfiltered
manifests.length which can include non-`.jsonl` files; instead compute and use
the filtered list (e.g., const jsonlManifests = manifests.filter(m =>
m.endsWith('.jsonl'))) and reference jsonlManifests.length and
jsonlManifests.join(', ') in the failure message and in the expect call (replace
the inline filter with the precomputed jsonlManifests) so the message matches
the actual value being asserted; update uses of manifests.filter(...) in this
test block accordingly (variables: manifestsDir, manifests, the filter
expression).
apps/ccaudit/src/__tests__/fixtures/manual-qa-followups.ts (1)

391-404: Optional: parallelize file writes in stagePaginationFixture.

For count=550, the sequential await writeFile + await utimes issues ~1100 serialized syscalls per fixture build. Setting up a 500-item picker test takes longer than necessary, which compounds across runs. Promise.all over an Array.from({ length: count }) keeps ordering immaterial here (filename suffix encodes order) and shaves a meaningful chunk off CI time on filesystems that support concurrent metadata ops.

♻️ Suggested fix
   await mkdir(path.join(tmpHome, '.claude', 'agents'), { recursive: true });
-  for (let i = 1; i <= count; i++) {
-    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 Promise.all(
+    Array.from({ length: count }, async (_v, idx) => {
+      const name = `agent-${String(idx + 1).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);
+    }),
+  );
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/ccaudit/src/__tests__/fixtures/manual-qa-followups.ts` around lines 391
- 404, stagePaginationFixture currently performs writeFile and utimes
sequentially for each agent, causing many serialized syscalls; change it to
build an array of tasks (e.g., using Array.from or a loop) that for each index
computes name and filePath and returns a Promise that writes the file and sets
utimes, then await Promise.all on that array so writeFile and utimes run
concurrently; keep the existing filename generation
(agent-${String(i).padStart(3,'0')}) and ensure writeRecentSession and the
mkdir/writeFile for .claude.json remain awaited after the parallel file ops so
test setup order remains correct.
apps/ccaudit/src/__tests__/tabbed-picker-tab-nav-keys.test.ts (1)

129-161: Inline marker polling silently times out — use shared waitForMarker instead.

Both polling blocks (lines 129–141 for the picker render, lines 149–161 for the confirmation prompt) fall through when the marker never appears. If the picker never renders or the confirmation prompt never shows, the test continues sending keys against a dead/blocked subprocess and downstream assertions surface a misleading "expected exactly 1 manifest, got 0" error rather than a clear "timed out waiting for Proceed with archive?" message.

The same helper module already exports waitForMarker (used by tabbed-picker-help-overlay.test.ts) which throws on timeout — using it here keeps the failure mode loud and consistent across the suite.

♻️ Suggested fix
 import {
   makeTmpHome,
   cleanupTmpHome,
   buildFakePs,
   runCcauditGhost,
   listManifestsDir,
   sendKeys,
+  waitForMarker,
 } from './_test-helpers.ts';
@@
-  // 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);
-    }
-  }
+  await waitForMarker(
+    () => stdoutBuf,
+    () => spawned.child.exitCode !== null,
+    'AGENTS (0/2)',
+  );
@@
-  {
-    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);
-    }
-  }
+  await waitForMarker(
+    () => stdoutBuf,
+    () => spawned.child.exitCode !== null,
+    'Proceed with archive?',
+  );
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/ccaudit/src/__tests__/tabbed-picker-tab-nav-keys.test.ts` around lines
129 - 161, Replace the two manual polling loops that poll stdoutBuf for 'AGENTS
(0/2)' and 'Proceed with archive?' with the shared test helper waitForMarker to
ensure timeouts throw; specifically, remove the while blocks that reference
spawned.child, stdoutBuf, startMs, delayMs and instead call
waitForMarker(spawned, 'AGENTS (0/2)', {timeout: 5000}) before sending keys and
waitForMarker(spawned, 'Proceed with archive?', {timeout: 5000}) after sendKeys
(or adjust arguments to the existing waitForMarker signature), keeping
references to spawned and stdoutBuf only as needed by waitForMarker so failures
surface a clear timeout error.
README.md (1)

454-459: Optional: clarify purge-archive --json defaults to dry-run.

The example block lists purge-archive --json with the comment "structured envelope, combinable with --yes", but a reader scanning the block sees --json without --yes and may not realize it inherits the safe-default dry-run behavior. A one-word clarification keeps the contract obvious for someone who pastes the line into CI.

📝 Suggested wording
-ccaudit purge-archive --json     # structured envelope, combinable with --yes
+ccaudit purge-archive --json           # dry-run + structured envelope (add --yes to execute)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@README.md` around lines 454 - 459, Update the README example to clarify that
the "ccaudit purge-archive --json" invocation defaults to dry-run by explicitly
noting it (e.g., "structured envelope, dry-run by default; combinable with
--yes"); target the line showing "ccaudit purge-archive --json" and add the
brief parenthetical or phrase that it inherits the safe default (--dry-run) so
readers know they must add "--yes" to execute changes.
packages/terminal/src/tui/_help-overlay.ts (1)

113-117: Truncation can split surrogate pairs.

truncate() uses String.prototype.slice on UTF-16 code units. The Unicode glyph legend includes 🔒 (U+1F512), which is a surrogate pair (length 2 in JS). For narrow cols values where the cut falls between the high and low surrogate of 🔒 (or any future astral character added to bindings), the returned line will contain a lone surrogate and render as a replacement character. The in-source tests only assert line.length <= cols, not validity, so they don't catch this.

This is low-risk in practice (the lock glyph sits at column ~3, well inside typical viewports and the compact-mode test runs at cols=15 where the lock line happens not to be cut mid-pair), but a defensive splitter avoids future regressions when bindings change.

♻️ Suggested fix
 function truncate(line: string, cols: number): string {
   if (cols <= 0) return '';
-  if (line.length <= cols) return line;
-  return line.slice(0, cols);
+  if (line.length <= cols) return line;
+  let cut = line.slice(0, cols);
+  // Avoid leaving a lone high surrogate at the cut boundary.
+  const lastCode = cut.charCodeAt(cut.length - 1);
+  if (lastCode >= 0xd800 && lastCode <= 0xdbff) {
+    cut = cut.slice(0, -1);
+  }
+  return cut;
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/terminal/src/tui/_help-overlay.ts` around lines 113 - 117, The
truncate function can split UTF-16 surrogate pairs because it slices by code
units; update truncate to operate on Unicode code points instead of raw string
indices: when cols > 0, convert the string into an array of characters using a
codepoint-aware method (e.g., Array.from or the spread operator) and return the
first cols code points joined back into a string, preserving the existing
early-return behavior for cols <= 0 and the check for short lines; keep the
function name truncate and ensure the return type remains string.
apps/ccaudit/src/__tests__/pagination-500.test.ts (1)

63-88: Manually-mirrored handler logic can drift from the implementation.

This test mutates picker.filterMode, tab.cursor, and tab.savedCursorPreFilter directly, then re-implements the Esc handler's restore-and-clamp logic inline (lines 80–85). If the real ? / Esc handler ever changes its restore semantics (clamp policy, savedCursorPreFilter semantics, or the call into applyScroll), this test will keep passing while the actual picker regresses.

The test header comment is honest about the trade-off, but consider invoking the picker's filter-on/off methods directly so the assertion exercises shipped code rather than a hand-rolled mirror. The next test (applyScroll round-trips) already covers the pure reducer; this one would be more valuable hitting the integration boundary.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/ccaudit/src/__tests__/pagination-500.test.ts` around lines 63 - 88, The
test currently mutates picker.filterMode, tab.cursor, and
tab.savedCursorPreFilter and re-implements the Esc restore/clamp logic; replace
those direct mutations with calls into the picker's real filter-on/off handlers
so the test exercises shipped code (i.e., invoke the picker method that
activates filter mode and the method/handler that exits filter mode / simulates
Esc so savedCursorPreFilter, applyScroll and the clamp semantics run in
production code rather than being mirrored in the test). Locate references to
filterMode, savedCursorPreFilter, tab.cursor and applyScroll in the picker
implementation and call the corresponding enter-filter and exit-filter/Esc
handler functions instead of manipulating fields directly.
packages/terminal/src/tui/_filter-sort.ts (1)

118-125: Optional: future-dated mtimeMs ranks as least-stale.

When item.mtimeMs > now (e.g. clock skew, or files touched on a system with a fast clock), bAge - aAge becomes negative and the item floats to the bottom of staleness-desc rather than being clamped to "most stale" or "freshest". Probably never visible in practice, but easy to defend if you want strictly monotonic staleness:

♻️ Optional clamp
     case 'staleness-desc':
       copy.sort((a, b) => {
-        const aAge = now - (a.item.mtimeMs ?? 0);
-        const bAge = now - (b.item.mtimeMs ?? 0);
+        const aAge = Math.max(0, now - (a.item.mtimeMs ?? 0));
+        const bAge = Math.max(0, now - (b.item.mtimeMs ?? 0));
         return bAge - aAge;
       });
       return copy;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/terminal/src/tui/_filter-sort.ts` around lines 118 - 125, The
staleness calculation in the 'staleness-desc' branch can produce negative ages
for future-dated mtimeMs (variables aAge, bAge inside the case in file
_filter-sort.ts), causing those items to sort to the bottom; clamp ages to a
non-negative value before subtracting (e.g., compute age = Math.max(0, now -
(item.mtimeMs ?? 0))) so future timestamps are treated as zero age and sort
correctly, then use those clamped aAge/bAge values in the copy.sort comparator.
apps/ccaudit/src/__tests__/tabbed-picker-filter.test.ts (1)

161-169: Optional: replace fixed-duration setTimeout sleeps with waitForMarker where possible.

The interactive flow already uses waitForMarker for known render landmarks, but several state transitions still rely on fixed sleeps (200/300/400/500 ms) before slicing stdoutBuf. Under loaded CI runners these can flake or, worse, pass for the wrong reason if the render lands a hair late. Where there's a deterministic post-keystroke marker (e.g. 0 of 6 selected across all tabs after Esc-clear, or Filtered: 2 of 3 visible | 1 selected after Space), prefer waitForMarker(...); keep the bare sleep only when asserting the absence of an event (e.g. "Enter must NOT submit").

Also applies to: 217-228, 262-279, 329-338

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/ccaudit/src/__tests__/tabbed-picker-filter.test.ts` around lines 161 -
169, The test uses fixed-duration sleeps (e.g., new Promise(r => setTimeout(r,
400))) after sendKeys to wait for renders, which flakes; replace those sleeps
with calls to the existing waitForMarker helper to wait for deterministic output
landmarks (for example wait for the "0 of 6 selected across all tabs" marker
before slicing stdoutBuf) in the test case around sendKeys/spawned.child and
stripAnsi usage (and similarly update the other occurrences noted at lines
~217-228, ~262-279, ~329-338) so the test slices stdoutBuf only after
waitForMarker resolves; keep a short sleep only where you are explicitly
asserting the absence of a marker.
apps/ccaudit/src/__tests__/restore-list-skips-purge.test.ts (1)

71-71: Use a real ISO 8601 timestamp for checkpoint_timestamp in the fixture.

'2026-04-20T10-00-00-000Z' swaps the time-component colons for dashes — that's the filename-safe slug, not a valid ISO 8601 string, and it diverges from what real bust manifests write (they use new Date().toISOString()). If a future schema tightening starts parsing this field, the fixture will silently fall through; better to mirror production output now.

🔧 Suggested fix
-      checkpoint_timestamp: '2026-04-20T10-00-00-000Z',
+      checkpoint_timestamp: bustMtime.toISOString(),

As per coding guidelines: "Use ISO 8601 UTC strings (new Date().toISOString()) for time values".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/ccaudit/src/__tests__/restore-list-skips-purge.test.ts` at line 71, The
fixture uses a filename-safe timestamp string for checkpoint_timestamp
('2026-04-20T10-00-00-000Z') which is not valid ISO 8601; update the fixture so
checkpoint_timestamp is a real ISO 8601 UTC string (e.g.,
'2026-04-20T10:00:00.000Z') or generate it via new Date(...).toISOString() so
the test mirrors production manifest output.
apps/ccaudit/src/__tests__/restore-json-envelope.test.ts (1)

1-14: Docblock paths drift from the actual assertions.

The header reads top-level command === 'restore' and data.status === 'success', but the test asserts parsed.meta.command, parsed.meta.exitCode, and parsed.status (no data wrapper). Future readers will trust the comment; please align it with the envelope shape now in use.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/ccaudit/src/__tests__/restore-json-envelope.test.ts` around lines 1 -
14, Update the docblock to match the actual envelope fields asserted in the
test: mention that the command and exit code live under parsed.meta
(parsed.meta.command and parsed.meta.exitCode), that parsed.status (not
data.status) should equal 'success', that parsed.selection_filter
(parsed.selection_filter.mode === 'subset' and parsed.selection_filter.ids has
exactly 2 entries) is used, and that parsed.skipped is present and is an array.
Ensure the header text references parsed.meta.command, parsed.meta.exitCode,
parsed.status, parsed.selection_filter, and parsed.skipped so it matches the
assertions in the test.
packages/internal/src/remediation/manifest.ts (2)

238-247: Update JSDoc and stale test names to reflect purge-*.jsonl inclusion.

The function-level JSDoc still says "Discover all bust-*.jsonl manifest files" and "Filters to only files matching the bust-*.jsonl pattern (T-09-01)", but the implementation on lines 259‑261 now also returns purge-*.jsonl. The existing test descriptions on line 1047 ('returns only bust-*.jsonl entries…') and line 1093 ('filters entries not matching bust-*.jsonl pattern') are similarly stale — they still pass because their inputs only contain bust entries, but they no longer describe the actual contract (which the new test on lines 1239‑1260 does cover correctly).

📝 Suggested doc tweak
 /**
- * Discover all bust-*.jsonl manifest files in the manifests directory,
- * sorted newest-first by mtime.
+ * Discover all bust-*.jsonl and purge-*.jsonl manifest files in the
+ * manifests directory, sorted newest-first by mtime.
  *
  * Returns [] when the directory doesn't exist (no bust history).
- * Filters to only files matching the `bust-*.jsonl` pattern (T-09-01).
+ * Filters to files matching `bust-*.jsonl` or `purge-*.jsonl` (T-09-01,
+ * Phase 9 SC6 — purge follow-ups must be visible to restore dedup).
  *
  * `@param` deps Injectable readdir + stat (inject fakes in tests; production
  *             passes `readdir: (d) => fs.readdir(d), stat: (p) => fs.stat(p)`).
  */
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/internal/src/remediation/manifest.ts` around lines 238 - 247, Update
the function-level JSDoc that currently says "Discover all bust-*.jsonl manifest
files" and "Filters to only files matching the `bust-*.jsonl` pattern" to
reflect that the implementation returns both bust-*.jsonl and purge-*.jsonl
manifests (mention both patterns and that results are sorted newest-first by
mtime); also update the stale test description strings ('returns only
bust-*.jsonl entries…' and 'filters entries not matching bust-*.jsonl pattern')
to mention purge-*.jsonl so the documentation and test names match the behavior
of the manifest discovery function.

200-205: Use crypto for the disambiguator suffix to remove a rare-but-real truncation case.

Math.random().toString(36).slice(2, 6) is not guaranteed to yield 4 characters. For "round" values whose base-36 representation is short, the slice can return fewer chars (e.g. (0.5).toString(36) === '0.i''i', and (0).toString(36) === '0'''). Probability is ~46655/2^52 per call — vanishingly small in practice, but it would produce filenames like bust-…-.jsonl that fail the test regex [a-z0-9]{4} on line 671 and weaken the same-millisecond collision-avoidance contract documented on lines 187‑188.

Since node:crypto is already imported (createHash, randomUUID), prefer a deterministic fixed-width source. The same applies to resolvePurgeManifestPath (line 449).

♻️ Proposed change
-import { createHash, randomUUID } from 'node:crypto';
+import { createHash, randomUUID, randomBytes } from 'node:crypto';
@@
 export function resolveManifestPath(now: Date = new Date()): string {
   // 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);
+  // 2 random bytes → 4 hex chars (16^4 = 65,536), guaranteed fixed width.
+  const rand = randomBytes(2).toString('hex');
   return path.join(homedir(), '.claude', 'ccaudit', 'manifests', `bust-${stamp}-${rand}.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);
+  const rand = randomBytes(2).toString('hex');
   return path.join(homedir(), '.claude', 'ccaudit', 'manifests', `purge-${stamp}-${rand}.jsonl`);
 }

If you keep this change, also relax the test regexes at lines 671 and 1191 to [a-f0-9]{4} (or [a-z0-9]{4} works as a strict superset).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/internal/src/remediation/manifest.ts` around lines 200 - 205, The
disambiguator uses Math.random().toString(36).slice(2,6) which can produce fewer
than 4 chars; replace it with a fixed-width crypto-derived hex string (e.g., use
node:crypto to derive 2 bytes and hex-encode to 4 chars) in resolveManifestPath
and the analogous resolvePurgeManifestPath so the suffix is always 4 hex chars;
update the implementation to call the crypto routine (or
createHash(randomUUID()) truncated) to generate a deterministic 4-character
[a-f0-9]{4} disambiguator and keep the existing stamp and path construction
unchanged.

Comment thread apps/ccaudit/scripts/bundle-size-check.mjs
Comment thread apps/ccaudit/src/__tests__/restore-corrupt-manifest.test.ts
Comment thread apps/ccaudit/src/__tests__/tabbed-picker-bust-parity.test.ts
Comment thread apps/ccaudit/src/cli/commands/ghost.ts Outdated
Comment thread apps/ccaudit/src/cli/commands/purge-archive.ts
Comment thread packages/internal/src/remediation/bust.ts
Comment thread packages/internal/src/remediation/purge.ts Outdated
Comment thread packages/internal/src/remediation/reclaim.ts
Comment thread packages/internal/src/remediation/restore.ts
Comment thread packages/terminal/src/tui/_force-partial-banner.ts
@fabio-dee
fabio-dee force-pushed the release/v1.5.0 branch 5 times, most recently from 61cdc6c to 95fe0c1 Compare April 27, 2026 05:34

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 12

🧹 Nitpick comments (21)
eslint.config.ts (1)

10-15: Consider including scripts in a tsconfig instead of bumping the default-project cap.

The option name itself (..._THIS_WILL_SLOW_DOWN_LINTING) is a deliberate signal from typescript-eslint that this path is a performance escape hatch. Comment notes "currently 5+" but you're raising the cap to 20, which both slows linting and just defers the problem. A more durable fix is to add the .mjs script directories to a dedicated tsconfig.scripts.json (or include them in an existing tsconfig) referenced via projectService / project, and drop both allowDefaultProject and the maximumDefaultProjectFileMatchCount_* knob.

Non-blocking — the current change is fine as a stopgap.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@eslint.config.ts` around lines 10 - 15, The current change raises
projectService.maximumDefaultProjectFileMatchCount_THIS_WILL_SLOW_DOWN_LINTING
to include more .mjs files; instead add those script paths to a proper tsconfig
and point ESLint at it: create or update a tsconfig (e.g. tsconfig.scripts.json)
that includes apps/ccaudit/scripts/*.mjs and scripts/*.mjs, then modify the
ESLint config to reference that project via projectService.project (or project)
and remove the allowDefaultProject and
maximumDefaultProjectFileMatchCount_THIS_WILL_SLOW_DOWN_LINTING entries; update
any comments to reflect the new tsconfig usage.
apps/ccaudit/src/__tests__/tabbed-picker-tab-nav-keys.test.ts (2)

213-240: Optional: parameterize the two near-identical cases with it.each.

The Tab and ArrowRight tests are byte-for-byte identical apart from the key sequence and the title. Collapsing them into an it.each table would remove duplication and make it trivially extensible (e.g., for the symmetric Shift-Tab / ArrowLeft pair mentioned in the file header).

♻️ Proposed refactor
-    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);
+    it.each([
+      { label: "Tab ('\\t')", key: '\t' },
+      { label: "ArrowRight ('\\x1b[C')", key: '\x1b[C' },
+    ])(
+      '$label cycles tabs forward and produces a manifest with exactly 2 planned ops',
+      async ({ key }) => {
+        const { exitCode, manifest, stderr } = await runPickerWithTabKey(tmpHome, key);
+
+        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,
+    );
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/ccaudit/src/__tests__/tabbed-picker-tab-nav-keys.test.ts` around lines
213 - 240, Two tests ("Tab key ('\\t') cycles tabs forward..." and "ArrowRight
key ('\\x1b[C') cycles tabs forward...") are duplicated; replace them with a
parameterized it.each that iterates over cases with a descriptive title and the
key sequence and runs the same assertions. Locate the tests that call
runPickerWithTabKey(tmpHome, <key>) and consolidate into a single it.each table
(e.g., rows for ['Tab key (\\t)', '\t'] and ['ArrowRight key (\\x1b[C)',
'\x1b[C']) and keep the existing assertions against exitCode,
manifest.header?.planned_ops.archive, manifest.ops.length, and archiveOps
categories unchanged. Ensure the test timeout (25_000) is preserved and the test
body still destructures { exitCode, manifest, stderr } from runPickerWithTabKey.

129-161: Optional: extract the duplicated stdout-poll into a small helper.

The two while loops on Lines 129–141 and 149–161 are structurally identical (exponential-backoff poll for a stdout substring while subprocess is alive, capped at 5 s). Extracting a waitForStdout(spawned.child, () => stdoutBuf, marker, maxWaitMs) helper — or better, promoting it to _test-helpers.ts — would simplify this file and benefit other TUI integration tests that follow the same pattern.

Not blocking; current code is correct.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/ccaudit/src/__tests__/tabbed-picker-tab-nav-keys.test.ts` around lines
129 - 161, Extract the duplicated exponential-backoff stdout polling into a
reusable helper (e.g., waitForStdout) and replace both while loops with calls to
it: implement waitForStdout(spawned.child, () => stdoutBuf, marker, maxWaitMs)
that polls until the marker substring appears or the child exits or timeout
elapses; put the helper in a shared test helper module (e.g., _test-helpers.ts)
and update the two call sites in tabbed-picker-tab-nav-keys.test.ts to call
waitForStdout(spawned.child, () => stdoutBuf, 'AGENTS (0/2)', 5000) and
waitForStdout(spawned.child, () => stdoutBuf, 'Proceed with archive?', 5000)
respectively.
apps/ccaudit/scripts/bundle-smoke-test.mjs (1)

4-9: Optional: use the ESM-idiomatic URL resolution.

The resolve(__filename, '../../dist/index.js') trick works (the trailing filename is consumed by the first ..), but it’s easy to misread. The standard ESM form is one line and avoids fileURLToPath round-tripping just to feed pathToFileURL later.

♻️ Proposed refactor
-import { fileURLToPath, pathToFileURL } from 'node:url';
-import { resolve } from 'node:path';
+import { fileURLToPath } from 'node:url';
 import { existsSync } from 'node:fs';
 
-const __filename = fileURLToPath(import.meta.url);
-const distPath = resolve(__filename, '../../dist/index.js');
+const distUrl = new URL('../dist/index.js', import.meta.url);
+const distPath = fileURLToPath(distUrl);
@@
-  await import(pathToFileURL(distPath).href);
+  await import(distUrl.href);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/ccaudit/scripts/bundle-smoke-test.mjs` around lines 4 - 9, Replace the
round-trip path resolution using fileURLToPath/__filename and resolve with the
ESM URL form: create the dist file URL via new URL('../../dist/index.js',
import.meta.url) (remove the __filename variable and the fileURLToPath import)
and use that URL (or convert to a filesystem path only where needed, e.g. with
fileURLToPath(new URL(...)) at the point of calling existsSync). Update
references to distPath to use the new URL variable (or its file path) and remove
the unused imports (fileURLToPath and resolve) so the script uses canonical ESM
URL resolution.
apps/ccaudit/src/__tests__/fixtures/tmux-e2e.ts (2)

176-178: Generated runner script is never cleaned up.

runnerPath is written into opts.tmpDir and chmod'd, but nothing in TmuxE2ESession removes it on kill() or on session exit. In practice callers usually nuke tmpDir wholesale, so this is non-blocking — but if a test suite re-uses tmpDir across cases or skips teardown on early failures, stale *.tmux-runner.sh files accumulate (and could shadow each other if opts.name collides). Consider unlinking the script in kill() or returning the path so callers can manage it explicitly.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/ccaudit/src/__tests__/fixtures/tmux-e2e.ts` around lines 176 - 178, The
generated runner script (runnerPath) written by buildRunnerScript is not removed
and should be cleaned up: store the runnerPath on the TmuxE2ESession instance
when you write it, then unlink it in TmuxE2ESession.kill() (and also on normal
session exit) so the file is removed even on early failures; alternatively
modify the API that creates the script to return the script path to callers so
they can manage deletion. Ensure you reference the runnerPath property in the
cleanup logic and handle ENOENT errors safely when unlinking.

142-146: Redundant ANSI strip in waitForText.

capture() already strips ANSI when called with ansi: false (line 116). With the default stripAnsi !== false, raw is already stripped, and lastCapture = stripAnsi(raw) strips it a second time. Idempotent and harmless, but it’s extra regex work on every poll iteration and the double-conditional logic obscures what the line is doing. You can just trust capture():

♻️ Proposed simplification
       const raw = await this.capture({
         startLine: opts.startLine ?? -200,
         ansi: opts.stripAnsi === false,
       });
-      lastCapture = opts.stripAnsi === false ? raw : stripAnsi(raw);
+      lastCapture = raw;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/ccaudit/src/__tests__/fixtures/tmux-e2e.ts` around lines 142 - 146, The
code in waitForText redundantly calls stripAnsi on raw even though capture(...)
already respects opts.stripAnsi via its ansi flag; replace the conditional
assignment "lastCapture = opts.stripAnsi === false ? raw : stripAnsi(raw)" with
a single unconditional "lastCapture = raw" (leave the capture(...) call and its
ansi: opts.stripAnsi === false argument intact) so lastCapture trusts capture()
to include/strip ANSI and removes the extra regex work; update references to
lastCapture accordingly.
packages/terminal/src/tables/change-plan.ts (1)

73-91: Optional: column alignment regression for the new commands row.

agents / skills are 6-char labels followed by , so all arrows align. commands is 8 chars with the same trailing two spaces, which shifts its two columns right relative to the agents/skills rows. Pure cosmetic — feel free to ignore — but if you want consistent arrows, pad the labels to a common width (e.g. 8) so lines up across all three rows.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/terminal/src/tables/change-plan.ts` around lines 73 - 91, The three
archive rows in the change-plan display (the lines.push template strings that
use plan.counts.agents / plan.counts.skills / plan.counts.commands) produce
misaligned arrows because "commands" is longer; normalize the label column width
by padding the label text to a common width (e.g., use label.padEnd(8) or format
the string so "agents", "skills", and "commands" occupy the same character
width) in each of the three template strings so the "→
~/.claude/ccaudit/archived/..." arrows align vertically.
apps/ccaudit/src/__tests__/restore-list-skips-purge.test.ts (1)

110-130: Purge-manifest header here is sparser than in restore-after-purge.test.ts — pick one.

The sister test at apps/ccaudit/src/__tests__/restore-after-purge.test.ts (lines 130-141) writes a full purge header (checkpoint_ghost_hash, checkpoint_timestamp, since_window, os, node_version, planned_ops, selection_filter). Here the header contains only 4 fields. If the manifest reader is currently lenient about purge headers both pass, but if validation tightens later one of the two tests will silently start covering nothing. Worth aligning these fixtures (probably toward the fuller shape, or factoring a makePurgeManifest() helper into _test-helpers.ts) so test intent doesn't drift.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/ccaudit/src/__tests__/restore-list-skips-purge.test.ts` around lines 110
- 130, The purge manifest header in this test is missing fields present in
restore-after-purge.test.ts; update the purgeHeader object (the variable named
purgeHeader used to build purgeBody and written to purgePath) to include the
fuller set of keys used in the sister test (e.g., checkpoint_ghost_hash,
checkpoint_timestamp, since_window, os, node_version, planned_ops,
selection_filter and any other fields there) so both fixtures match, or
alternatively add a shared makePurgeManifest()/makePurgeHeader() helper in
_test-helpers.ts and use it here to construct the header before stringifying and
writing purgeBody.
CHANGELOG.md (1)

8-8: Minor: inconsistent dash style on version headers.

Line 87 uses an em-dash (## [1.5.0-beta.0] — 2026-04-19) while line 8 and all preceding releases use a plain hyphen (## [1.5.0] - 2026-04-26, ## [1.4.0] - 2026-04-13, etc.). Some changelog parsers/anchors are punctuation-sensitive — worth normalizing on the ASCII hyphen for consistency.

Optional follow-up: consider adding an empty ## [Unreleased] section above line 8 once 1.5.0 ships, per Keep a Changelog convention.

Also applies to: 87-87

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@CHANGELOG.md` at line 8, Normalize the dash style in the changelog headers:
replace the em-dash in the header containing "## [1.5.0-beta.0] — 2026-04-19"
with an ASCII hyphen to match the other headers like "## [1.5.0] - 2026-04-26"
and ensure all release headers use " - " consistently; optionally add an empty
"## [Unreleased]" section above the "## [1.5.0] - 2026-04-26" header to follow
Keep a Changelog convention.
apps/ccaudit/src/__tests__/pagination-500.test.ts (1)

20-21: Consider package-name imports over deep relative paths.

../../../../packages/terminal/src/tui/tabbed-picker.ts, ./fixtures/ghost-500-items.ts, and the dynamic import('../../../../packages/terminal/src/tui/_viewport.ts') reach into internal source paths. If the @ccaudit/terminal barrel exposes TabbedGhostPicker and applyScroll, prefer those imports — they survive directory restructures and avoid coupling integration tests to package internals. Also, the dynamic await import(...) at line 96 can be hoisted to a static top-level import unless test-isolation requires the lazy form.

Also applies to: 96-96

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/ccaudit/src/__tests__/pagination-500.test.ts` around lines 20 - 21,
Tests import internal source via deep relative paths (TabbedGhostPicker,
buildGhosts500 and a dynamic import of _viewport), which couples tests to
package internals; change imports to use the package barrel exports (e.g. import
{ TabbedGhostPicker, applyScroll } from '@ccaudit/terminal') and move fixture
import to a package-level or test fixtures path (replace buildGhosts500 relative
import accordingly). If test isolation is not required, replace the dynamic
await import('../../../../packages/terminal/src/tui/_viewport.ts') with a static
top-level import of applyScroll (or the exported symbol) from the package;
otherwise keep it but import the exported symbol from the package barrel instead
of the internal path.
apps/ccaudit/src/cli/commands/purge-archive.ts (1)

134-250: Surface manifestErrors in failure envelopes as well, not only on success.

manifestErrors is collected by the per-readManifest try/catch (line 145–156) but is only included in the success envelope at line 264. If discoverManifests or classifyArchiveOps then fails (lines 167–201) — or executePurge returns Result.isFailure (lines 207–250) — the operator's JSON only sees the top-level error message; any preceding unreadable-manifest entries are silently lost. Same applies for the per-class history record on those paths.

♻️ Suggested fix
       if (outMode.json) {
         process.stdout.write(
           JSON.stringify(
             buildJsonEnvelope('purge-archive', 'n/a', 1, {
               purge: {
                 summary: { ... },
-                failures: [{ path: '', reason: message }],
+                failures: [
+                  { path: '', reason: message },
+                  ...manifestErrors,
+                ],
                 dryRun,
               },
             }),
           ) + '\n',
         );
       }

(Apply analogously to the Result.isFailure envelope at lines 215–224.)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/ccaudit/src/cli/commands/purge-archive.ts` around lines 134 - 250, The
code collects unreadable-manifest entries in manifestErrors inside the
readManifest loop but only includes them in the success envelope; fix by
appending manifestErrors (mapped to {path,reason} or their reason strings) into
the JSON failure envelopes and the safeRecordHistory errors/result for both the
catch branch that handles discoverManifests/classifyArchiveOps failures and the
Result.isFailure branch after executePurge; update the objects built for
buildJsonEnvelope('purge-archive', ...) and the errors/result arrays passed to
safeRecordHistory so they include manifestErrors (e.g., include manifestErrors
as additional failures in the purge.failures array and merge their reasons into
the history.errors array) while preserving the existing top-level message.
apps/ccaudit/src/__tests__/tabbed-picker-filter.test.ts (1)

93-102: Consider adding TZ: 'UTC' to baseEnv for parity with sibling integration tests.

restore-interactive-roundtrip.test.ts (and the broader CI norm in this PR) sets TZ='UTC' on the spawned subprocess. The fixture here writes new Date(...).toISOString() so the test isn't actively timezone-sensitive today, but pinning TZ keeps any future timestamp/marker assertion reproducible across local dev environments.

♻️ Optional fix
 function baseEnv(extra: Record<string, string> = {}): Record<string, string> {
   return {
     CCAUDIT_FORCE_TTY: '1',
     CCAUDIT_TEST_STDOUT_ROWS: '30',
     LINES: '30',
     COLUMNS: '100',
     NO_COLOR: '1',
+    TZ: 'UTC',
     ...extra,
   };
 }

As per coding guidelines: Use ISO 8601 UTC strings for time values.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/ccaudit/src/__tests__/tabbed-picker-filter.test.ts` around lines 93 -
102, The test environment helper baseEnv is missing a TZ override which other
integration tests set to ensure reproducible timestamps; update the baseEnv
function to include TZ: 'UTC' in its returned object (i.e., add the TZ key
alongside CCAUDIT_FORCE_TTY, LINES, COLUMNS, NO_COLOR, etc.) so spawned
subprocesses use UTC and timestamp-related fixtures remain deterministic across
environments.
apps/ccaudit/src/__tests__/ghost-select-ids.test.ts (1)

39-63: Duplicated FAKE_PS_SCRIPT / buildFakePs — other tests import these from _test-helpers.ts.

apps/ccaudit/src/__tests__/preflight-retry.test.ts (line 33) and apps/ccaudit/src/__tests__/safety-invariants-framework.test.ts (line 34) already import buildFakePs from ./_test-helpers.ts. Re-defining it inline here drifts in two ways: (1) bug fixes / behavior changes to the helper won't reach this suite, and (2) the agentItemId helper in this file (lines 165–174) is a re-implementation of the version exported from _test-helpers.ts.

♻️ Suggested cleanup
-import { readManifest, canonicalItemId } from '@ccaudit/internal';
-import type { InventoryItem } from '@ccaudit/internal';
+import { readManifest } from '@ccaudit/internal';
+import { buildFakePs, agentItemId } from './_test-helpers.ts';

-const FAKE_PS_SCRIPT = `#!/bin/sh
-...
-`;
-
-async function buildFakePs(tmpHome: string): Promise<string> { ... }

-function agentItemId(tmpHome: string, fileName: string): string { ... }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/ccaudit/src/__tests__/ghost-select-ids.test.ts` around lines 39 - 63,
This test file duplicates FAKE_PS_SCRIPT, buildFakePs and agentItemId that
already live in _test-helpers.ts; remove the local FAKE_PS_SCRIPT and
buildFakePs definitions and the re-implemented agentItemId, and instead import
buildFakePs and agentItemId from ./_test-helpers.ts and use those imports
throughout the test (referencing the symbols buildFakePs and agentItemId to
locate where to replace the local code).
apps/ccaudit/src/__tests__/tabbed-picker-live-counter.test.ts (1)

51-58: ANSI stripper covers common CSI but not OSC/DCS sequences — fine for picker output, fragile if rendering grows.

The current regex set handles CSI cursor/SGR/erase codes, which is sufficient for the @clack/prompts and ink-style output the picker emits today. If future picker work introduces OSC (\x1b]...\x1b\\ for hyperlinks/title sets) or DCS sequences, this stripper would leak them into the assertion buffer and produce confusing failures. Not a blocker — flagging for awareness if the picker adds richer terminal features.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/ccaudit/src/__tests__/tabbed-picker-live-counter.test.ts` around lines
51 - 58, The stripAnsi function currently only removes CSI sequences and cursor
show/hide codes and may leave OSC (`\x1b]... \x1b\\`) and DCS sequences; update
the stripAnsi function to also remove OSC and DCS sequences by adding regexes
that strip OSC patterns (`\x1b\][^\x1b]*(\x1b\\|\x07)`) and DCS patterns
(`\x1bP[^\x1b]*\x1b\\`) in addition to the existing CSI rules so assertions
won’t leak richer terminal escapes if the picker output grows.
apps/ccaudit/src/cli/commands/restore.ts (1)

286-330: Duplicated manifest-walk pre-dispatch — extract a helper.

The blocks for --name (288–310), --all-matching (318–330), and --interactive (337–348) each rebuild pairs via the same incantation:

const entries = await findManifestsForRestore(deps);
const pairs = await Promise.all(
  entries.map(async (entry) => ({ entry, ops: (await readManifest(entry.path)).ops })),
);

Three near-identical IO passes that each re-read every manifest. Lifting this into a small helper (e.g., loadManifestPairs(deps)) would deduplicate and also let you cache the result if a future invocation combines flags or uses the same data twice.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/ccaudit/src/cli/commands/restore.ts` around lines 286 - 330, The restore
command duplicates manifest-reading logic in the nameFlag, allMatchingFlag and
interactive branches by repeatedly calling findManifestsForRestore and
readManifest to build pairs; extract this into a helper (e.g.,
loadManifestPairs(deps)) that performs the single IO pass: call
findManifestsForRestore(deps), map entries to { entry, ops: (await
readManifest(entry.path)).ops } with Promise.all, and return the pairs (or
deduped result if you prefer); then replace the repeated blocks in the nameFlag
and allMatchingFlag branches to reuse loadManifestPairs and feed its result into
dedupManifestOps/matchByName to produce nameResolvedId and
allMatchingResolvedIds.
packages/internal/src/remediation/bust.ts (1)

305-313: Minor: emitting console.warn from the domain layer.

The warning at lines 308–312 fires from inside runBust, which is otherwise a pure orchestrator returning a discriminated BustResult. Per the project's "catch at the outermost layer, propagate via Result inside the domain" convention, this informational message would arguably be cleaner as a non-error field on BustResult.success (or simply emitted by the CLI command after inspecting selectedItems). Not a blocker — the message is benign and idempotent — but it's the only stderr write inside the orchestrator.

Based on learnings: "Catch errors at the outermost layer (CLI command, history writer) and degrade gracefully; propagate errors inside the domain via Result type."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/internal/src/remediation/bust.ts` around lines 305 - 313, The
orchestrator runBust currently emits a console.warn when selectedItems is an
empty set; remove that stderr write and instead propagate the informational
message via the BustResult success value (e.g., add a warnings or infoMessage
field on BustResult.success) so callers can decide how to display it; locate the
selectedItems check in runBust (and the filteredPlan/filterChangePlan logic) and
when selectedItems.size === 0 populate the new warning field on the returned
success result rather than calling console.warn, or leave it unset and let the
CLI inspect selectedItems and emit the message.
packages/internal/src/remediation/manifest.ts (2)

447-512: Optional: dedupe purge-manifest path generation.

openPurgeManifestWriter reconstructs the filename inline at lines 501–503 rather than reusing resolvePurgeManifestPath. The two implementations will drift if the format ever changes (e.g., suffix length, prefix). Calling resolvePurgeManifestPath(new Date(ts)) and then path.dirname/basename for the override path keeps a single source of truth.

♻️ Suggested refactor
 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 defaultPath = resolvePurgeManifestPath(new Date(ts));
+  const manifestPath = manifestsDir
+    ? path.join(manifestsDir, path.basename(defaultPath))
+    : defaultPath;
   const writer = new ManifestWriter(manifestPath);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/internal/src/remediation/manifest.ts` around lines 447 - 512,
openPurgeManifestWriter duplicates the purge filename logic from
resolvePurgeManifestPath, risking drift; replace the inline stamp/rand
generation by calling resolvePurgeManifestPath(new Date(ts)) to get a canonical
filename, then if manifestsDir is provided use path.join(manifestsDir,
path.basename(resolvedPath)) otherwise use resolvedPath as manifestPath, and
proceed to construct the ManifestWriter and call
writer.open(buildPurgeManifestHeader(...)) as before (update references in
openPurgeManifestWriter to use resolvePurgeManifestPath, ManifestWriter,
buildPurgeManifestHeader).

560-604: Optional: collapse writePurgeManifest's dual-mode branches.

The two branches (lines 570–588 with explicit manifestPath, lines 590–604 via openPurgeManifestWriter) duplicate the open/write-loop/close/error-rewind logic. Once the path-resolution helper above is shared, these collapse to a single try block. Since the doc-comment already calls this a backward-compat wrapper, this is purely a nice-to-have.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/internal/src/remediation/manifest.ts` around lines 560 - 604,
writePurgeManifest duplicates the writer creation/open/write/close/error-rewind
logic in two branches; refactor to resolve writer and manifestPath before the
try/catch: if input.manifestPath create const writer = new
ManifestWriter(input.manifestPath) and await
writer.open(buildPurgeManifestHeader(...)) and set manifestPath =
input.manifestPath, otherwise call const { writer, path: manifestPath } = await
openPurgeManifestWriter(...); then have a single try block that loops await
writer.writeOp(op) for each op, calls await closePurgeManifestWriter(writer, {
durationMs: writer.elapsedMs }), and a single catch that does await
writer.close(null) and rethrows; reference symbols: writePurgeManifest,
ManifestWriter, openPurgeManifestWriter, closePurgeManifestWriter,
buildPurgeManifestHeader.
packages/internal/src/remediation/purge.ts (1)

268-300: Minor: comment drift on stale_archive_missing branch.

The comment at lines 270–273 says "we still write the follow-up op to suppress the stale entry from future restore listings," and indeed the unlink is correctly skipped for stale_archive_missing (line 274 only unlinks on source_occupied). This is the intended behavior. However, the surrounding try still records a generic failure on any throw at line 293–299, which would attribute filesystem failures to a stale op that didn't perform any I/O — only the writeOp could realistically throw here. Readability nit only; the logic is correct.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/internal/src/remediation/purge.ts` around lines 268 - 300, The outer
try currently wraps both the conditional unlink (deps.unlinkFile) and the
writer.writeOp, causing filesystem errors to be reported against ops that didn't
perform I/O (e.g., reason === 'stale_archive_missing'); split the logic so
unlink errors are handled separately from manifest write errors: perform
deps.unlinkFile inside its own try/catch (or a narrowly-scoped try) and push a
failure entry specific to unlink failures if it throws, then run
buildArchivePurgeOp and the existing writer.writeOp try/catch exactly as-is to
record manifest write failures; reference the loop over plan.drop, the
conditional check for reason === 'source_occupied', deps.unlinkFile,
buildArchivePurgeOp, writer.writeOp, and the failures/appendedOps/summary
updates when moving the catch scopes.
packages/internal/src/remediation/restore.ts (2)

1250-1267: Inconsistent corrupt-manifest handling in all-matching path.

Corrupt middle manifests are silently skipped here (if (m.header === null) continue;), but both executeInteractiveOps (Line 954) and the full path (Line 1201) emit a warning via deps.onWarning?. for the same condition. Worth aligning so users always see when a manifest is being skipped.

♻️ Proposed alignment
     for (const e of allEntries) {
       const m = await deps.readManifest(e.path);
-      if (m.header === null) 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 });
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/internal/src/remediation/restore.ts` around lines 1250 - 1267, In
the all-matching branch, when iterating allEntries and calling deps.readManifest
(inside the block that currently does "if (m.header === null) continue;"), emit
the same warning as other paths instead of silently skipping: call
deps.onWarning?.(...) with a message/metadata indicating a corrupt or unreadable
manifest (include the entry/path or canonical id) before continuing. This aligns
behavior with executeInteractiveOps and the full path and ensures users see
skipped corrupt manifests; update the loop that builds zipped (the readManifest
check) to perform the warning prior to continue.

349-366: Optional: parallelize pathExists checks.

filterRestoreableItems awaits each isStaleArchiveOp sequentially. For users with many archived items (e.g. a tab in the TUI containing dozens of archive ops across multiple manifests), this serializes the per-op pathExists × 2 calls and can be noticeable on slow filesystems. The list-mode loop (Lines 497-503) and full-restore loop (Lines 1207-1217) have the same shape.

A drop-in Promise.all over the items keeps the kept-order deterministic and the stale count exact:

♻️ Proposed parallelization (filterRestoreableItems)
-  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 };
+  const staleness = await Promise.all(
+    items.map(async (item) =>
+      item.op.op_type === 'archive' ? isStaleArchiveOp(item.op, pathExists) : false,
+    ),
+  );
+  const kept: Array<{ entry: ManifestEntry; op: RestoreableOp; canonical_id: string }> = [];
+  let filteredStaleCount = 0;
+  items.forEach((item, i) => {
+    if (staleness[i]) filteredStaleCount += 1;
+    else kept.push(item);
+  });
+  return { kept, filteredStaleCount };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/internal/src/remediation/restore.ts` around lines 349 - 366,
filterRestoreableItems currently awaits isStaleArchiveOp inside a for..of loop,
causing sequential pathExists calls; instead, map items to promises (call
isStaleArchiveOp only for items with op.op_type === 'archive', else resolve
false), await Promise.all to get stale flags, then iterate original items and
corresponding flags to build kept and filteredStaleCount (this preserves order
and exact counts). Apply the same Promise.all pattern to the other two loops
with the same shape (the list-mode loop and the full-restore loop) so their
per-item pathExists checks run in parallel while keeping deterministic ordering.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@apps/ccaudit/scripts/bundle-smoke-test.mjs`:
- Around line 17-25: The smoke test currently imports dist/index.js which
triggers top-level execution of run() from src/index.ts and can conflate CLI
help/missing-subcommand exits with real import-resolution failures; fix by
either (A) making src/index.ts only call await run() when executed as the main
module (guard the top-level invocation with import.meta.main or equivalent so
importing the module does not execute cli()), referencing the run() and cli(...)
calls in src/index.ts, or (B) change apps/ccaudit/scripts/bundle-smoke-test.mjs
to invoke the built CLI with a benign argument (e.g., "--help") when
importing/executing distPath and assert the process exit code to prove proper
initialization; pick one approach and implement the corresponding guard or
test-argument so the smoke test can reliably detect import-resolution failures.

In `@apps/ccaudit/src/__tests__/empty-inventory.test.ts`:
- Around line 88-96: The test is currently passing vacuously because the process
is not running in TTY mode; update the invocation of runCcauditGhost in the
'plain `ghost` on empty inventory' test to force TTY by injecting the
CCAUDIT_FORCE_TTY=1 env (or equivalent option supported by runCcauditGhost) so
the auto-open prompt code path is exercised; specifically modify the call to
runCcauditGhost(tmpHome, [], { timeout: 15_000 }) to include the environment
override (CCAUDIT_FORCE_TTY=1) so the assertions against stdout for the
interactive picker and prompt are meaningful.

In `@apps/ccaudit/src/__tests__/fixtures/tmux-e2e.ts`:
- Around line 212-222: The generated runner script silently continues if the cd
fails because the shebang block uses "set -uo pipefail" without "-e"; update the
script generation in the tmux-e2e fixture so the shell aborts on failing cd —
either add "-e" to the "set -uo pipefail" line or explicitly guard the cd (check
the directory using the same shellQuote(opts.cwd) value and exit with a clear
error and non-zero status if cd fails) so the test cannot continue in the wrong
working directory and the exit-status marker reflects the real failure.

In `@apps/ccaudit/src/__tests__/preflight-retry.test.ts`:
- Around line 167-186: Comment and test env disagree: the narrative describes
N=2 but the test sets CCAUDIT_TEST_PREFLIGHT_DIRTY='3'; update one to match the
other. Either change the explanatory text in the preflight accounting comment
(the paragraph describing "N=2" and the call-count math) to refer to N=3 and
adjust the described call counts accordingly, or set
CCAUDIT_TEST_PREFLIGHT_DIRTY to '2' in the runCcauditGhost invocation; locate
the test harness where runCcauditGhost is called and the
CCAUDIT_TEST_PREFLIGHT_DIRTY env entry to make the change so the comment and env
value are consistent.

In `@apps/ccaudit/src/__tests__/safety-invariants-framework.test.ts`:
- Around line 221-230: The test reassigns the shared tmpHome set up in
beforeEach, leaking the original temp directory and conflicting fixtures; fix by
either not reusing the shared beforeEach for this test or explicitly cleaning
the original tmpHome before reassigning it: locate the test that uses tmpHome
(the IIFE that calls buildFakePs and createMultiFrameworkFixture) and either
move it into its own describe with its own setup/teardown (so it doesn't rely on
beforeEach/afterEach), or call the same cleanup used in afterEach (rm(tmpHome, {
recursive: true }) or the project helper used for teardown) right before
creating the new temp dir so the original tmpHome is removed and no fixtures are
overlaid.

In `@apps/ccaudit/src/__tests__/scanner-multi-config-mcp.test.ts`:
- Around line 90-94: The current stability check is tautological — replace the
trivial copy-equality with a meaningful deterministic-order assertion: either
create an independently sorted reference by calling sorted =
[...it.configRefs!].sort(compareConfigRef) and assert
expect(it.configRefs).toEqual(sorted), or call scanMcpServers twice and assert
the two returned configRefs arrays are deep-equal (e.g.,
expect(first.configRefs).toEqual(second.configRefs)); reference
compareConfigRef, scanMcpServers and configRefs when making the change.

In `@apps/ccaudit/src/__tests__/tabbed-picker-force-partial-overflow.test.ts`:
- Around line 116-127: The test is counting unique agent-NN across the
accumulated output (agentMatches / plain) which can span multiple frames because
stdoutBuf is cumulative and stripAnsi removes overwrite cues; to fix, detect
when the banner first appears (e.g. by matching the banner text or using the
existing banner detection logic in this test) then reset/clear stdoutBuf (or
re-snapshot plain) immediately after that banner render and re-run the agent-NN
match on the fresh buffer so agentMatches/uniqueAgents reflect a single frame;
update the test around the agentMatches computation to re-collect plain after
clearing stdoutBuf (or re-snapshot) before asserting uniqueAgents ≤ 13.

In `@apps/ccaudit/src/__tests__/tabbed-picker-tab-nav-keys.test.ts`:
- Around line 175-185: The failure message when jsonlManifests.length !== 1
lacks useful diagnostics; update the thrown Error in the block that inspects
listManifestsDir/tmpHome (around variables jsonlManifests, manifestBase,
manifestPath, manifest) to include the subprocess exit code (result.exitCode)
and whether the confirmation prompt was reached by adding a boolean like
result.stdout.includes('Proceed with archive?') to the message; ensure you keep
the existing stdout/stderr slices and clearly label the new fields so CI logs
show exit code and the confirmation-marker presence.

In `@apps/ccaudit/src/cli/commands/restore.ts`:
- Around line 244-271: Several pre-dispatch hard-fail sites (mutual-exclusion
check, CCAUDIT_NO_INTERACTIVE refusal, TTY guard, the --name/--all-matching
no-match/ambiguity blocks, and interactive empty-archive) currently call
process.stderr.write(...) + process.exit(...) and must instead emit the JSON
failure envelope when outMode.json is active; replace those early exits with the
same JSON-emitting path used in the catch block (call
buildJsonEnvelope('restore','n/a', exitCode, { error: <message> }) and write it
to stdout or throw a typed Error that the existing catch will convert), ensure
you consult outMode.json before emitting plain-text, and call safeRecordHistory
where appropriate before exiting so history is not silently skipped.

In `@packages/internal/src/remediation/_archive-move.ts`:
- Line 62: The docstring for the ArchiveMove operation incorrectly claims
mkdirRecursive uses mode 0o700 while ArchiveMoveDeps.mkdirRecursive is typed as
(dir: string) => Promise<void> and the caller in
apps/ccaudit/src/cli/commands/restore.ts actually calls mkdir(dir, { recursive:
true, mode }) defaulting to 0o755; fix this by either (A) updating the docstring
near the ArchiveMoveDeps/ArchiveMove comment to state that the parent directory
is created with the caller-provided/default mode (0o755) and remove the "0o700
where honored" text, or (B) if 0o700 is required, add a mode parameter to
ArchiveMoveDeps.mkdirRecursive (e.g., mkdirRecursive(dir: string, mode?:
number)) and propagate that parameter through the restore.ts wiring to
mkdir(..., { mode: 0o700 }); choose one approach and update the signature,
calls, and docstring consistently (references: ArchiveMoveDeps.mkdirRecursive
and the restore.ts mkdir call).

In `@packages/internal/src/remediation/purge.ts`:
- Around line 316-326: The footer duration is hardcoded to 0 when closing the
manifest; instead use the writer's elapsedMs so the manifest footer records the
real elapsed time. Update the close call in purge.ts to pass the writer's
elapsedMs (the getter provided by ManifestWriter.open) into
closePurgeManifestWriter rather than 0, mirroring how writePurgeManifest in
manifest.ts reads writer.elapsedMs for bust manifests.

In `@README.md`:
- Around line 163-178: The README picker example incorrectly shows a "[ hooks
(0/1) ]" tab which contradicts SC6 and the PR scope; update the ASCII picker
sample to remove the hooks tab so it only lists agents, skills, mcp, commands,
and memory (remove the "[ hooks (0/1) ]" fragment and adjust spacing/counts
accordingly), and add a one-line note that hook archival is deferred in v1.5
(referencing the advisory string in ghost.ts such as "Hook archival deferred —
selectable archive coming in a future phase"); ensure this aligns with the test
expectation in apps/ccaudit/src/__tests__/preflight-retry.test.ts that the
rendered tab bar does not contain "HOOKS".

---

Nitpick comments:
In `@apps/ccaudit/scripts/bundle-smoke-test.mjs`:
- Around line 4-9: Replace the round-trip path resolution using
fileURLToPath/__filename and resolve with the ESM URL form: create the dist file
URL via new URL('../../dist/index.js', import.meta.url) (remove the __filename
variable and the fileURLToPath import) and use that URL (or convert to a
filesystem path only where needed, e.g. with fileURLToPath(new URL(...)) at the
point of calling existsSync). Update references to distPath to use the new URL
variable (or its file path) and remove the unused imports (fileURLToPath and
resolve) so the script uses canonical ESM URL resolution.

In `@apps/ccaudit/src/__tests__/fixtures/tmux-e2e.ts`:
- Around line 176-178: The generated runner script (runnerPath) written by
buildRunnerScript is not removed and should be cleaned up: store the runnerPath
on the TmuxE2ESession instance when you write it, then unlink it in
TmuxE2ESession.kill() (and also on normal session exit) so the file is removed
even on early failures; alternatively modify the API that creates the script to
return the script path to callers so they can manage deletion. Ensure you
reference the runnerPath property in the cleanup logic and handle ENOENT errors
safely when unlinking.
- Around line 142-146: The code in waitForText redundantly calls stripAnsi on
raw even though capture(...) already respects opts.stripAnsi via its ansi flag;
replace the conditional assignment "lastCapture = opts.stripAnsi === false ? raw
: stripAnsi(raw)" with a single unconditional "lastCapture = raw" (leave the
capture(...) call and its ansi: opts.stripAnsi === false argument intact) so
lastCapture trusts capture() to include/strip ANSI and removes the extra regex
work; update references to lastCapture accordingly.

In `@apps/ccaudit/src/__tests__/ghost-select-ids.test.ts`:
- Around line 39-63: This test file duplicates FAKE_PS_SCRIPT, buildFakePs and
agentItemId that already live in _test-helpers.ts; remove the local
FAKE_PS_SCRIPT and buildFakePs definitions and the re-implemented agentItemId,
and instead import buildFakePs and agentItemId from ./_test-helpers.ts and use
those imports throughout the test (referencing the symbols buildFakePs and
agentItemId to locate where to replace the local code).

In `@apps/ccaudit/src/__tests__/pagination-500.test.ts`:
- Around line 20-21: Tests import internal source via deep relative paths
(TabbedGhostPicker, buildGhosts500 and a dynamic import of _viewport), which
couples tests to package internals; change imports to use the package barrel
exports (e.g. import { TabbedGhostPicker, applyScroll } from
'@ccaudit/terminal') and move fixture import to a package-level or test fixtures
path (replace buildGhosts500 relative import accordingly). If test isolation is
not required, replace the dynamic await
import('../../../../packages/terminal/src/tui/_viewport.ts') with a static
top-level import of applyScroll (or the exported symbol) from the package;
otherwise keep it but import the exported symbol from the package barrel instead
of the internal path.

In `@apps/ccaudit/src/__tests__/restore-list-skips-purge.test.ts`:
- Around line 110-130: The purge manifest header in this test is missing fields
present in restore-after-purge.test.ts; update the purgeHeader object (the
variable named purgeHeader used to build purgeBody and written to purgePath) to
include the fuller set of keys used in the sister test (e.g.,
checkpoint_ghost_hash, checkpoint_timestamp, since_window, os, node_version,
planned_ops, selection_filter and any other fields there) so both fixtures
match, or alternatively add a shared makePurgeManifest()/makePurgeHeader()
helper in _test-helpers.ts and use it here to construct the header before
stringifying and writing purgeBody.

In `@apps/ccaudit/src/__tests__/tabbed-picker-filter.test.ts`:
- Around line 93-102: The test environment helper baseEnv is missing a TZ
override which other integration tests set to ensure reproducible timestamps;
update the baseEnv function to include TZ: 'UTC' in its returned object (i.e.,
add the TZ key alongside CCAUDIT_FORCE_TTY, LINES, COLUMNS, NO_COLOR, etc.) so
spawned subprocesses use UTC and timestamp-related fixtures remain deterministic
across environments.

In `@apps/ccaudit/src/__tests__/tabbed-picker-live-counter.test.ts`:
- Around line 51-58: The stripAnsi function currently only removes CSI sequences
and cursor show/hide codes and may leave OSC (`\x1b]... \x1b\\`) and DCS
sequences; update the stripAnsi function to also remove OSC and DCS sequences by
adding regexes that strip OSC patterns (`\x1b\][^\x1b]*(\x1b\\|\x07)`) and DCS
patterns (`\x1bP[^\x1b]*\x1b\\`) in addition to the existing CSI rules so
assertions won’t leak richer terminal escapes if the picker output grows.

In `@apps/ccaudit/src/__tests__/tabbed-picker-tab-nav-keys.test.ts`:
- Around line 213-240: Two tests ("Tab key ('\\t') cycles tabs forward..." and
"ArrowRight key ('\\x1b[C') cycles tabs forward...") are duplicated; replace
them with a parameterized it.each that iterates over cases with a descriptive
title and the key sequence and runs the same assertions. Locate the tests that
call runPickerWithTabKey(tmpHome, <key>) and consolidate into a single it.each
table (e.g., rows for ['Tab key (\\t)', '\t'] and ['ArrowRight key (\\x1b[C)',
'\x1b[C']) and keep the existing assertions against exitCode,
manifest.header?.planned_ops.archive, manifest.ops.length, and archiveOps
categories unchanged. Ensure the test timeout (25_000) is preserved and the test
body still destructures { exitCode, manifest, stderr } from runPickerWithTabKey.
- Around line 129-161: Extract the duplicated exponential-backoff stdout polling
into a reusable helper (e.g., waitForStdout) and replace both while loops with
calls to it: implement waitForStdout(spawned.child, () => stdoutBuf, marker,
maxWaitMs) that polls until the marker substring appears or the child exits or
timeout elapses; put the helper in a shared test helper module (e.g.,
_test-helpers.ts) and update the two call sites in
tabbed-picker-tab-nav-keys.test.ts to call waitForStdout(spawned.child, () =>
stdoutBuf, 'AGENTS (0/2)', 5000) and waitForStdout(spawned.child, () =>
stdoutBuf, 'Proceed with archive?', 5000) respectively.

In `@apps/ccaudit/src/cli/commands/purge-archive.ts`:
- Around line 134-250: The code collects unreadable-manifest entries in
manifestErrors inside the readManifest loop but only includes them in the
success envelope; fix by appending manifestErrors (mapped to {path,reason} or
their reason strings) into the JSON failure envelopes and the safeRecordHistory
errors/result for both the catch branch that handles
discoverManifests/classifyArchiveOps failures and the Result.isFailure branch
after executePurge; update the objects built for
buildJsonEnvelope('purge-archive', ...) and the errors/result arrays passed to
safeRecordHistory so they include manifestErrors (e.g., include manifestErrors
as additional failures in the purge.failures array and merge their reasons into
the history.errors array) while preserving the existing top-level message.

In `@apps/ccaudit/src/cli/commands/restore.ts`:
- Around line 286-330: The restore command duplicates manifest-reading logic in
the nameFlag, allMatchingFlag and interactive branches by repeatedly calling
findManifestsForRestore and readManifest to build pairs; extract this into a
helper (e.g., loadManifestPairs(deps)) that performs the single IO pass: call
findManifestsForRestore(deps), map entries to { entry, ops: (await
readManifest(entry.path)).ops } with Promise.all, and return the pairs (or
deduped result if you prefer); then replace the repeated blocks in the nameFlag
and allMatchingFlag branches to reuse loadManifestPairs and feed its result into
dedupManifestOps/matchByName to produce nameResolvedId and
allMatchingResolvedIds.

In `@CHANGELOG.md`:
- Line 8: Normalize the dash style in the changelog headers: replace the em-dash
in the header containing "## [1.5.0-beta.0] — 2026-04-19" with an ASCII hyphen
to match the other headers like "## [1.5.0] - 2026-04-26" and ensure all release
headers use " - " consistently; optionally add an empty "## [Unreleased]"
section above the "## [1.5.0] - 2026-04-26" header to follow Keep a Changelog
convention.

In `@eslint.config.ts`:
- Around line 10-15: The current change raises
projectService.maximumDefaultProjectFileMatchCount_THIS_WILL_SLOW_DOWN_LINTING
to include more .mjs files; instead add those script paths to a proper tsconfig
and point ESLint at it: create or update a tsconfig (e.g. tsconfig.scripts.json)
that includes apps/ccaudit/scripts/*.mjs and scripts/*.mjs, then modify the
ESLint config to reference that project via projectService.project (or project)
and remove the allowDefaultProject and
maximumDefaultProjectFileMatchCount_THIS_WILL_SLOW_DOWN_LINTING entries; update
any comments to reflect the new tsconfig usage.

In `@packages/internal/src/remediation/bust.ts`:
- Around line 305-313: The orchestrator runBust currently emits a console.warn
when selectedItems is an empty set; remove that stderr write and instead
propagate the informational message via the BustResult success value (e.g., add
a warnings or infoMessage field on BustResult.success) so callers can decide how
to display it; locate the selectedItems check in runBust (and the
filteredPlan/filterChangePlan logic) and when selectedItems.size === 0 populate
the new warning field on the returned success result rather than calling
console.warn, or leave it unset and let the CLI inspect selectedItems and emit
the message.

In `@packages/internal/src/remediation/manifest.ts`:
- Around line 447-512: openPurgeManifestWriter duplicates the purge filename
logic from resolvePurgeManifestPath, risking drift; replace the inline
stamp/rand generation by calling resolvePurgeManifestPath(new Date(ts)) to get a
canonical filename, then if manifestsDir is provided use path.join(manifestsDir,
path.basename(resolvedPath)) otherwise use resolvedPath as manifestPath, and
proceed to construct the ManifestWriter and call
writer.open(buildPurgeManifestHeader(...)) as before (update references in
openPurgeManifestWriter to use resolvePurgeManifestPath, ManifestWriter,
buildPurgeManifestHeader).
- Around line 560-604: writePurgeManifest duplicates the writer
creation/open/write/close/error-rewind logic in two branches; refactor to
resolve writer and manifestPath before the try/catch: if input.manifestPath
create const writer = new ManifestWriter(input.manifestPath) and await
writer.open(buildPurgeManifestHeader(...)) and set manifestPath =
input.manifestPath, otherwise call const { writer, path: manifestPath } = await
openPurgeManifestWriter(...); then have a single try block that loops await
writer.writeOp(op) for each op, calls await closePurgeManifestWriter(writer, {
durationMs: writer.elapsedMs }), and a single catch that does await
writer.close(null) and rethrows; reference symbols: writePurgeManifest,
ManifestWriter, openPurgeManifestWriter, closePurgeManifestWriter,
buildPurgeManifestHeader.

In `@packages/internal/src/remediation/purge.ts`:
- Around line 268-300: The outer try currently wraps both the conditional unlink
(deps.unlinkFile) and the writer.writeOp, causing filesystem errors to be
reported against ops that didn't perform I/O (e.g., reason ===
'stale_archive_missing'); split the logic so unlink errors are handled
separately from manifest write errors: perform deps.unlinkFile inside its own
try/catch (or a narrowly-scoped try) and push a failure entry specific to unlink
failures if it throws, then run buildArchivePurgeOp and the existing
writer.writeOp try/catch exactly as-is to record manifest write failures;
reference the loop over plan.drop, the conditional check for reason ===
'source_occupied', deps.unlinkFile, buildArchivePurgeOp, writer.writeOp, and the
failures/appendedOps/summary updates when moving the catch scopes.

In `@packages/internal/src/remediation/restore.ts`:
- Around line 1250-1267: In the all-matching branch, when iterating allEntries
and calling deps.readManifest (inside the block that currently does "if
(m.header === null) continue;"), emit the same warning as other paths instead of
silently skipping: call deps.onWarning?.(...) with a message/metadata indicating
a corrupt or unreadable manifest (include the entry/path or canonical id) before
continuing. This aligns behavior with executeInteractiveOps and the full path
and ensures users see skipped corrupt manifests; update the loop that builds
zipped (the readManifest check) to perform the warning prior to continue.
- Around line 349-366: filterRestoreableItems currently awaits isStaleArchiveOp
inside a for..of loop, causing sequential pathExists calls; instead, map items
to promises (call isStaleArchiveOp only for items with op.op_type === 'archive',
else resolve false), await Promise.all to get stale flags, then iterate original
items and corresponding flags to build kept and filteredStaleCount (this
preserves order and exact counts). Apply the same Promise.all pattern to the
other two loops with the same shape (the list-mode loop and the full-restore
loop) so their per-item pathExists checks run in parallel while keeping
deterministic ordering.

In `@packages/terminal/src/tables/change-plan.ts`:
- Around line 73-91: The three archive rows in the change-plan display (the
lines.push template strings that use plan.counts.agents / plan.counts.skills /
plan.counts.commands) produce misaligned arrows because "commands" is longer;
normalize the label column width by padding the label text to a common width
(e.g., use label.padEnd(8) or format the string so "agents", "skills", and
"commands" occupy the same character width) in each of the three template
strings so the "→ ~/.claude/ccaudit/archived/..." arrows align vertically.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

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

Comment thread apps/ccaudit/scripts/bundle-smoke-test.mjs Outdated
Comment thread apps/ccaudit/src/__tests__/empty-inventory.test.ts
Comment thread apps/ccaudit/src/__tests__/preflight-retry.test.ts
Comment thread apps/ccaudit/src/__tests__/safety-invariants-framework.test.ts
Comment thread apps/ccaudit/src/__tests__/scanner-multi-config-mcp.test.ts
Comment thread apps/ccaudit/src/__tests__/tabbed-picker-tab-nav-keys.test.ts
Comment thread apps/ccaudit/src/cli/commands/restore.ts Outdated
Comment thread packages/internal/src/remediation/_archive-move.ts Outdated
Comment thread packages/internal/src/remediation/purge.ts
Comment thread README.md
…ction

Ships an interactive TUI for selective ghost archival, scoped restore
flows, framework-as-unit protection, and a purge command for draining
the archive directory. All flows preserve the v1.4.0 safety invariants:
nothing is deleted, every mutation is manifest-logged, every bust is
gated by a SHA-256 inventory checkpoint.

### Added

- `ccaudit ghost --interactive` (default when stdout is a TTY): tabbed
  picker across agents, skills, MCP servers, commands, and memory with
  live token counter, fuzzy filter (`/`), sort cycle (`s`), framework
  group toggle, help overlay (`?`), and SIGWINCH-safe pagination.
  Subset busts write filtered `manifest.header.planned_ops` and a new
  `bust.summary.freedTokens` (subset-accurate) alongside the additive
  `totalPlannedTokens` (full-plan figure preserved).
- `ccaudit restore --interactive` / `--name <pattern>` / `--all-matching`:
  scoped restore selection across all manifests; surfaces
  `restore.selection_filter` and `restore.skipped[]` in the JSON envelope.
- `ccaudit purge-archive`: classifier-driven drain of
  `~/.claude/ccaudit/archived/` (reclaim-if-free → drop-if-occupied →
  drop-if-stale → skip). Default `--dry-run`; real purge requires `--yes`.
  Each mutation appends an `archive_purge` op to a fresh purge manifest;
  originals are never rewritten.
- Framework-as-unit protection: members of partially-used frameworks are
  unselectable in the picker and skipped by the non-interactive path
  unless `--force-partial` is set on both `--dry-run` and
  `--dangerously-bust-ghosts`.
- `CCAUDIT_NO_INTERACTIVE=1` env gate: silently suppresses auto-open;
  hard-refuses explicit `--interactive` with exit 2.
- Empty-inventory short-circuit on `ghost` and `ghost --interactive`.
- Color-blind-friendly glyph set with ASCII fallback under `NO_COLOR` /
  `TERM=dumb`: ◉ selected, ◯ unselected, 🔒 protected, ⚠ multi-config
  MCP, ⌛ stale memory.
- Tabbed-picker pagination bounded by terminal rows; 500+ item tabs
  scroll cleanly; scroll position preserved across filter / sort /
  framework-toggle.
- Stale-archive filter on `restore` listing; surfaces
  `restore.filtered_stale_count` in JSON.

### Changed

- README headline reframed around the Opus 4.7 1M-token context window:
  ~108k token ghost inventory now expressed as ~11% of 1M, with the
  absolute number kept inline. New "Native alternatives" section
  compares `/skills t-sort`, `/usage`, and `claude plugin disable`
  against ccaudit's cross-component scope, regime-aware token math, and
  archive-with-rollback differentiator.
- Manifest header records subset-vs-full provenance; `restore` walks
  every manifest in `~/.claude/ccaudit/manifests/` deduplicated by
  `archive_path` (newer wins).
- `restore --list` now skips purge follow-up manifests so they don't
  appear as restoreable busts.
- Memory canonical_ids include op_type and op_id so distinct
  flag/refresh ops on the same file remain individually restoreable
  (INV-S3 strengthening).

### Fixed

- Unselected MCP server keys are byte-preserved in `~/.claude.json`
  across subset busts (no whitespace churn, no key reordering).
- MCP rename in global-disable now scopes its key search to the
  top-level `mcpServers` block, preventing accidental mutation of a
  project-scoped entry that happens to share the same name.
- SIGINT during any TUI flow produces zero manifest writes.
- `--help` output for `restore` and `ghost` subcommands (negatable
  flags, drop `-v` short, pattern metavars).
- Memory ops surfaced in restore picker via `collectRestoreableItems`.
- Windows path rendering: `presentPath` and `scan-memory` import paths
  now normalize backslashes so project-relative and home-relative paths
  render correctly on Windows.
- Full-bust health summary now excludes archived commands from the
  remaining ghost count (was previously only excluding agents/skills/MCP).
- `reclaim` performs a fresh source-existence check at move-time via
  the injected `pathExists` callback, closing a TOCTOU window between
  orphan detection and rename.
- `ghost --interactive` under `CCAUDIT_NO_INTERACTIVE=1` now sets
  `process.exitCode` and returns instead of `process.exit()`, so the
  outer try/finally writes a history entry for the refused invocation.
- `restore` CLI wraps `findManifestsForRestore`, `readManifest`, and
  `openRestorePicker` in the outer try/catch so corrupt manifests and
  TUI init failures degrade gracefully through the structured stderr /
  JSON / history path.
- `purge` `executePurge()` now wraps `writeManifest` in try/catch and
  returns `Result.fail(...)` on throw, keeping the domain-layer Result
  contract.
- TUI integration test helpers `waitForPicker` and `waitForMarker`
  centralized in `_test-helpers.ts`; the inverted `child.exitCode`
  poll condition is fixed and `waitForMarker` now throws on timeout
  rather than silently continuing past missing output.

### Invariants

INV-S1..INV-S6 added to CLAUDE.md as fixture-backed integration tests.
See [CHANGELOG.md](./CHANGELOG.md) and [docs/JSON-SCHEMA.md](./docs/JSON-SCHEMA.md)
for the full surface.
Fabio-D added 2 commits April 27, 2026 10:08
Two stale `v1.5.0-beta.0` strings remained at lines 34 and 685 after the
package.json bump to 1.5.0. Aligns headline + Version section with the
shipped package version and adds the missing `purge-archive` mention.
Three upcoming directions: an interactive TUI process killer for live
Claude Code sessions, a stable v1.6 JSON CLI game-API contract (initial
consumer: the ccaudit Ghost Town game), and a forthcoming visual frontend.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 13

♻️ Duplicate comments (2)
packages/internal/src/remediation/restore.ts (1)

473-503: ⚠️ Potential issue | 🟠 Major

restore --list still leaks items that were already drained by purge-archive.

This loop skips the purge-*.jsonl files themselves, but it never suppresses older archive ops whose op_id is referenced by those purge manifests. If the source path later disappears, those purged items show back up in --list even though the full-restore and interactive paths intentionally hide them. Reuse collectPurgedOpIds() here before building kept so list mode stays consistent with the other restore flows.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/internal/src/remediation/restore.ts` around lines 473 - 503, In
executeListMode, call collectPurgedOpIds() (using the same deps) before
iterating manifest.ops and use the returned Set of purged op_ids to exclude any
ManifestOp whose op_id is present (in addition to the existing stale archive
check); update the kept construction (the loop that builds kept and increments
filteredStaleCount) to skip ops when op.op_id is in the purged set so list mode
mirrors full-restore/interactive behavior (refer to collectPurgedOpIds,
executeListMode, manifest.ops, isStaleArchiveOp, filteredStaleCount, and
ManifestOp to locate the change).
apps/ccaudit/src/cli/commands/restore.ts (1)

377-380: ⚠️ Potential issue | 🟠 Major

Don’t bypass the shared JSON/history path on picker cancellation.

This direct process.exit(0) is the remaining early-exit hole in the new pre-dispatch flow. restore --interactive --json will emit plain stderr with empty stdout on cancel, and cancellation never reaches safeRecordHistory(). Route cancel through the same typed preflight/result path instead of exiting inside the try. Based on learnings, recordHistory must be wrapped in try/catch at the outermost layer, and the --json envelope is a public contract.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/ccaudit/src/cli/commands/restore.ts` around lines 377 - 380, The code
currently calls process.exit(0) when openRestorePicker returns outcome.kind ===
'cancelled', which bypasses the shared JSON/history path; remove the direct exit
and instead convert the cancelled outcome into the same typed preflight/result
flow that other outcomes use so cancellation is handled by the existing
post-dispatch logic (i.e., pass a cancellation result object into the same
branch that calls safeRecordHistory()/recordHistory); ensure recordHistory is
invoked inside the outermost try/catch (wrap the caller that handles outcomes so
recordHistory errors are caught) and preserve the --json envelope on stdout even
for cancellations so the public JSON contract remains intact.
🧹 Nitpick comments (23)
apps/ccaudit/scripts/bundle-smoke-test.mjs (1)

29-33: Optional: surface signal/stdout on failure for easier diagnosis.

When spawnSync hits the 10s timeout, result.status is null and result.signal is set (e.g. SIGTERM); the failure message will read exited with code null and result.stdout (which may contain partial CLI output explaining the hang) is dropped. Consider including both for faster triage:

♻️ Proposed refinement
 if (result.status !== 0) {
-  console.error(`[bundle-smoke] FAIL: dist/index.js --help exited with code ${result.status}`);
+  const reason = result.signal
+    ? `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);
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/ccaudit/scripts/bundle-smoke-test.mjs` around lines 29 - 33, The failure
branch currently only logs result.status and stderr; update it to also surface
result.signal when status is null and always print result.stdout (alongside
stderr) to aid diagnosis of timeouts/hangs from the spawnSync call that produces
the result object. Locate the block referencing result.status (the spawnSync
result variable) and change the error message to include either "exited with
code <status>" or "killed by <signal>" depending on whether result.status is
null, and ensure console.error prints result.stdout and result.stderr (if
present) before calling process.exit(1).
apps/ccaudit/src/__tests__/fixtures/ghost-500-items.ts (2)

47-47: Path pluralization is unsafe for non-default categories.

/fake/${category}s/${name}.md only yields a sensible plural for agent → agents, skill → skills, command → commands. Categories like memory or mcp would render as memorys / mcps, which is fine for purely synthetic fixtures but will mislead anyone grepping fixture output by realistic path shape. Since the only consumer today (pagination-500.test.ts) uses the 'agent' default this is not breaking, but consider mapping category → directory explicitly if non-agent fixtures get added.

♻️ Suggested mapping
-      path: `/fake/${category}s/${name}.md`,
+      path: `/fake/${categoryDir(category)}/${name}.md`,
function categoryDir(c: TokenCostResult['item']['category']): string {
  switch (c) {
    case 'agent': return 'agents';
    case 'skill': return 'skills';
    case 'command': return 'commands';
    case 'mcp': return 'mcp';
    case 'memory': return 'memory';
    default: return `${c}s`;
  }
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/ccaudit/src/__tests__/fixtures/ghost-500-items.ts` at line 47, The path
pluralization is unsafe; create a helper function categoryDir (or similar) that
maps TokenCostResult item categories to their correct directory names (e.g.,
'agent'→'agents', 'skill'→'skills', 'command'→'commands', 'mcp'→'mcp',
'memory'→'memory', default → `${c}s`) and replace the inline
`/fake/${category}s/${name}.md` with `/fake/${categoryDir(category)}/${name}.md`
in the ghost-500-items fixture so paths are correct for non-default categories.

7-9: Doc nit: comment says 001..500 but factory supports up to 999.

The block comment on lines 7–9 states names go agent-001..agent-500, but count is validated against the range 1..999 (line 32). Consider tightening the comment to reflect the 1–999 range so future readers don't think 500 is a hard ceiling.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/ccaudit/src/__tests__/fixtures/ghost-500-items.ts` around lines 7 - 9,
Update the block comment that currently says "agent-001..agent-500" to reflect
the actual supported range (1..999) enforced by the count validation; mention
tokens decrementing from 1000 and the deterministic mtime behavior as before.
Locate the comment near the factory that uses the count variable (the same scope
that validates count against 1..999) and change the example to
"agent-001..agent-999" or otherwise state the 1–999 range so readers don’t
assume 500 is the limit.
apps/ccaudit/src/__tests__/pagination-500.test.ts (2)

90-103: Test name overstates what's verified; consider adding a real clamp case.

The header (line 17) advertises that applyScroll "clamps cursor on heavy filter narrowing", but this test passes rowsLen: 500 to filterOff so no clamping branch is exercised — it only confirms a no-op round-trip. A small additional assertion with rowsLen smaller than the saved cursor (e.g. applyScroll({cursor: 300, savedCursorPreFilter: 300}, {type: 'filterOff', rowsLen: 50}) → cursor ≤ 49) would actually cover the clamp path.

Also, the dynamic await import(...) on line 96 has no functional reason here (no circular-import or lazy-load constraint mentioned); a top-level import { applyScroll } from '...' would be simpler and let the test stay synchronous.

♻️ Suggested additions
-  it('applyScroll reducer round-trips cursor across filter on/off', async () => {
-    const { applyScroll } = await import('../../../../packages/terminal/src/tui/_viewport.ts');
+  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();
+
+    // Clamp branch: rowsLen shrank below saved cursor.
+    const sClamp = applyScroll(s2, { type: 'filterOff', rowsLen: 50 });
+    expect(sClamp.cursor).toBeLessThanOrEqual(49);
+    expect(sClamp.savedCursorPreFilter).toBeNull();
   });

Plus add import { applyScroll } from '../../../../packages/terminal/src/tui/_viewport.ts'; at the top.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/ccaudit/src/__tests__/pagination-500.test.ts` around lines 90 - 103, The
test name claims clamping is verified but currently only exercises a no-op
round-trip because applyScroll is dynamically imported and filterOff is called
with rowsLen: 500; change the test to import { applyScroll } top-level rather
than using await import(...) and add a second assertion that exercises the clamp
branch by calling applyScroll({ cursor: 300, savedCursorPreFilter: 300 }, {
type: 'filterOff', rowsLen: 50 }) (or any rowsLen < savedCursorPreFilter) and
asserting the returned cursor is <= rowsLen - 1 and savedCursorPreFilter is
null; this covers the heavy-filter-narrowing clamp path and keeps the original
round-trip assertion.

63-88: This test re-implements the handler it claims to verify.

Lines 70–84 manually set filterMode, savedCursorPreFilter, then manually clamp and restore — exactly the operations the production / and Esc handlers perform. As written, the test asserts that the test code itself is consistent, not that TabbedGhostPicker preserves cursor across filter on/off. The comment on lines 74–76 acknowledges this ("We DON'T actually run the filter here").

If the goal is to lock the reducer contract, the next test (lines 90–103) already does that via applyScroll. Consider either (a) driving the picker through its real filter entry/exit methods (e.g. the keystroke or method that the / handler eventually calls) so the invariant is enforced on the production code, or (b) deleting this case as redundant with the reducer test.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/ccaudit/src/__tests__/pagination-500.test.ts` around lines 63 - 88, This
test manually replicates the production '/' and 'Esc' handlers (setting
picker.filterMode, tab.savedCursorPreFilter, clamping and restoring) so it only
verifies the test code itself; either remove this redundant test or change it to
exercise the actual production handlers: instead of directly mutating
picker.filterMode and tab.cursor, drive the picker via the real entry/exit
methods (e.g., call the keystroke handler or public methods the UI uses such as
picker.handleKey('/') and picker.handleKey('Escape') or
picker.enterFilterMode()/picker.exitFilterMode() if those exist) and then assert
that tab.savedCursorPreFilter and tab.cursor are preserved/restored; keep
references to makePicker, picker, tab.savedCursorPreFilter, and applyScroll when
updating or removing the test to avoid duplication with the reducer test.
apps/ccaudit/src/__tests__/tabbed-picker-50-item-fixture.test.ts (2)

155-175: Hard-coded settle delays may be flaky on slow CI runners.

The 200–300ms sleeps between keystrokes are pragmatic for a PTY harness, but on a loaded CI box the picker may not have repainted before the next key arrives, which would manifest as intermittent failures of the 'selected across all tabs' marker check or as keys consumed in the wrong tab/mode. If you start seeing intermittent failures, consider replacing each setTimeout with a small waitForMarker that pins on the expected post-keystroke state (e.g., help overlay visible/hidden, filter input echoed, tab title changed) instead of wall-clock time.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/ccaudit/src/__tests__/tabbed-picker-50-item-fixture.test.ts` around
lines 155 - 175, Replace the hard-coded setTimeout-based delays after
sendKeys(spawned.child, ...) calls with targeted waits that assert the UI
reached the expected post-keystroke state (e.g., visible/hidden help overlay,
filter input echoed, tab title changed) using a helper like waitForMarker;
update the sequence around sendKeys(...) and the following new Promise(...)
calls so each keystroke is followed by waitForMarker(markerStringOrPredicate)
instead of setTimeout, referencing the existing sendKeys and spawned.child usage
and adding/using waitForMarker in this test to pin on the expected render before
continuing.

183-186: Optional: prefer toContain for the exit-code assertion.

expect([...].includes(x)).toBe(true) collapses to a boolean and loses the diff in Vitest's output. expect([0, 130, null]).toContain(result.exitCode) produces a clearer failure message that names the actual value, and you can drop the manual stderr/stdout tail in the message string (Vitest will print the assertion itself).

♻️ Proposed refactor
-      // 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);
+      // Exit contract. Tails are appended to the assertion message so that
+      // CI failures still surface the picker's last output without losing the
+      // diff that `toContain` provides.
+      try {
+        expect([0, 130, null]).toContain(result.exitCode);
+      } catch (err) {
+        (err as Error).message +=
+          `\nstderr tail:\n${stderrBuf.slice(-500)}\nstdout tail:\n${stdoutBuf.slice(-500)}`;
+        throw err;
+      }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/ccaudit/src/__tests__/tabbed-picker-50-item-fixture.test.ts` around
lines 183 - 186, Replace the boolean-inclusion assertion that uses expect([0,
130, null].includes(result.exitCode)).toBe(true) with Vitest's clearer form
expect([0, 130, null]).toContain(result.exitCode) in the test (referencing
result.exitCode), and remove the custom failure message (the manual stderrBuf/
stdoutBuf tail string) since Vitest will show the actual value and diff
automatically.
apps/ccaudit/src/__tests__/tabbed-picker-help-overlay.test.ts (3)

105-120: Space-swallow assertion is correct but indirect — consider an explicit check.

The "Space was swallowed while overlay was open" guarantee is verified transitively: after closing via ?, the footer slice must still contain 0 of 3 selected across all tabs. That works (a processed Space would flip the counter to 1 of 3), but the failure message will point at "footer not restored" rather than "Space leaked into the picker", which is harder to triage.

Consider also asserting that the overlay text ((Press ? or Esc to close) or one of the visible headings) is still present in the slice between sending Space and sending the closing ?, to make the swallow behavior explicit.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/ccaudit/src/__tests__/tabbed-picker-help-overlay.test.ts` around lines
105 - 120, Add an explicit assertion that the overlay remained open after the
Space key was sent instead of only relying on the footer after the `?` toggle;
specifically, after the sendKeys(spawned.child, [' ']) and the following delay,
capture the intermediate stdoutBuf slice (using stripAnsi on the bytes appended
since before the Space) and assert it contains a known overlay string such as
"(Press ? or Esc to close)" or one of the overlay headings, so the test checks
the Space-swallow behavior directly before you later sendKeys(spawned.child,
['?']) and verify footer restoration.

138-138: Minor: include result.exitCode in the assertion message.

Both tests end with expect([0, 130, null].includes(result.exitCode)).toBe(true); (also at L185). On failure, vitest only reports expected false to be true, which forces a re-run with logging to discover the actual exit code. A small wrapper makes the diagnostic self-contained.

♻️ Proposed tweak
-    expect([0, 130, null].includes(result.exitCode)).toBe(true);
+    expect(
+      [0, 130, null].includes(result.exitCode),
+      `expected exitCode ∈ {0,130,null} after Ctrl-C, got ${String(result.exitCode)}`,
+    ).toBe(true);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/ccaudit/src/__tests__/tabbed-picker-help-overlay.test.ts` at line 138,
Replace the brittle assertion pattern expect([0, 130,
null].includes(result.exitCode)).toBe(true) with an assertion that reports the
actual value, e.g. expect([0, 130, null]).toContain(result.exitCode) (update
both occurrences around the test endings), so failures will show the received
exitCode in the test output.

39-46: Consider using strip-ansi (available as transitive dependency) or extending the regex to cover additional escape sequences.

The local stripAnsi implementation handles cursor visibility (?25l/h), a fixed set of CSI final bytes (mGKHFABCD), and JST sequences, but does not cover bracketed-paste mode (?2004h/l), other DEC private modes, OSC sequences (\x1b]…\x07 / …\x1b\\), or r/n/c sequences. If the renderer emits any of these, residual bytes can land mid-token in assertion checks like toContain('Selection'), causing flaky failures.

The strip-ansi@6.0.1 package is available as a transitive dependency (via @clack/promptsstring-width), making it a practical choice. Low priority since the current regex evidently covers what the picker emits today.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/ccaudit/src/__tests__/tabbed-picker-help-overlay.test.ts` around lines
39 - 46, Replace the fragile local stripAnsi function in the test with the
well-tested strip-ansi implementation: remove the stripAnsi function and import
stripAnsi from the strip-ansi package (used transitively) in the test file so
all OSC, DEC private modes (e.g. ?2004h/l), bracketed paste, and other escape
sequences are stripped consistently before assertions; if you prefer to keep a
local solution instead, expand the regex in the stripAnsi function to also match
OSC sequences (\x1b] ... \x07 / ... \x1b\\), additional DEC private modes like
?2004[h|l], and remaining CSI/SGR final bytes to avoid stray bytes in test
tokens.
packages/internal/src/remediation/atomic-write.ts (1)

65-129: Optional: factor the shared tmp+rename body out of atomicWriteJson / atomicWriteText.

The two functions are byte-identical except for the body production (JSON.stringify(value, null, 2) vs the caller-supplied text). Extracting a private atomicWriteRaw(targetPath, body, opts) and having both publics call it would keep the retry/unlink/tmp-suffix invariants in one place — important when SC-9's Windows EPERM retry semantics evolve.

♻️ Sketch
+async function atomicWriteRaw(
+  targetPath: string,
+  body: string,
+  options: AtomicWriteOptions,
+): Promise<void> {
+  const opts = { ...DEFAULTS, ...options };
+  const dir = path.dirname(targetPath);
+  const rand = Math.random().toString(36).slice(2, 10).padEnd(8, '0');
+  const tmpPath = `${targetPath}.tmp-${process.pid}-${rand}`;
+  await mkdir(dir, { recursive: true, mode: opts.dirMode });
+  try {
+    await writeFile(tmpPath, body, { mode: opts.mode, encoding: 'utf8' });
+    await renameWithRetry(tmpPath, targetPath, opts);
+  } catch (err) {
+    try { await unlink(tmpPath); } catch { /* swallow */ }
+    throw err;
+  }
+}
+
 export async function atomicWriteJson<T>(...): Promise<void> {
-  // inline body
+  await atomicWriteRaw(targetPath, JSON.stringify(value, null, 2), options);
 }
 export async function atomicWriteText(...): Promise<void> {
-  // inline body
+  await atomicWriteRaw(targetPath, text, options);
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/internal/src/remediation/atomic-write.ts` around lines 65 - 129, The
two functions atomicWriteJson and atomicWriteText duplicate the tmp-file
creation, mkdir, write/rename with retry and cleanup logic; extract that shared
behavior into a private helper atomicWriteRaw(targetPath: string, body: string,
options?: AtomicWriteOptions): Promise<void> that performs rand/tmpPath
creation, mkdir(dir,..), writeFile(tmpPath,...),
renameWithRetry(tmpPath,targetPath,opts) and the unlink-on-error cleanup, then
have atomicWriteJson call atomicWriteRaw(targetPath, JSON.stringify(value, null,
2), options) and atomicWriteText call atomicWriteRaw(targetPath, text, options)
so the EPERM retry/unlink invariants live in one place.
packages/internal/src/scanner/_present-path.ts (1)

30-46: Confirm passthrough mutates non-home Windows paths to forward slashes.

L45 returns abs (the forward-slashed copy), not absPath. So a Windows-style input like C:\\some\\system\\path.json that matches neither homeDir nor projectRoot is returned as C:/some/system/path.json — not the original. The module doc at L17 ("Forward slashes only") suggests this is intentional, but the JSDoc at L28 says "absPath unchanged" which is now slightly inaccurate. Either:

  • update the JSDoc to read "the forward-slash-normalized form of absPath", or
  • pass through absPath verbatim when no rule matches.

Most callers will already be feeding pre-normalized paths in, so this is a doc/behavior alignment nit rather than a bug.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/internal/src/scanner/_present-path.ts` around lines 30 - 46, The
JSDoc for presentPath is inaccurate: the function normalizes backslashes to
forward slashes and returns the normalized string even in the passthrough case;
update the JSDoc for presentPath to state it returns the
forward-slash-normalized form of absPath (e.g., "Returns the
forward-slash-normalized form of absPath, with projectRoot or homeDir compressed
when applicable") so the documentation matches the existing behavior
(alternatively, if you prefer original raw behavior, change the final return to
return absPath verbatim instead of abs).
apps/ccaudit/src/__tests__/restore-preflight-json.test.ts (1)

66-138: Repeated PATH-override env construction can be hoisted.

Each of the five JSON-envelope tests (and the plain-stderr test, and the empty-archive test) recomputes PATH: \${path.join(tmpHome, 'bin')}:${process.env.PATH ?? ''}`. Pulling this into a small helper (e.g., withFakePsPath(tmpHome, extra?)) would shrink each test by a line, reduce copy-paste drift across the suite, and make it easier to keep parity with how runCcauditGhostwiresPATHin_test-helpers.ts`.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/ccaudit/src/__tests__/restore-preflight-json.test.ts` around lines 66 -
138, The tests in restore-preflight-json.test.ts repeatedly build the same PATH
override for runCcauditCli calls; introduce a small helper (e.g.,
withFakePsPath(tmpHome, extraEnv?)) that returns an env object with PATH:
`${path.join(tmpHome, 'bin')}:${process.env.PATH ?? ''}` merged with any extra
vars, add it to _test-helpers.ts (next to runCcauditGhost wiring) and replace
the inline env objects in the tests (the runCcauditCli invocations in this file)
with calls to withFakePsPath(tmpHome) or withFakePsPath(tmpHome, {
CCAUDIT_FORCE_TTY: '1', ... }) to reduce duplication.
apps/ccaudit/src/__tests__/fixtures/manual-qa-followups.ts (1)

391-404: stagePaginationFixture writes 550 files serially — non-trivial test setup latency.

Each iteration awaits writeFile then utimes, serialized. 550 × 2 = 1100 sequential FS round-trips. Batching with Promise.all (or chunked) cuts this dramatically and keeps pagination tests within reasonable budgets on slower CI runners.

-  for (let i = 1; i <= count; i++) {
-    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 Promise.all(
+    Array.from({ length: count }, async (_, idx) => {
+      const name = `agent-${String(idx + 1).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);
+    }),
+  );
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/ccaudit/src/__tests__/fixtures/manual-qa-followups.ts` around lines 391
- 404, stagePaginationFixture currently performs writeFile and utimes
sequentially per file which causes 1100 serial FS round-trips for count=550;
change it to run file writes/utimes in parallel by creating per-file async tasks
(each task does writeFile then utimes) and collecting them into batches, then
await Promise.all for each batch to limit concurrency (e.g., CHUNK_SIZE 50) so
the functions (stagePaginationFixture, the per-file task) still ensure utimes
runs after writeFile but many files are processed concurrently to drastically
reduce test setup latency.
apps/ccaudit/src/__tests__/tabbed-picker-force-partial-banner.test.ts (2)

113-120: Fixed 400ms wait before reading the post-a frame is racy.

sendKeys(['a']) followed by setTimeout(400) then assertion is timing-dependent. On a slow CI (or under coverage instrumentation) the picker may not have repainted the 2 of 2 selected across all tabs line within 400ms, producing a flaky failure that won't reproduce locally. Prefer reusing waitForMarker against the post-press buffer slice — it already polls until the predicate matches or the child exits.

-    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);
+    const before = out.length;
+    await sendKeys(spawned.child, ['a']);
+    await waitForMarker(
+      () => out.slice(before),
+      () => spawned.child.exitCode !== null,
+      '2 of 2 selected across all tabs',
+    );

Same pattern recurs in Scenario C (line 196-200) and in tabbed-picker-protection.test.ts.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/ccaudit/src/__tests__/tabbed-picker-force-partial-banner.test.ts` around
lines 113 - 120, The test currently uses sendKeys(spawned.child, ['a']) then a
fixed await new Promise(setTimeout 400) and immediate slice+regex, which is
racy; replace the fixed timeout by awaiting the existing waitForMarker helper to
poll the post-press buffer until the expected text appears. Concretely, after
sendKeys(spawned.child, ['a']) remove the setTimeout and the immediate const
afterA = stripAnsi(out.slice(before)); expect(...) and instead call await
waitForMarker(() => /2 of 2 selected across all
tabs/.test(stripAnsi(out.slice(before)))) (or the project’s waitForMarker API
variant that accepts the buffer slice/predicate), then perform the assertion
using the matched buffer; apply the same change to the Scenario C occurrence and
the similar pattern in tabbed-picker-protection.test.ts, referencing sendKeys,
waitForMarker, stripAnsi, spawned.child and out to locate each spot.

38-44: stripAnsi is duplicated across test files; consolidate to a shared test helper.

The test files in apps/ccaudit/src/__tests__/ each define their own stripAnsi function—at least 8 files (tabbed-picker-force-partial-banner.test.ts, tabbed-picker-overflow.test.ts, tabbed-picker-protection.test.ts, tabbed-picker-framework-group.test.ts, tabbed-picker-sort-cycle.test.ts, tabbed-picker-live-counter.test.ts, tabbed-picker-mcp-multi-config.test.ts, and tabbed-picker-sort-cycle.test.ts). While a shared helper exists in packages/terminal/src/utils/table-utils.ts, it only strips SGR codes and is not imported into tests.

A shared test helper (e.g., in apps/ccaudit/src/__tests__/_test-helpers.ts) would reduce duplication. The current regex variants are adequate for the sequences actually emitted by the TTY under test (cursor show/hide, SGR colors, cursor movement, and erase), but a consolidated approach ensures consistency and simplifies maintenance.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/ccaudit/src/__tests__/tabbed-picker-force-partial-banner.test.ts` around
lines 38 - 44, Multiple tests duplicate the stripAnsi function; extract it into
a shared test helper (e.g., create an exported stripAnsi in a new
_test-helpers.ts) and update each test (those that currently define stripAnsi
such as tabbed-picker-force-partial-banner.test.ts,
tabbed-picker-overflow.test.ts, tabbed-picker-protection.test.ts, etc.) to
import and use the shared stripAnsi instead of their local copy; keep the
current regex behavior (cursor show/hide, SGR, movement, erase) or align with
the existing strip logic in table-utils.ts if preferred, and remove the
duplicated stripAnsi definitions from all test files.
packages/internal/src/remediation/checkpoint.ts (1)

595-647: Minor: fixture is re-read three times.

Each golden-fixture test reloads ghost-hash-golden.json via readFile. Hoisting the load (e.g., into a beforeAll that populates a closure variable) avoids three identical disk reads and centralizes the JSON parse/cast. Not blocking — three reads on a tiny fixture is fine — but it would tighten the suite.

Also worth considering: Test 2 silently passes if the fixture happens to contain no memory-category items (the .map would no-op and mutated would equal fixture.input modulo identity). A expect(mutatedAtLeastOne).toBe(true) guard inside the map would harden the test against future fixture changes.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/internal/src/remediation/checkpoint.ts` around lines 595 - 647,
Hoist the repeated read/parse of ./__fixtures__/ghost-hash-golden.json into a
beforeAll that sets a closure variable (e.g. fixture: { input:
TokenCostResult[]; expectedHash: string }) and reuse it in the three it() blocks
instead of calling readFile each time; keep using computeGhostHash and the
deterministic StatFn (StatFn, MCP_MTIME) as now but create them once per test or
in beforeEach as needed. Additionally, in the "mutating any mtime" test, assert
that you actually mutated at least one item (e.g. check fixture.input.some(r =>
r.item.category === 'memory') or set a mutatedAtLeastOne flag during the map and
add expect(mutatedAtLeastOne).toBe(true)) before computing mutatedHash to avoid
a silent no-op if the fixture contains no memory items. Ensure references to
computeGhostHash, ghost-hash-golden.json, TokenCostResult, and StatFn remain
unchanged so the diff is localized to test setup and the mutation check.
packages/internal/src/remediation/purge.ts (1)

190-207: Nit: the void item; workaround in the dry-run reclaim loop is unnecessary.

The body doesn't read anything from item, so the cleanest expression of the intent is a direct assignment:

Suggested simplification
   if (opts.dryRun) {
     // Dry-run: compute counters from the plan; NO deps called at all.
-    for (const item of plan.reclaim) {
-      summary.reclaimedCount += 1;
-      void item; // explicit noop — satisfy no-unused-vars without mutating item
-    }
+    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;
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/internal/src/remediation/purge.ts` around lines 190 - 207, Remove
the unnecessary noop `void item;` in the dry-run reclaim loop inside the
opts.dryRun branch: the loop over plan.reclaim should simply increment
summary.reclaimedCount for each item without referencing item; update the for
(const item of plan.reclaim) { summary.reclaimedCount += 1; } body accordingly
(no other changes to the surrounding Result.succeed return or the drop loop).
apps/ccaudit/src/__tests__/restore-interactive-roundtrip.test.ts (1)

148-152: Optional: the 300 ms fixed delay between picker submit and confirm-prompt toggle is a flake risk on slow CI runners.

The assertion plan relies on the clack confirm prompt being mounted before the ArrowLeft keystroke arrives. On a hot loaded CI box (or under sandbox/cgroup throttling) 300 ms can be tight, and a missed keystroke would leave the prompt at its initialValue: false and silently flip the round-trip to a dry-fail. Consider either bumping to ~600 ms or extending the helper module with a waitForConfirmPrompt(child) that polls stdout for a clack-confirm marker the way waitForPicker does. Not a blocker for v1.5.0 — flagging because the test gates INV-S3.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/ccaudit/src/__tests__/restore-interactive-roundtrip.test.ts` around
lines 148 - 152, The fixed 300ms grace delay before sending ArrowLeft to the
clack confirm prompt is flaky on slow CI; update the test in
restore-interactive-roundtrip.test.ts to wait deterministically for the confirm
prompt instead of (or in addition to) a short timeout: either increase the sleep
to ~600ms or (preferred) add a waitForConfirmPrompt(child) helper that polls the
spawned.child stdout for the same clack-confirm marker pattern used by
waitForPicker, then call that before invoking sendKeys(spawned.child,
['\x1b[D'], 100) and sendKeys(spawned.child, ['\r'], 100) so the keystrokes
arrive only after the confirm prompt is mounted.
packages/internal/src/remediation/change-plan.ts (1)

102-157: Optional: extract a recomputeCounts helper to DRY up count recomputation.

The counts recomputation block is duplicated verbatim between buildChangePlan (lines 102-108) and filterChangePlan (lines 146-152). When a future category is added, both sites must change in lockstep — a small extract avoids the drift risk.

♻️ Proposed extract
+function computeCounts(
+  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,
+  };
+}

Then call it from both buildChangePlan and filterChangePlan.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/internal/src/remediation/change-plan.ts` around lines 102 - 157, The
counts object is duplicated in buildChangePlan and filterChangePlan; extract a
helper (e.g., recomputeCounts) that accepts the archive/disable/flag arrays or a
ChangePlan-like partial and returns the counts shape used (agents, skills, mcp,
memory, commands), then replace the inline count blocks in buildChangePlan and
filterChangePlan to call recomputeCounts and assign its result to counts before
recomputing savings with calculateDryRunSavings; reference the functions
buildChangePlan, filterChangePlan, calculateDryRunSavings, and the counts
property to locate where to replace logic.
packages/internal/src/remediation/index.ts (1)

146-148: Underscore-prefixed module exposed through the public barrel.

_archive-move.ts uses the _ prefix that conventionally signals "private/internal", yet it's now re-exported from the package barrel (moveArchiveToSource + types). Either the underscore is misleading (the helper is actually a shared public primitive used by both reclaim and purge) or the re-export should be reconsidered. Renaming to archive-move.ts would make the intent unambiguous.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/internal/src/remediation/index.ts` around lines 146 - 148, The file
and exports currently re-export a module named _archive-move.ts (exporting
moveArchiveToSource and types ArchiveMoveDeps, MoveArchiveInput,
MoveArchiveFailure) which uses a leading underscore implying private but is
publicly surfaced; rename the module to archive-move.ts and update the export
lines to import/export from './archive-move.ts' (and rename the file on disk) so
the public API (moveArchiveToSource and the three types) no longer references a
misleading underscore and clearly indicates a public/shared primitive.
packages/internal/src/remediation/bust.ts (2)

686-750: Tolerate JSON whitespace between key and colon in findTopLevelMcpServersBlock.

text.startsWith('"mcpServers":', i) and the per-mutation needle = "${name}": both require zero whitespace between the closing quote and :. Valid JSON permits "mcpServers" : { ... }, so any pretty-printer that emits a space here triggers null and silently downgrades to atomicWriteJson (loses INV-S1 byte preservation). Since disableMcpTransactional recovers via the JSON fallback this is not a correctness bug, just a degraded path users won't notice.

♻️ Sketch: skip whitespace before the colon
-      if (depth === 1 && text.startsWith('"mcpServers":', i)) {
-        let j = i + '"mcpServers":'.length;
+      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++;

The same relaxation can apply to needle lookup at line 779 (e.g., search via a regex bounded to [block.start, block.end)).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/internal/src/remediation/bust.ts` around lines 686 - 750, The
findTopLevelMcpServersBlock function currently uses
text.startsWith('"mcpServers":', i) which fails when JSON has whitespace between
the closing quote and colon; change the logic to first match the quoted key
'"mcpServers"' at index i, then advance past any intervening whitespace and
assert the next non-space char is ':' before continuing to locate the object
(update the same pattern inside the function where it computes j). Also update
the per-mutation needle construction/lookup (the code that builds/checks
`${name}":`) to allow optional whitespace between the closing quote and ':' —
either by scanning/skipping whitespace after the quoted name or by using a regex
bounded to the block range — so keys like `"mcpServers" : {` are accepted.

949-1061: Mixed-scope MCP files always fall back to full JSON rewrite.

When a single config file produces both a project-scope item (sets hasSurgicalFallback = true) and a global/flat item, the global items' surgical mutations are discarded and the entire file is rewritten via atomicWriteJson, dropping byte-preservation for keys that could otherwise be patched surgically. This matches the JSDoc (project-scope is unsupported), but it means INV-S1 is silently coarser than necessary in mixed busts. Not a bug — flagging for awareness.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/internal/src/remediation/bust.ts` around lines 949 - 1061,
Mixed-scope files currently drop surgical mutations when hasSurgicalFallback is
true; change the write logic so surgicalMutations are attempted via
patchMcpConfigText regardless of hasSurgicalFallback and, if patchMcpConfigText
returns non-null, apply its result to the final write path: (a) if there are
only surgical mutations (no project-scope changes), write the patched text with
deps.atomicWriteText as before; (b) if project-scope changes exist, parse or
otherwise incorporate the patched result into the config object (so the surgical
key moves are reflected) before calling deps.atomicWriteJson, ensuring
surgicalMutations (variables surgicalMutations, hasSurgicalFallback) are
preserved while still performing an atomic JSON rewrite for project-scope edits;
use patchMcpConfigText, deps.atomicWriteText, deps.atomicWriteJson and the
config object to implement this.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: e172ff65-62e8-4a42-aa4e-3085bcbf9a70

📥 Commits

Reviewing files that changed from the base of the PR and between 95fe0c1 and 4c37c26.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (124)
  • CHANGELOG.md
  • CLAUDE.md
  • README.md
  • apps/ccaudit/package.json
  • apps/ccaudit/scripts/bundle-baseline-03.2.txt
  • apps/ccaudit/scripts/bundle-baseline-phase-05.txt
  • apps/ccaudit/scripts/bundle-baseline-phase-06.txt
  • apps/ccaudit/scripts/bundle-baseline-phase-09.txt
  • apps/ccaudit/scripts/bundle-baseline.txt
  • apps/ccaudit/scripts/bundle-size-check.mjs
  • apps/ccaudit/scripts/bundle-smoke-test.mjs
  • apps/ccaudit/src/__tests__/__fixtures__/regime-post-2-1-117/.claude.json
  • apps/ccaudit/src/__tests__/__fixtures__/regime-post-2-1-117/.claude/settings.json
  • apps/ccaudit/src/__tests__/__fixtures__/regime-pre-2-1-116/.claude.json
  • apps/ccaudit/src/__tests__/__fixtures__/regime-pre-2-1-116/.claude/settings.json
  • apps/ccaudit/src/__tests__/__fixtures__/restore-interactive/bust-2026-04-17T10-00-00-000Z-aaaa.jsonl
  • apps/ccaudit/src/__tests__/__fixtures__/restore-interactive/bust-2026-04-18T10-00-00-000Z-bbbb.jsonl
  • apps/ccaudit/src/__tests__/_test-helpers.ts
  • apps/ccaudit/src/__tests__/command-archival.test.ts
  • apps/ccaudit/src/__tests__/empty-inventory.test.ts
  • apps/ccaudit/src/__tests__/fixtures/ghost-500-items.ts
  • apps/ccaudit/src/__tests__/fixtures/manual-qa-followups.md
  • apps/ccaudit/src/__tests__/fixtures/manual-qa-followups.ts
  • apps/ccaudit/src/__tests__/fixtures/tmux-e2e.md
  • apps/ccaudit/src/__tests__/fixtures/tmux-e2e.ts
  • apps/ccaudit/src/__tests__/ghost-select-ids.test.ts
  • apps/ccaudit/src/__tests__/help-output.test.ts
  • apps/ccaudit/src/__tests__/interactive-smoke.test.ts
  • apps/ccaudit/src/__tests__/no-interactive-env.test.ts
  • apps/ccaudit/src/__tests__/pagination-500.test.ts
  • apps/ccaudit/src/__tests__/preflight-retry.test.ts
  • apps/ccaudit/src/__tests__/purge-archive.test.ts
  • apps/ccaudit/src/__tests__/regime-detection-cc-2-1-117-compat.test.ts
  • apps/ccaudit/src/__tests__/restore-after-purge.test.ts
  • apps/ccaudit/src/__tests__/restore-all-matching.test.ts
  • apps/ccaudit/src/__tests__/restore-command.test.ts
  • apps/ccaudit/src/__tests__/restore-corrupt-manifest.test.ts
  • apps/ccaudit/src/__tests__/restore-flag-mutual-exclusion.test.ts
  • apps/ccaudit/src/__tests__/restore-interactive-memory-roundtrip.test.ts
  • apps/ccaudit/src/__tests__/restore-interactive-roundtrip.test.ts
  • apps/ccaudit/src/__tests__/restore-interactive-source-exists.test.ts
  • apps/ccaudit/src/__tests__/restore-json-envelope.test.ts
  • apps/ccaudit/src/__tests__/restore-list-skips-purge.test.ts
  • apps/ccaudit/src/__tests__/restore-name-ambiguity.test.ts
  • apps/ccaudit/src/__tests__/restore-preflight-json.test.ts
  • apps/ccaudit/src/__tests__/restore-stale-filter.test.ts
  • apps/ccaudit/src/__tests__/safety-invariants-framework.test.ts
  • apps/ccaudit/src/__tests__/safety-invariants-mcp.test.ts
  • apps/ccaudit/src/__tests__/safety-invariants-restore-roundtrip.test.ts
  • apps/ccaudit/src/__tests__/safety-invariants-tui-abort.test.ts
  • apps/ccaudit/src/__tests__/scanner-multi-config-mcp.test.ts
  • apps/ccaudit/src/__tests__/tabbed-picker-50-item-fixture.test.ts
  • apps/ccaudit/src/__tests__/tabbed-picker-bust-parity.test.ts
  • apps/ccaudit/src/__tests__/tabbed-picker-filter.test.ts
  • apps/ccaudit/src/__tests__/tabbed-picker-force-partial-banner.test.ts
  • apps/ccaudit/src/__tests__/tabbed-picker-force-partial-overflow.test.ts
  • apps/ccaudit/src/__tests__/tabbed-picker-framework-group.test.ts
  • apps/ccaudit/src/__tests__/tabbed-picker-help-overlay.test.ts
  • apps/ccaudit/src/__tests__/tabbed-picker-live-counter.test.ts
  • apps/ccaudit/src/__tests__/tabbed-picker-mcp-multi-config.test.ts
  • apps/ccaudit/src/__tests__/tabbed-picker-overflow.test.ts
  • apps/ccaudit/src/__tests__/tabbed-picker-protection.test.ts
  • apps/ccaudit/src/__tests__/tabbed-picker-sort-cycle.test.ts
  • apps/ccaudit/src/__tests__/tabbed-picker-tab-nav-keys.test.ts
  • apps/ccaudit/src/__tests__/tabbed-picker-terminal-too-short.test.ts
  • apps/ccaudit/src/__tests__/tmux-e2e-manual-qa.test.ts
  • apps/ccaudit/src/cli/_shared-args.ts
  • apps/ccaudit/src/cli/commands/ghost.ts
  • apps/ccaudit/src/cli/commands/inventory.ts
  • apps/ccaudit/src/cli/commands/mcp.ts
  • apps/ccaudit/src/cli/commands/purge-archive.ts
  • apps/ccaudit/src/cli/commands/restore.ts
  • apps/ccaudit/src/cli/commands/trend.ts
  • apps/ccaudit/src/cli/index.ts
  • docs/JSON-SCHEMA.md
  • eslint.config.ts
  • package.json
  • packages/internal/src/cli/env.ts
  • packages/internal/src/cli/index.ts
  • packages/internal/src/index.ts
  • packages/internal/src/remediation/__fixtures__/ghost-hash-golden.json
  • packages/internal/src/remediation/_archive-move.ts
  • packages/internal/src/remediation/atomic-write.ts
  • packages/internal/src/remediation/bust.ts
  • packages/internal/src/remediation/change-plan.ts
  • packages/internal/src/remediation/checkpoint.ts
  • packages/internal/src/remediation/index.ts
  • packages/internal/src/remediation/manifest.ts
  • packages/internal/src/remediation/purge.ts
  • packages/internal/src/remediation/reclaim.ts
  • packages/internal/src/remediation/restore.ts
  • packages/internal/src/remediation/savings.ts
  • packages/internal/src/scanner/_config-refs.ts
  • packages/internal/src/scanner/_present-path.ts
  • packages/internal/src/scanner/annotate.ts
  • packages/internal/src/scanner/index.ts
  • packages/internal/src/scanner/scan-hooks.ts
  • packages/internal/src/scanner/scan-mcp.ts
  • packages/internal/src/scanner/scan-memory.ts
  • packages/internal/src/scanner/types.ts
  • packages/internal/src/token/format.ts
  • packages/internal/src/token/index.ts
  • packages/internal/src/types.ts
  • packages/terminal/package.json
  • packages/terminal/src/index.ts
  • packages/terminal/src/tables/change-plan.ts
  • packages/terminal/src/tui/_filter-sort.ts
  • packages/terminal/src/tui/_force-partial-banner.ts
  • packages/terminal/src/tui/_glyph-capability.ts
  • packages/terminal/src/tui/_glyphs.ts
  • packages/terminal/src/tui/_help-overlay.ts
  • packages/terminal/src/tui/_mcp-warning-render.ts
  • packages/terminal/src/tui/_preflight-copy.ts
  • packages/terminal/src/tui/_protection-render.ts
  • packages/terminal/src/tui/_tab-bar.ts
  • packages/terminal/src/tui/_tui-mode.ts
  • packages/terminal/src/tui/_viewport.ts
  • packages/terminal/src/tui/auto-open-prompt.ts
  • packages/terminal/src/tui/confirmation.ts
  • packages/terminal/src/tui/index.ts
  • packages/terminal/src/tui/select-ghosts.ts
  • packages/terminal/src/tui/select-restore.ts
  • packages/terminal/src/tui/tabbed-picker.ts
  • pnpm-workspace.yaml
✅ Files skipped from review due to trivial changes (30)
  • apps/ccaudit/scripts/bundle-baseline-phase-05.txt
  • apps/ccaudit/scripts/bundle-baseline-03.2.txt
  • apps/ccaudit/scripts/bundle-baseline-phase-06.txt
  • apps/ccaudit/src/tests/fixtures/regime-pre-2-1-116/.claude.json
  • apps/ccaudit/scripts/bundle-baseline.txt
  • apps/ccaudit/scripts/bundle-baseline-phase-09.txt
  • apps/ccaudit/src/tests/fixtures/regime-pre-2-1-116/.claude/settings.json
  • packages/internal/src/cli/index.ts
  • packages/terminal/src/index.ts
  • packages/terminal/package.json
  • packages/internal/src/scanner/scan-memory.ts
  • apps/ccaudit/package.json
  • packages/internal/src/remediation/fixtures/ghost-hash-golden.json
  • apps/ccaudit/src/tests/fixtures/regime-post-2-1-117/.claude/settings.json
  • apps/ccaudit/src/tests/fixtures/manual-qa-followups.md
  • apps/ccaudit/src/tests/fixtures/restore-interactive/bust-2026-04-18T10-00-00-000Z-bbbb.jsonl
  • apps/ccaudit/src/tests/fixtures/regime-post-2-1-117/.claude.json
  • apps/ccaudit/src/cli/commands/trend.ts
  • apps/ccaudit/src/tests/fixtures/restore-interactive/bust-2026-04-17T10-00-00-000Z-aaaa.jsonl
  • apps/ccaudit/scripts/bundle-size-check.mjs
  • packages/internal/src/scanner/index.ts
  • apps/ccaudit/src/cli/commands/mcp.ts
  • apps/ccaudit/src/tests/fixtures/tmux-e2e.md
  • apps/ccaudit/src/tests/tabbed-picker-sort-cycle.test.ts
  • package.json
  • apps/ccaudit/src/tests/tabbed-picker-overflow.test.ts
  • apps/ccaudit/src/tests/tabbed-picker-filter.test.ts
  • packages/internal/src/scanner/annotate.ts
  • apps/ccaudit/src/tests/safety-invariants-mcp.test.ts
  • apps/ccaudit/src/cli/commands/ghost.ts
🚧 Files skipped from review as they are similar to previous changes (27)
  • packages/internal/src/remediation/savings.ts
  • packages/internal/src/token/index.ts
  • eslint.config.ts
  • apps/ccaudit/src/tests/restore-flag-mutual-exclusion.test.ts
  • apps/ccaudit/src/tests/empty-inventory.test.ts
  • apps/ccaudit/src/tests/restore-after-purge.test.ts
  • apps/ccaudit/src/tests/restore-corrupt-manifest.test.ts
  • apps/ccaudit/src/tests/tabbed-picker-force-partial-overflow.test.ts
  • packages/internal/src/scanner/types.ts
  • packages/internal/src/cli/env.ts
  • apps/ccaudit/src/tests/regime-detection-cc-2-1-117-compat.test.ts
  • apps/ccaudit/src/tests/restore-json-envelope.test.ts
  • apps/ccaudit/src/tests/tabbed-picker-bust-parity.test.ts
  • apps/ccaudit/src/tests/tabbed-picker-tab-nav-keys.test.ts
  • packages/internal/src/scanner/_config-refs.ts
  • apps/ccaudit/src/tests/interactive-smoke.test.ts
  • packages/internal/src/token/format.ts
  • apps/ccaudit/src/tests/restore-name-ambiguity.test.ts
  • apps/ccaudit/src/tests/restore-stale-filter.test.ts
  • apps/ccaudit/src/tests/tabbed-picker-framework-group.test.ts
  • apps/ccaudit/src/tests/scanner-multi-config-mcp.test.ts
  • apps/ccaudit/src/tests/restore-interactive-memory-roundtrip.test.ts
  • apps/ccaudit/src/tests/preflight-retry.test.ts
  • apps/ccaudit/src/tests/tmux-e2e-manual-qa.test.ts
  • apps/ccaudit/src/tests/tabbed-picker-mcp-multi-config.test.ts
  • apps/ccaudit/src/tests/_test-helpers.ts
  • apps/ccaudit/src/tests/tabbed-picker-live-counter.test.ts

Comment thread apps/ccaudit/src/__tests__/fixtures/manual-qa-followups.ts
Comment thread apps/ccaudit/src/__tests__/fixtures/tmux-e2e.ts
Comment thread apps/ccaudit/src/__tests__/pagination-500.test.ts
Comment on lines +172 to +196
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);
}
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Look up the documented envelope shape for empty-archive / no-op cases.
fd -t f 'JSON-SCHEMA' -e md
rg -nP -C5 'archive is empty|empty archive|no-op|filtered_stale_count'
rg -nP --type=ts -C3 'RestorePreflightError\b'

Repository: fabio-dee/ccaudit

Length of output: 50375


🏁 Script executed:

head -200 docs/JSON-SCHEMA.md

Repository: fabio-dee/ccaudit

Length of output: 11490


🏁 Script executed:

grep -nA 10 -B 2 "error\|preflight\|status" docs/JSON-SCHEMA.md | head -100

Repository: fabio-dee/ccaudit

Length of output: 6487


🏁 Script executed:

rg -nA 15 "restore.*status|preflight|empty archive" docs/JSON-SCHEMA.md | head -150

Repository: fabio-dee/ccaudit

Length of output: 1063


🏁 Script executed:

tail -200 docs/JSON-SCHEMA.md | head -150

Repository: fabio-dee/ccaudit

Length of output: 6738


🏁 Script executed:

rg -n "restore.*envelope|RestorePreflight" docs/JSON-SCHEMA.md

Repository: fabio-dee/ccaudit

Length of output: 131


🏁 Script executed:

rg -B 5 -A 20 "^\| \`.*restore" docs/JSON-SCHEMA.md

Repository: fabio-dee/ccaudit

Length of output: 5140


🏁 Script executed:

sed -n '1,300p' docs/JSON-SCHEMA.md | grep -A 100 "restore"

Repository: fabio-dee/ccaudit

Length of output: 14394


🏁 Script executed:

rg -B 5 -A 15 "RestorePreflightError|error.*field|error.*envelope" docs/JSON-SCHEMA.md

Repository: fabio-dee/ccaudit

Length of output: 43


🏁 Script executed:

rg -B 10 -A 15 "parseEnvelope|buildJsonEnvelope" apps/ccaudit/src/__tests__/restore-preflight-json.test.ts | head -80

Repository: fabio-dee/ccaudit

Length of output: 3035


🏁 Script executed:

wc -l docs/JSON-SCHEMA.md && rg -n "error\|status" docs/JSON-SCHEMA.md

Repository: fabio-dee/ccaudit

Length of output: 85


🏁 Script executed:

cat docs/JSON-SCHEMA.md | grep -A 5 -B 5 "error\|preflight"

Repository: fabio-dee/ccaudit

Length of output: 3389


🏁 Script executed:

rg -B 5 -A 25 "class RestorePreflightError\|interface.*Preflight" packages/internal/src/remediation/restore.ts | head -100

Repository: fabio-dee/ccaudit

Length of output: 43


🏁 Script executed:

rg -B 5 -A 15 "RestorePreflightError" packages/internal/src/remediation/restore.ts | head -150

Repository: fabio-dee/ccaudit

Length of output: 43


🏁 Script executed:

find . -name "*.ts" -type f -exec grep -l "RestorePreflightError" {} \;

Repository: fabio-dee/ccaudit

Length of output: 164


🏁 Script executed:

rg -B 5 -A 15 "RestorePreflightError" apps/ccaudit/src/cli/commands/restore.ts | head -200

Repository: fabio-dee/ccaudit

Length of output: 7871


🏁 Script executed:

rg "empty.*archive|Nothing to restore|preflight.*envelope" docs/JSON-SCHEMA.md

Repository: fabio-dee/ccaudit

Length of output: 43


Document the preflight error envelope shape in docs/JSON-SCHEMA.md.

The test correctly expects exitCode: 0 with error: "archive is empty" — this is intentional design for informational preflight outcomes (e.g., empty archive detected before execution begins). However, per the public contract rule, the preflight error envelope shape is not currently documented in the schema. Add a section covering the shape for cases like:

  • Empty archive when --interactive finds no restoreable items
  • Other preflight validation failures that exit non-zero but still emit JSON

The envelope includes meta.exitCode and error at the top level (spread via buildJsonEnvelope), with stderr remaining empty.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/ccaudit/src/__tests__/restore-preflight-json.test.ts` around lines 172 -
196, Add documentation for the preflight error JSON envelope used by
buildJsonEnvelope (cases like RestorePreflightError) to the project's JSON
schema document: describe the top-level fields emitted (meta.command,
meta.exitCode, error string) and note that stderr is empty for informational
preflight outcomes (e.g., "archive is empty" with meta.exitCode: 0) and that
other preflight failures may emit a non-zero meta.exitCode while still returning
JSON; include an example envelope and update schema section for preflight
validation responses so consumers know the exact shape.

Comment thread apps/ccaudit/src/__tests__/tabbed-picker-help-overlay.test.ts
Comment thread CHANGELOG.md
Comment thread docs/JSON-SCHEMA.md
Comment thread packages/internal/src/remediation/atomic-write.ts
Comment thread packages/internal/src/remediation/restore.ts
Comment thread packages/internal/src/scanner/scan-hooks.ts
Restore correctness & safety invariants
- Stop anchoring restore on the purge manifest (use bust manifests)
- Skip unreadable manifests instead of crashing restore
- Filter purged items out of `restore --list`
- Record history on cancel paths and JSON envelope on flag conflict

Parser hardening
- patchMcpConfigText: tolerate whitespace variants (INV-S1)
- scan-hooks: stop accepting non-canonical fields

Public API field renames (snake_case → camelCase)
- selection_filter → selectionFilter
- filtered_stale_count → filteredStaleCount
- canonical_id → canonicalId
- Coordinated across CLI, tests, fixtures, and docs/JSON-SCHEMA.md

Test reliability
- Replace remaining setTimeout sleeps with waitForMarker in
  tabbed-picker-help-overlay (CR-003) — protects INV-S2 coverage
- Make reclaim TOCTOU in-source test Windows-safe via
  path.join(tmpdir(), ...) + path.resolve() (CR-021)
- Sync stale docstring in tabbed-picker-help-overlay (CR-039)

Refactor
- Rename _archive-move.ts → archive-move.ts (drop underscore prefix)

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 9

♻️ Duplicate comments (1)
apps/ccaudit/src/cli/commands/purge-archive.ts (1)

201-216: ⚠️ Potential issue | 🟡 Minor

Verify purge.manifestPath / purge.manifestErrors are intentionally public.

These envelopes add two top-level keys beyond purge.summary.* and purge.failures[]. If that surface is intentional, it should be documented alongside the rest of the JSON contract; otherwise it would be cleaner to fold manifest-read problems into purge.failures[] and drop the extra keys. Based on learnings: "The --json envelope is a public contract; shape changes must go through docs/JSON-SCHEMA.md and require a CHANGELOG note flagged as breaking".

#!/bin/bash
# Verify whether the extra purge envelope fields are documented anywhere public.
rg -n -C2 'manifestPath|manifestErrors|purge\.summary|purge\.failures' \
  README.md CHANGELOG.md docs/JSON-SCHEMA.md

Also applies to: 257-260, 293-303

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/ccaudit/src/cli/commands/purge-archive.ts` around lines 201 - 216, The
JSON envelope being emitted when outMode.json is true currently exposes
purge.manifestPath and purge.manifestErrors as top-level fields beyond
purge.summary and purge.failures; decide whether those fields are part of the
public contract: if they are intentional, update the JSON schema
(docs/JSON-SCHEMA.md) and add a CHANGELOG entry noting the public shape change,
otherwise remove the extra keys from the envelope constructed by
buildJsonEnvelope in purge-archive.ts and fold any manifest read problems into
purge.failures[] (append failure entries derived from manifestErrors with a
clear reason and drop manifestPath), and apply the same change to the other
envelope constructions that reference manifestErrors/manifestPath so the JSON
output shape remains consistent.
🧹 Nitpick comments (14)
apps/ccaudit/scripts/bundle-smoke-test.mjs (1)

1-41: LGTM — smoke test correctly gates the bundle.

The script addresses the prior concern by spawning the built bundle with --help and asserting a clean exit, so import-resolution failures are now distinguishable from CLI startup paths. Spawn-error, signal-termination (incl. timeout SIGTERM), and non-zero exit are each handled with a clear [bundle-smoke] diagnostic, and stdout/stderr are echoed only when non-empty. Path resolution at line 10 correctly produces apps/ccaudit/dist/index.js because path.resolve treats __filename as a left-to-right segment.

Optional nit (Line 10): for readability, consider resolve(dirname(__filename), '../dist/index.js') so the relative depth no longer relies on the implicit "trailing filename consumes one .." behavior of path.resolve. Not a defect — purely stylistic.

✏️ Optional readability tweak
 import { fileURLToPath } from 'node:url';
-import { resolve } from 'node:path';
+import { dirname, 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');
+const __dirname = dirname(__filename);
+const distPath = resolve(__dirname, '../dist/index.js');
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/ccaudit/scripts/bundle-smoke-test.mjs` around lines 1 - 41, The dist
path computation using resolve(__filename, '../../dist/index.js') is correct but
slightly non-obvious; change it to compute dirname(__filename) first and then
resolve that with '../dist/index.js' so the intent is clearer — update the
distPath assignment (where __filename is derived via
fileURLToPath(import.meta.url) and currently used in the distPath constant) to
call dirname(__filename) from 'node:path' and then resolve(dirname(__filename),
'../dist/index.js') so the relative depth is explicit and more readable.
apps/ccaudit/src/__tests__/tabbed-picker-help-overlay.test.ts (1)

113-121: Dead beforeLen and a no-op waitForMarker after Space.

Two small issues in the Space-swallowed step:

  • L113 captures beforeLen = stdoutBuf.length but it is never read before being reassigned on L127.
  • The waitForMarker on L115–119 waits for (Press ? or Esc to close) against the full stdoutBuf, but that marker was already present from the open-overlay step on L96–100, so this call returns immediately without actually synchronizing on any post-Space render. The real "Space was swallowed" contract is what's asserted later via the 0 of 3 selected across all tabs footer after the ? close (L134–138), which is fine — but the inline assertion on L120 is just re-asserting the same already-buffered marker.

Consider either dropping L113 + tightening the post-Space wait to slice from the new offset (so we genuinely wait for the next render after Space), or trimming the redundant assertion and letting the post-toggle counter check carry the contract.

♻️ Suggested simplification
     // While overlay open, press Space — must be swallowed per D5-13.
-    let beforeLen = stdoutBuf.length;
+    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();
+    // Give Space a chance to be processed; the post-close counter check
+    // below is what actually proves the keystroke was swallowed.
+    expect(spawned.child.exitCode).toBeNull();
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/ccaudit/src/__tests__/tabbed-picker-help-overlay.test.ts` around lines
113 - 121, The test captures beforeLen = stdoutBuf.length but never uses it, and
waitForMarker(...) is checking the full stdoutBuf for the already-present
"(Press ? or Esc to close)" marker, so the post-Space synchronization is a
no-op; fix by either removing the dead beforeLen and the redundant wait/assert,
or make the wait actually observe new output by using the slice from beforeLen
(e.g., pass a function that returns stripAnsi(stdoutBuf).slice(beforeLen)) when
calling waitForMarker after sendKeys; update references to beforeLen, stdoutBuf,
sendKeys, waitForMarker, stripAnsi and spawned.child.exitCode accordingly so the
test truly waits for the post-Space render or removes the redundant checks.
apps/ccaudit/src/__tests__/pagination-500.test.ts (1)

43-48: Nit: tighten the upper bound to the actual viewport height.

The comment correctly derives viewportHeight = max(8, 24 - 10) = 14, so agentMatches.size should never exceed 14 distinct names. The ≤ 20 ceiling will silently accept a regression that lets the picker render up to 20 unique rows (e.g. an off-by-N in viewport slicing) — exactly the kind of overflow this test exists to catch. Lowering the cap to 14 makes the test match the documented invariant.

-    expect(agentMatches.size).toBeGreaterThanOrEqual(10);
-    expect(agentMatches.size).toBeLessThanOrEqual(20);
+    expect(agentMatches.size).toBeGreaterThanOrEqual(10);
+    expect(agentMatches.size).toBeLessThanOrEqual(14);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/ccaudit/src/__tests__/pagination-500.test.ts` around lines 43 - 48, The
test's upper bound is too loose: replace the ceiling check that expects
agentMatches.size ≤ 20 with a stricter check matching the derived viewportHeight
(14). Update the assertion that references agentMatches (created via new
Set(frame.match(/agent-\d{3}/g) ?? [])) so it uses toBeLessThanOrEqual(14)
instead of 20, and adjust or keep the comment noting viewportHeight = max(8,
24-10) = 14 to reflect the invariant.
apps/ccaudit/src/__tests__/fixtures/ghost-500-items.ts (1)

59-74: Optional: add an exhaustiveness guard to categoryDir.

The switch implicitly relies on TokenCostResult['item']['category'] being exactly these six members. If the union later gains a new variant (e.g. 'plugin'), this function will silently fall through and return undefined, which would then end up inside item.path as /fake/undefined/... and degrade fixture realism without a type error in most strictness configs.

♻️ Suggested guard
 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';
+    default: {
+      const _exhaustive: never = category;
+      throw new Error(`buildGhosts500: unsupported category ${String(_exhaustive)}`);
+    }
   }
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/ccaudit/src/__tests__/fixtures/ghost-500-items.ts` around lines 59 - 74,
The switch in categoryDir(category: TokenCostResult['item']['category']) lacks
an exhaustiveness guard and can return undefined if a new category is added;
update categoryDir to include a default / unreachable branch that either throws
an informative Error or uses a never-type assertion to force a compile-time
error (e.g., assertUnreachable) and ensure the function always returns a string,
referencing the categoryDir function and the TokenCostResult['item']['category']
union so future new variants fail loudly.
apps/ccaudit/src/__tests__/tabbed-picker-live-counter.test.ts (2)

173-175: Accepting null as a valid exit code may hide hangs.

expect([0, 130, null].includes(result.exitCode)).toBe(true) allows null, which in Node corresponds to a process terminated by signal without an exit code. After spawned.done resolves you've awaited termination, so 0 (clean exit) and 130 (SIGINT) are the only legitimate outcomes for these Ctrl-C cleanups. Accepting null here (and at lines 215, 277, 325, 401, 472) means a hung-then-killed-by-timeout subprocess would still pass. Consider tightening to [0, 130] once the harness reliably reports the SIGINT exit code.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/ccaudit/src/__tests__/tabbed-picker-live-counter.test.ts` around lines
173 - 175, The test currently accepts null as a valid exit code which can mask
hangs; update the assertions that check process termination (the
expect([...].includes(result.exitCode)) calls that follow
spawned.child.stdin!.end() and await spawned.done — e.g., the one assigning
const result = await spawned.done and using result.exitCode — to only accept [0,
130] instead of [0, 130, null]; apply the same tightening to the other identical
assertions in this test file (the other expect([...].includes(result.exitCode))
occurrences) so a hung/timeout-killed subprocess no longer passes the test.

263-271: Regex tolerates loose forms but only validates "a number is present".

/(?:~|≈)?\s*(\d+)(?:\.\d+)?k?\s+tokens saved/ makes the glyph, decimal, and k all optional, then only asserts the captured integer is finite. That's effectively "any digit followed by tokens saved". Given Test 3's purpose is to prove cross-tab sums populate the global footer, consider also asserting parsed > 0 (the fixture has 3 non-trivial bodies, so the sum must be positive) — otherwise a regression that zeroes the counter while still emitting "0 tokens saved" would slip past.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/ccaudit/src/__tests__/tabbed-picker-live-counter.test.ts` around lines
263 - 271, The test's regex in tabbed-picker-live-counter.test.ts currently only
checks that a numeric match exists and that the parsed integer is finite; update
the assertion after parsing (the `parsed` variable derived from `match[1]`) to
also assert parsed > 0 so the test fails if the counter is zero or negative,
ensuring the cross-tab sum is actually positive; keep the existing regex and
Number.isFinite check and simply add expect(parsed).toBeGreaterThan(0) (or
equivalent) after the parse step.
apps/ccaudit/src/__tests__/tabbed-picker-bust-parity.test.ts (1)

148-154: Optional: silent-skip path can mask real regressions.

When pickerTokensK is null or 0 the test logs and returns success rather than failing. If the fixture estimator ever silently returns 0 for non-trivial agent bodies (e.g. due to a tokenizer regression), this test will go green forever instead of catching the drift it's meant to guard. Consider asserting pickerTokensK !== null && pickerTokensK > 0 (or only allowing skip when explicitly opted in via env), since buildManyGhostsFixture is a controlled fixture that should reliably yield >0 tokens.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/ccaudit/src/__tests__/tabbed-picker-bust-parity.test.ts` around lines
148 - 154, The test currently silently skips when pickerTokensK is null or 0
(the if block that logs and returns), which can mask tokenizer regressions;
replace that silent-skip with an explicit assertion that pickerTokensK !== null
&& pickerTokensK > 0 (or only allow a skip when an explicit env var like
ALLOW_FIXTURE_SKIP is set), and if the assertion fails fail the test (clean up
by sending '\x03' to spawned.child and awaiting spawned.done as before) instead
of returning success; locate the conditional around pickerTokensK (produced by
buildManyGhostsFixture) and the cleanup using sendKeys(spawned.child, ['\x03'])
/ spawned.child.stdin!.end() to implement this change.
apps/ccaudit/src/__tests__/tmux-e2e-manual-qa.test.ts (2)

230-248: Possible flake: cleared.toContain('agent-081') couples to picker scroll-position-after-Esc.

After typing /agent-09 + Enter the filter narrows to 10 items, then Esc clears the filter. Whether agent-081 is still under the cursor (and therefore in the captured viewport) when the unfiltered list returns depends on the picker's "preserve cursor across filter clear" behavior. If the picker resets to row 0 on Esc — a reasonable UX choice — this assertion will start failing on a future picker tweak that's logically correct.

If the intent is "Esc cleared the filter" (not "cursor preserved its row"), the Filtered: 10 of 550 visible absence check on line 246 already proves that. Consider dropping line 247 or replacing it with a check on tab header counts (e.g. AGENTS (0/550)).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/ccaudit/src/__tests__/tmux-e2e-manual-qa.test.ts` around lines 230 -
248, The assertion that cleared.toContain('agent-081') is flaky because it
depends on picker cursor/scroll behavior after Esc; update the test in
tmux-e2e-manual-qa.test.ts to stop asserting the presence of a specific row
after clearing the filter and instead assert that the filter was cleared via an
invariant that doesn't depend on scroll position (for example, remove the
cleared.toContain('agent-081') check and replace it with a check against the tab
header text like asserting session.capture or session.waitForText contains
'AGENTS (0/550)', or simply rely on the existing
expect(current).not.toContain('Filtered: 10 of 550 visible') to prove the filter
was cleared). Ensure you update references to cleared/current/session
accordingly so the test still verifies the filter-clear behavior without
coupling to cursor position.

35-43: Minor: tmux availability probe runs at import-time even when opt-in is off.

The IIFE at lines 35-43 short-circuits on !optIn || win32, so execFileSync('tmux', ['-V']) only runs when opt-in is set. That's fine. However, if CCAUDIT_TMUX_E2E=1 is exported across a CI matrix where tmux is missing on some runners, the probe will swallow the error and silently skip the suite. Consider logging a single stderr line in that case so misconfigured CI is easier to debug.

-  } catch {
-    return false;
+  } catch {
+    process.stderr.write('tmux-e2e: opt-in set but `tmux -V` failed; suite will skip.\n');
+    return false;
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/ccaudit/src/__tests__/tmux-e2e-manual-qa.test.ts` around lines 35 - 43,
The tmux availability IIFE (tmuxAvailable) currently swallows errors when
execFileSync('tmux', ['-V']) fails, which hides misconfigured CI; update the
catch block so that when optIn is true and execFileSync throws you print a
single stderr line (e.g., via console.error) that includes a brief diagnostic
message and the caught error (or error.message) before returning false; keep the
short-circuit for process.platform === 'win32' and only emit the log when optIn
is enabled and execFileSync fails so the test matrix surfaces missing tmux,
referencing tmuxAvailable, optIn, and execFileSync.
packages/internal/src/remediation/change-plan.ts (1)

312-340: Brittle test: hand-rolled canonical IDs duplicate the format from canonicalItemId.

Tests 2 and 3 reconstruct the canonical ID strings by hand (e.g. `agent|global||/tmp/agentA`, `mcp-server|global||m1|/tmp/m1`). If the format produced by canonicalItemId ever changes (separator, ordering, additional segments), these assertions break far away from the production change and the failure won't make the cause obvious.

Consider invoking canonicalItemId(...) directly against the same inputs that produced the plan so the tests assert behavior, not a snapshot of the format string.

♻️ Suggested approach
-    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]));
+    it('Test 2: Set with 2 of 3 ids returns only those items', () => {
+      const plan = makePlan3();
+      const idA = canonicalItemId(plan.archive.find((i) => i.name === 'agentA')!);
+      const idB = canonicalItemId(plan.archive.find((i) => i.name === 'agentB')!);
+      const result = filterChangePlan(plan, new Set([idA, idB]));
       expect(result.archive).toHaveLength(2);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/internal/src/remediation/change-plan.ts` around lines 312 - 340,
Replace the brittle hand-constructed canonical ID strings in the tests with
calls to the canonicalItemId helper so the tests assert behavior instead of a
hardcoded format: in the Test 2 block that uses makePlan3() and then constructs
idA/idB, call canonicalItemId(...) with the same
category/scope/projectPath/name/path used to build those results; likewise in
Test 3, build idA1 and idM1 by calling canonicalItemId with the arguments
matching makeResult(...) and the mcp-server item's fields. Update the imports if
necessary and keep the rest of the assertions (filterChangePlan, counts,
savings) unchanged.
packages/internal/src/remediation/archive-move.ts (1)

90-102: Minor: io_error message conflates mkdir and rename failures.

If mkdirRecursive(...) throws, the catch wraps the error as rename ${archivePath} → ${sourcePath}: ..., which misattributes the failure (it was a mkdir, not a rename). For most callers the distinction doesn't matter, but reclaim's failed[].error and purge's failures[].reason surface this string verbatim, so debugging an EACCES on mkdir .claude/agents becomes harder than necessary.

♻️ Suggested fix
   try {
-    await deps.mkdirRecursive(path.dirname(sourcePath));
-    await deps.renameFile(archivePath, sourcePath);
-    return Result.succeed({ moved: true });
+    try {
+      await deps.mkdirRecursive(path.dirname(sourcePath));
+    } catch (err) {
+      const message = err instanceof Error ? err.message : String(err);
+      return Result.fail({
+        reason: 'io_error',
+        message: `mkdir ${path.dirname(sourcePath)}: ${message}`,
+      });
+    }
+    await deps.renameFile(archivePath, sourcePath);
+    return Result.succeed({ moved: true });
   } catch (err) {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/internal/src/remediation/archive-move.ts` around lines 90 - 102, The
error message always labels failures as a "rename" even when mkdirRecursive
fails; update the logic in the function that calls deps.mkdirRecursive and
deps.renameFile so the failure message reflects which operation failed (mkdir vs
rename). Implement this by tracking the current operation (e.g. set a local var
op = 'mkdir' before await deps.mkdirRecursive(...) and op = 'rename' before
await deps.renameFile(...)) or by splitting into two try/catch blocks, and use
that operation name when building the Result.fail payload (the reason/message
returned from Result.fail should include the correct operation and path),
leaving the Result.succeed({ moved: true }) behavior unchanged.
apps/ccaudit/src/__tests__/fixtures/tmux-e2e.ts (1)

145-165: Minor: stripAnsi runs twice on the default path.

When opts.stripAnsi !== false (the default), capture() already calls stripAnsi(stdout) internally (line 118), then waitForText calls stripAnsi(raw) again on line 155. It's harmless (the regex finds nothing on the second pass) but redundant. Either trust capture() to do it, or skip it in capture() when called from waitForText.

♻️ Suggested simplification
-    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);
+    while (Date.now() - start <= timeoutMs) {
+      lastCapture = await this.capture({
+        startLine: opts.startLine ?? -200,
+        ansi: opts.stripAnsi === false,
+      });
       const matched =
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/ccaudit/src/__tests__/fixtures/tmux-e2e.ts` around lines 145 - 165, The
waitForText function is redundantly calling stripAnsi on the capture output even
though capture already strips ANSI by default; update waitForText to trust
capture's ansi handling: when calling this.capture pass ansi: opts.stripAnsi ===
false (so capture strips unless explicitly disabled), then assign lastCapture =
raw (no extra stripAnsi call) and only call stripAnsi if capture was invoked
with ansi: true; adjust logic around opts.stripAnsi and the capture({ ansi })
call in waitForText accordingly so stripAnsi is not applied twice.
apps/ccaudit/src/__tests__/safety-invariants-restore-roundtrip.test.ts (1)

105-113: Helper drops runCcauditGhost overrides other than env.

runGhost only forwards env to runCcauditGhost; any other future option (timeout, cwd, stdin, signal) the helper supports will be silently ignored by callers of this local wrapper. Right now there is one call site so this is fine, but as the PR adds more --interactive-flavored flows on top of this fixture, prefer passing through opts opaquely.

♻️ Suggested refactor
-    async function runGhost(
-      args: string[],
-      env?: Record<string, string>,
-    ): Promise<{ stdout: string; stderr: string; exitCode: number | null }> {
-      const spawned = runCcauditGhost(tmpHome, args, env ? { env } : {});
+    async function runGhost(
+      args: string[],
+      opts: Parameters<typeof runCcauditGhost>[2] = {},
+    ): Promise<{ stdout: string; stderr: string; exitCode: number | null }> {
+      const spawned = runCcauditGhost(tmpHome, args, opts);
       spawned.child.stdin?.end();
       const r = await spawned.done;
       return { stdout: r.stdout, stderr: r.stderr, exitCode: r.exitCode };
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/ccaudit/src/__tests__/safety-invariants-restore-roundtrip.test.ts`
around lines 105 - 113, The helper runGhost only forwards the env option to
runCcauditGhost, dropping any other options; change runGhost to accept an
additional opts (or options) parameter (e.g., opts?: Record<string, any> or the
actual runCcauditGhost options type), pass those options through to
runCcauditGhost (merging env if provided) instead of only sending { env }, and
preserve the existing behavior of ending stdin and returning
stdout/stderr/exitCode; update the signature and the call site(s) that use
runGhost accordingly so future options like timeout, cwd, stdin, or signal are
not ignored.
packages/internal/src/remediation/purge.ts (1)

265-297: Optional: dedupe the reclaim/drop per-op scaffold.

The reclaim loop (232–263) and drop loop (265–297) share the same shape — outer try/catch, inner writer.writeOp try/catch with manifest_write_failed formatting, appendedOps.push, summary increment. A small runOpWithJournal(op, mutate, buildPurgeOp, onSuccess) helper would let the two loops collapse to their distinguishing parts (the mutation + the summary delta) and keep the manifest_write_failed: reason string in one place. Not blocking; current duplication is small and well-tested.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/internal/src/remediation/purge.ts` around lines 265 - 297, The two
loops ("reclaim" and "drop") duplicate the same outer try/catch, inner
writer.writeOp error handling, appendedOps push and summary updates; extract a
helper (e.g., runOpWithJournal) that accepts the op, a mutate function (the
conditional unlink or noop), a function to build the journal op (e.g.,
buildArchivePurgeOp or the reclaim equivalent), and an onSuccess callback to
update appendedOps and summary; inside the helper perform the try { await
mutate(); const journal = buildOp(...); try { await writer.writeOp(journal); }
catch (writeErr) { push to failures with manifest_write_failed formatted exactly
as now; return; } appendedOps.push(journal); call onSuccess(); } catch (err) {
push to failures with existing error formatting; } then replace both loops to
call runOpWithJournal(...) with the appropriate mutate and onSuccess lambdas so
the manifest_write_failed formatting and push-to-failures logic remain
centralized.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@apps/ccaudit/src/__tests__/restore-interactive-source-exists.test.ts`:
- Around line 80-83: Update the JSON-SCHEMA.md example and description to
reflect that canonicalId for archive operations includes the full archive path
(format: "agent:<archive_path>") not just the basename; find references/examples
showing "canonicalId": "agent:code-reviewer" and change them to the full path
form (e.g., "agent:/path/to/.claude/ccaudit/archived/agents/code-reviewer.md")
and add a short clarifying sentence near the "canonical item identifiers"
section explaining that archive ops emit the full archive path rather than only
the name so the public --json API contract is accurate (tests reference
parsed.skipped[*].canonicalId).

In `@apps/ccaudit/src/cli/commands/restore.ts`:
- Around line 305-308: The current use of Promise.all when mapping entries to
readManifest(entry.path) causes one unreadable/corrupt manifest to reject the
whole preflight; change the logic in the restore command near the
findManifestsForRestore usage so you individually attempt to read each manifest
(e.g., with a for-loop or Promise.allSettled) and collect only the successfully
read results into the pairs array, skipping and logging a warning for unreadable
manifests; preserve the existing deduplication semantics (dedupe by
archive_path, newer wins) when building the final list. Apply the same fix to
the other similar readManifest usages in this file where entries are bulk-read.

In `@docs/JSON-SCHEMA.md`:
- Around line 106-115: The two adjacent blockquotes titled "Manifest casing
exception." and "Pre-dispatch validation envelope." are separated by a blank
line which triggers MD028; either merge them into a single blockquote or insert
non-blockquote content between them. To fix, remove the blank line so both
paragraphs become one continuous blockquote (keeping the exact text of both
headers and bodies) or add a short paragraph or horizontal rule between the two
quoted sections to explicitly separate them; apply this change to the
blockquotes that begin with "Manifest casing exception." and "Pre-dispatch
validation envelope." in the diff.
- Around line 86-104: The docs incorrectly show short canonical IDs; update the
JSON-SCHEMA.md entries for selectionFilter (selectionFilter.ids) and
skipped[].canonicalId to document the real full-ID formats and replace the
example: state that canonicalId/selectionFilter.ids are strings that can be one
of the op-specific shapes — archive ops use "agent:<archive_path>" or generally
"<category>:<archive_path>" with a full filesystem path (e.g.
agent:/home/user/.claude/agents/_archived/code-reviewer.md), disable ops use
"mcp:<config_path>:<key>", and memory ops use
"memory:<op_type>:<file_path>:<op_id>"; update the prose lines describing
selectionFilter and skipped and replace the JSON example to show these full
formats so callers see selectionFilter.ids and skipped[].canonicalId as the real
full op identifiers.

In `@packages/internal/src/remediation/atomic-write.ts`:
- Around line 69-71: The change to pretty-print JSON in atomicWriteJson is
intentional: keep JSON.stringify(value, null, 2) in atomicWriteJson (the call
that delegates to atomicWriteRaw) and do not revert to compact JSON; if team
policy mandates recording on-disk format changes, add a short CHANGELOG entry
referencing the internal manifest files (e.g., ~/.claude/ccaudit/manifests) and
the commit c466f2b, otherwise leave the implementation as-is since tests cover
atomicity/retries not formatting.

In `@packages/internal/src/remediation/purge.ts`:
- Around line 232-311: The current all-failed check treats manifest_write_failed
(journal persist failures after disk mutations) the same as disk-level failures;
update the final-all-failed gate in the purge function so it ignores
manifest_write_failed entries when deciding to return Result.fail: compute
failureCount = failures.filter(f =>
!f.reason?.startsWith('manifest_write_failed')).length and use that against
totalRequested (keep the existing closePurgeManifestWriter(writer, null)
behavior and existing failures array), so that if only manifest_write_failed
entries exist (disk mutations succeeded) the function returns Result.success
while still reporting the manifest failures in failures[].

In `@packages/internal/src/remediation/restore.ts`:
- Around line 985-991: The unreadable manifest bug comes from calling
deps.readManifest() in the executeInteractiveOps / "{ kind: 'all-matching' }"
paths without the per-entry try/catch used by full/list mode; wrap each
deps.readManifest(e.path) call in a try/catch, on error call deps.onWarning with
a descriptive message including path basename and skip that entry (do not
throw), and only push to zipped when a manifest object is successfully read and
m.header !== null—mirror the error-handling pattern used in the full/list
restore flow so restore continues despite single corrupt manifests (apply same
fix to both the executeInteractiveOps and the all-matching branch).

In `@README.md`:
- Around line 323-329: The README incorrectly documents the positional restore
operand as accepting a canonical id; update the wording to state it expects an
archived item name (the archive basename / MCP server name) to match how
apps/ccaudit/src/cli/commands/restore.ts routes the positional arg through
findManifestForName() and how packages/internal/src/remediation/restore.ts
resolves items. Change the `<name>` row (and the duplicate description at lines
referenced 409-413) to explicitly say "archive name (basename / MCP server
name)" or similar, and ensure examples and error text align with the actual
behavior implemented by findManifestForName() and the remediation restore logic.
- Around line 234-243: Add a new row for the purge-archive command to the
top-level command table so the README’s command matrix includes this v1.5
feature: locate the markdown table and insert a row with `purge-archive` as the
Command, a concise description like "Purge archived files not referenced by any
manifest", and the relevant Notable options (e.g. `--dry-run`, any other flags
documented in the existing "purge-archive" section); ensure formatting matches
the other rows and the pipe-separated columns align with the header.

---

Duplicate comments:
In `@apps/ccaudit/src/cli/commands/purge-archive.ts`:
- Around line 201-216: The JSON envelope being emitted when outMode.json is true
currently exposes purge.manifestPath and purge.manifestErrors as top-level
fields beyond purge.summary and purge.failures; decide whether those fields are
part of the public contract: if they are intentional, update the JSON schema
(docs/JSON-SCHEMA.md) and add a CHANGELOG entry noting the public shape change,
otherwise remove the extra keys from the envelope constructed by
buildJsonEnvelope in purge-archive.ts and fold any manifest read problems into
purge.failures[] (append failure entries derived from manifestErrors with a
clear reason and drop manifestPath), and apply the same change to the other
envelope constructions that reference manifestErrors/manifestPath so the JSON
output shape remains consistent.

---

Nitpick comments:
In `@apps/ccaudit/scripts/bundle-smoke-test.mjs`:
- Around line 1-41: The dist path computation using resolve(__filename,
'../../dist/index.js') is correct but slightly non-obvious; change it to compute
dirname(__filename) first and then resolve that with '../dist/index.js' so the
intent is clearer — update the distPath assignment (where __filename is derived
via fileURLToPath(import.meta.url) and currently used in the distPath constant)
to call dirname(__filename) from 'node:path' and then
resolve(dirname(__filename), '../dist/index.js') so the relative depth is
explicit and more readable.

In `@apps/ccaudit/src/__tests__/fixtures/ghost-500-items.ts`:
- Around line 59-74: The switch in categoryDir(category:
TokenCostResult['item']['category']) lacks an exhaustiveness guard and can
return undefined if a new category is added; update categoryDir to include a
default / unreachable branch that either throws an informative Error or uses a
never-type assertion to force a compile-time error (e.g., assertUnreachable) and
ensure the function always returns a string, referencing the categoryDir
function and the TokenCostResult['item']['category'] union so future new
variants fail loudly.

In `@apps/ccaudit/src/__tests__/fixtures/tmux-e2e.ts`:
- Around line 145-165: The waitForText function is redundantly calling stripAnsi
on the capture output even though capture already strips ANSI by default; update
waitForText to trust capture's ansi handling: when calling this.capture pass
ansi: opts.stripAnsi === false (so capture strips unless explicitly disabled),
then assign lastCapture = raw (no extra stripAnsi call) and only call stripAnsi
if capture was invoked with ansi: true; adjust logic around opts.stripAnsi and
the capture({ ansi }) call in waitForText accordingly so stripAnsi is not
applied twice.

In `@apps/ccaudit/src/__tests__/pagination-500.test.ts`:
- Around line 43-48: The test's upper bound is too loose: replace the ceiling
check that expects agentMatches.size ≤ 20 with a stricter check matching the
derived viewportHeight (14). Update the assertion that references agentMatches
(created via new Set(frame.match(/agent-\d{3}/g) ?? [])) so it uses
toBeLessThanOrEqual(14) instead of 20, and adjust or keep the comment noting
viewportHeight = max(8, 24-10) = 14 to reflect the invariant.

In `@apps/ccaudit/src/__tests__/safety-invariants-restore-roundtrip.test.ts`:
- Around line 105-113: The helper runGhost only forwards the env option to
runCcauditGhost, dropping any other options; change runGhost to accept an
additional opts (or options) parameter (e.g., opts?: Record<string, any> or the
actual runCcauditGhost options type), pass those options through to
runCcauditGhost (merging env if provided) instead of only sending { env }, and
preserve the existing behavior of ending stdin and returning
stdout/stderr/exitCode; update the signature and the call site(s) that use
runGhost accordingly so future options like timeout, cwd, stdin, or signal are
not ignored.

In `@apps/ccaudit/src/__tests__/tabbed-picker-bust-parity.test.ts`:
- Around line 148-154: The test currently silently skips when pickerTokensK is
null or 0 (the if block that logs and returns), which can mask tokenizer
regressions; replace that silent-skip with an explicit assertion that
pickerTokensK !== null && pickerTokensK > 0 (or only allow a skip when an
explicit env var like ALLOW_FIXTURE_SKIP is set), and if the assertion fails
fail the test (clean up by sending '\x03' to spawned.child and awaiting
spawned.done as before) instead of returning success; locate the conditional
around pickerTokensK (produced by buildManyGhostsFixture) and the cleanup using
sendKeys(spawned.child, ['\x03']) / spawned.child.stdin!.end() to implement this
change.

In `@apps/ccaudit/src/__tests__/tabbed-picker-help-overlay.test.ts`:
- Around line 113-121: The test captures beforeLen = stdoutBuf.length but never
uses it, and waitForMarker(...) is checking the full stdoutBuf for the
already-present "(Press ? or Esc to close)" marker, so the post-Space
synchronization is a no-op; fix by either removing the dead beforeLen and the
redundant wait/assert, or make the wait actually observe new output by using the
slice from beforeLen (e.g., pass a function that returns
stripAnsi(stdoutBuf).slice(beforeLen)) when calling waitForMarker after
sendKeys; update references to beforeLen, stdoutBuf, sendKeys, waitForMarker,
stripAnsi and spawned.child.exitCode accordingly so the test truly waits for the
post-Space render or removes the redundant checks.

In `@apps/ccaudit/src/__tests__/tabbed-picker-live-counter.test.ts`:
- Around line 173-175: The test currently accepts null as a valid exit code
which can mask hangs; update the assertions that check process termination (the
expect([...].includes(result.exitCode)) calls that follow
spawned.child.stdin!.end() and await spawned.done — e.g., the one assigning
const result = await spawned.done and using result.exitCode — to only accept [0,
130] instead of [0, 130, null]; apply the same tightening to the other identical
assertions in this test file (the other expect([...].includes(result.exitCode))
occurrences) so a hung/timeout-killed subprocess no longer passes the test.
- Around line 263-271: The test's regex in tabbed-picker-live-counter.test.ts
currently only checks that a numeric match exists and that the parsed integer is
finite; update the assertion after parsing (the `parsed` variable derived from
`match[1]`) to also assert parsed > 0 so the test fails if the counter is zero
or negative, ensuring the cross-tab sum is actually positive; keep the existing
regex and Number.isFinite check and simply add expect(parsed).toBeGreaterThan(0)
(or equivalent) after the parse step.

In `@apps/ccaudit/src/__tests__/tmux-e2e-manual-qa.test.ts`:
- Around line 230-248: The assertion that cleared.toContain('agent-081') is
flaky because it depends on picker cursor/scroll behavior after Esc; update the
test in tmux-e2e-manual-qa.test.ts to stop asserting the presence of a specific
row after clearing the filter and instead assert that the filter was cleared via
an invariant that doesn't depend on scroll position (for example, remove the
cleared.toContain('agent-081') check and replace it with a check against the tab
header text like asserting session.capture or session.waitForText contains
'AGENTS (0/550)', or simply rely on the existing
expect(current).not.toContain('Filtered: 10 of 550 visible') to prove the filter
was cleared). Ensure you update references to cleared/current/session
accordingly so the test still verifies the filter-clear behavior without
coupling to cursor position.
- Around line 35-43: The tmux availability IIFE (tmuxAvailable) currently
swallows errors when execFileSync('tmux', ['-V']) fails, which hides
misconfigured CI; update the catch block so that when optIn is true and
execFileSync throws you print a single stderr line (e.g., via console.error)
that includes a brief diagnostic message and the caught error (or error.message)
before returning false; keep the short-circuit for process.platform === 'win32'
and only emit the log when optIn is enabled and execFileSync fails so the test
matrix surfaces missing tmux, referencing tmuxAvailable, optIn, and
execFileSync.

In `@packages/internal/src/remediation/archive-move.ts`:
- Around line 90-102: The error message always labels failures as a "rename"
even when mkdirRecursive fails; update the logic in the function that calls
deps.mkdirRecursive and deps.renameFile so the failure message reflects which
operation failed (mkdir vs rename). Implement this by tracking the current
operation (e.g. set a local var op = 'mkdir' before await
deps.mkdirRecursive(...) and op = 'rename' before await deps.renameFile(...)) or
by splitting into two try/catch blocks, and use that operation name when
building the Result.fail payload (the reason/message returned from Result.fail
should include the correct operation and path), leaving the Result.succeed({
moved: true }) behavior unchanged.

In `@packages/internal/src/remediation/change-plan.ts`:
- Around line 312-340: Replace the brittle hand-constructed canonical ID strings
in the tests with calls to the canonicalItemId helper so the tests assert
behavior instead of a hardcoded format: in the Test 2 block that uses
makePlan3() and then constructs idA/idB, call canonicalItemId(...) with the same
category/scope/projectPath/name/path used to build those results; likewise in
Test 3, build idA1 and idM1 by calling canonicalItemId with the arguments
matching makeResult(...) and the mcp-server item's fields. Update the imports if
necessary and keep the rest of the assertions (filterChangePlan, counts,
savings) unchanged.

In `@packages/internal/src/remediation/purge.ts`:
- Around line 265-297: The two loops ("reclaim" and "drop") duplicate the same
outer try/catch, inner writer.writeOp error handling, appendedOps push and
summary updates; extract a helper (e.g., runOpWithJournal) that accepts the op,
a mutate function (the conditional unlink or noop), a function to build the
journal op (e.g., buildArchivePurgeOp or the reclaim equivalent), and an
onSuccess callback to update appendedOps and summary; inside the helper perform
the try { await mutate(); const journal = buildOp(...); try { await
writer.writeOp(journal); } catch (writeErr) { push to failures with
manifest_write_failed formatted exactly as now; return; }
appendedOps.push(journal); call onSuccess(); } catch (err) { push to failures
with existing error formatting; } then replace both loops to call
runOpWithJournal(...) with the appropriate mutate and onSuccess lambdas so the
manifest_write_failed formatting and push-to-failures logic remain centralized.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

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

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 00132340-b539-4ac3-960a-bc4f1a87a981

📥 Commits

Reviewing files that changed from the base of the PR and between 4c37c26 and c466f2b.

📒 Files selected for processing (39)
  • CHANGELOG.md
  • README.md
  • apps/ccaudit/scripts/bundle-size-check.mjs
  • apps/ccaudit/scripts/bundle-smoke-test.mjs
  • apps/ccaudit/src/__tests__/__fixtures__/restore-interactive/bust-2026-04-17T10-00-00-000Z-aaaa.jsonl
  • apps/ccaudit/src/__tests__/__fixtures__/restore-interactive/bust-2026-04-18T10-00-00-000Z-bbbb.jsonl
  • apps/ccaudit/src/__tests__/_test-helpers.ts
  • apps/ccaudit/src/__tests__/fixtures/ghost-500-items.ts
  • apps/ccaudit/src/__tests__/fixtures/manual-qa-followups.ts
  • apps/ccaudit/src/__tests__/fixtures/tmux-e2e.ts
  • apps/ccaudit/src/__tests__/pagination-500.test.ts
  • apps/ccaudit/src/__tests__/purge-archive.test.ts
  • apps/ccaudit/src/__tests__/restore-after-purge.test.ts
  • apps/ccaudit/src/__tests__/restore-command.test.ts
  • apps/ccaudit/src/__tests__/restore-flag-mutual-exclusion.test.ts
  • apps/ccaudit/src/__tests__/restore-interactive-memory-roundtrip.test.ts
  • apps/ccaudit/src/__tests__/restore-interactive-source-exists.test.ts
  • apps/ccaudit/src/__tests__/restore-json-envelope.test.ts
  • apps/ccaudit/src/__tests__/restore-list-skips-purge.test.ts
  • apps/ccaudit/src/__tests__/restore-stale-filter.test.ts
  • apps/ccaudit/src/__tests__/safety-invariants-restore-roundtrip.test.ts
  • apps/ccaudit/src/__tests__/tabbed-picker-bust-parity.test.ts
  • apps/ccaudit/src/__tests__/tabbed-picker-help-overlay.test.ts
  • apps/ccaudit/src/__tests__/tabbed-picker-live-counter.test.ts
  • apps/ccaudit/src/__tests__/tabbed-picker-protection.test.ts
  • apps/ccaudit/src/__tests__/tmux-e2e-manual-qa.test.ts
  • apps/ccaudit/src/cli/commands/purge-archive.ts
  • apps/ccaudit/src/cli/commands/restore.ts
  • docs/JSON-SCHEMA.md
  • packages/internal/src/remediation/archive-move.ts
  • packages/internal/src/remediation/atomic-write.ts
  • packages/internal/src/remediation/bust.ts
  • packages/internal/src/remediation/change-plan.ts
  • packages/internal/src/remediation/index.ts
  • packages/internal/src/remediation/purge.ts
  • packages/internal/src/remediation/reclaim.ts
  • packages/internal/src/remediation/restore.ts
  • packages/internal/src/scanner/_present-path.ts
  • packages/internal/src/scanner/scan-hooks.ts
✅ Files skipped from review due to trivial changes (1)
  • apps/ccaudit/src/tests/fixtures/restore-interactive/bust-2026-04-17T10-00-00-000Z-aaaa.jsonl
🚧 Files skipped from review as they are similar to previous changes (15)
  • apps/ccaudit/src/tests/restore-flag-mutual-exclusion.test.ts
  • apps/ccaudit/src/tests/restore-command.test.ts
  • apps/ccaudit/src/tests/restore-list-skips-purge.test.ts
  • apps/ccaudit/src/tests/restore-json-envelope.test.ts
  • apps/ccaudit/src/tests/restore-after-purge.test.ts
  • apps/ccaudit/src/tests/fixtures/restore-interactive/bust-2026-04-18T10-00-00-000Z-bbbb.jsonl
  • apps/ccaudit/src/tests/restore-stale-filter.test.ts
  • apps/ccaudit/src/tests/tabbed-picker-protection.test.ts
  • apps/ccaudit/scripts/bundle-size-check.mjs
  • CHANGELOG.md
  • apps/ccaudit/src/tests/fixtures/manual-qa-followups.ts
  • apps/ccaudit/src/tests/purge-archive.test.ts
  • packages/internal/src/remediation/index.ts
  • packages/internal/src/remediation/bust.ts
  • apps/ccaudit/src/tests/_test-helpers.ts

Comment thread apps/ccaudit/src/cli/commands/restore.ts Outdated
Comment thread docs/JSON-SCHEMA.md
Comment thread docs/JSON-SCHEMA.md
Comment on lines +106 to +115
> **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": <code>, ... }, "error": "..." }`.
> Without `--json`, the same message is written to stderr.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

MD028: blank line between two adjacent blockquotes (carried over from earlier review).

Static analysis flags line 110 with MD028 — the previous identical issue around line 143/147 was fixed, but the same pattern landed again here between the "Manifest casing exception" blockquote and the "Pre-dispatch validation envelope" blockquote. If they're meant to be one blockquote, drop the blank line; if they're meant to be two, separate them with non-blockquote content (e.g. a horizontal rule or a paragraph) so markdownlint doesn't see them as a split-quote.

♻️ Proposed fix (merge into one blockquote)
 > **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
+>
+> **Pre-dispatch validation envelope.** Restore preflight failures such as
 > mutually-exclusive flags, `CCAUDIT_NO_INTERACTIVE`, TTY refusal, no-match, or
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
> **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": <code>, ... }, "error": "..." }`.
> Without `--json`, the same message is written to stderr.
> **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": <code>, ... }, "error": "..." }`.
> Without `--json`, the same message is written to stderr.
🧰 Tools
🪛 markdownlint-cli2 (0.22.1)

[warning] 110-110: Blank line inside blockquote

(MD028, no-blanks-blockquote)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/JSON-SCHEMA.md` around lines 106 - 115, The two adjacent blockquotes
titled "Manifest casing exception." and "Pre-dispatch validation envelope." are
separated by a blank line which triggers MD028; either merge them into a single
blockquote or insert non-blockquote content between them. To fix, remove the
blank line so both paragraphs become one continuous blockquote (keeping the
exact text of both headers and bodies) or add a short paragraph or horizontal
rule between the two quoted sections to explicitly separate them; apply this
change to the blockquotes that begin with "Manifest casing exception." and
"Pre-dispatch validation envelope." in the diff.

Comment thread packages/internal/src/remediation/atomic-write.ts
Comment on lines +232 to +311
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`),
);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

cd packages/internal/src/remediation && wc -l purge.ts

Repository: fabio-dee/ccaudit

Length of output: 74


🏁 Script executed:

cd packages/internal/src/remediation && sed -n '230,320p' purge.ts

Repository: fabio-dee/ccaudit

Length of output: 3257


🏁 Script executed:

cd packages/internal/src/remediation && sed -n '1,50p' purge.ts

Repository: fabio-dee/ccaudit

Length of output: 2039


🏁 Script executed:

# Check test cases related to manifest write failures
cd packages/internal/src/remediation && sed -n '400,700p' purge.ts | grep -A 20 "manifest_write_failed\|writeOp"

Repository: fabio-dee/ccaudit

Length of output: 5250


🏁 Script executed:

# Look for any docstrings or comments about the all-failed behavior
cd packages/internal/src/remediation && grep -n "manifest-close\|all-failed\|all.*failed" purge.ts

Repository: fabio-dee/ccaudit

Length of output: 310


🏁 Script executed:

# Find the test for "all items failed" scenario
cd packages/internal/src/remediation && sed -n '680,750p' purge.ts

Repository: fabio-dee/ccaudit

Length of output: 3234


🏁 Script executed:

# Check if there are any tests specifically for manifest_write_failed scenarios
cd packages/internal/src/remediation && grep -n "manifest_write_failed" purge.ts

Repository: fabio-dee/ccaudit

Length of output: 380


🏁 Script executed:

# Look for the complete all-failed test and any related tests
cd packages/internal/src/remediation && sed -n '675,750p' purge.ts

Repository: fabio-dee/ccaudit

Length of output: 3397


🏁 Script executed:

# Find the test around line 834 that checks manifest_write_failed
cd packages/internal/src/remediation && sed -n '800,860p' purge.ts

Repository: fabio-dee/ccaudit

Length of output: 2370


🏁 Script executed:

# Search for NEW-C1 tests that specifically test writeOp failures
cd packages/internal/src/remediation && grep -B 10 -A 15 "manifest_write_failed" purge.ts | head -100

Repository: fabio-dee/ccaudit

Length of output: 2879


🏁 Script executed:

# Look for the complete test context around line 834
cd packages/internal/src/remediation && sed -n '820,870p' purge.ts

Repository: fabio-dee/ccaudit

Length of output: 2133


🏁 Script executed:

# Search for tests that specifically have all writeOp failures (not mutation failures)
cd packages/internal/src/remediation && grep -n "it\|describe" purge.ts | grep -i "write\|manifest" | head -20

Repository: fabio-dee/ccaudit

Length of output: 1490


🏁 Script executed:

# Look for any test that sets up writeOp to fail for all items
cd packages/internal/src/remediation && sed -n '750,850p' purge.ts | grep -B 5 -A 20 "writeOp.*throw\|writeOpFn"

Repository: fabio-dee/ccaudit

Length of output: 2696


🏁 Script executed:

# Check if there's a specific test name mentioning all items or all failures with writeOp
cd packages/internal/src/remediation && rg -A 10 "all.*failed.*writeOp|every.*writeOp.*fail" purge.ts

Repository: fabio-dee/ccaudit

Length of output: 43


🏁 Script executed:

# Get full context of the test that checks "2 out of 3 succeeded"
cd packages/internal/src/remediation && sed -n '795,855p' purge.ts

Repository: fabio-dee/ccaudit

Length of output: 2343


🏁 Script executed:

# Check if there's any test or documentation about the "all writeOp failures" scenario
cd packages/internal/src/remediation && sed -n '300,330p' purge.ts

Repository: fabio-dee/ccaudit

Length of output: 1159


🏁 Script executed:

# Look for any comments or documentation about the all-failed semantics
cd packages/internal/src/remediation && sed -n '160,230p' purge.ts

Repository: fabio-dee/ccaudit

Length of output: 2758


🏁 Script executed:

# Check if there's a test that explicitly tests all writeOp failures (not mutation failures)
cd packages/internal/src/remediation && rg -c "writeOp" purge.ts

Repository: fabio-dee/ccaudit

Length of output: 64


Clarify behavior when all writeOp calls fail after successful mutations.

The docstring states "Returns Result.err only when every requested item failed," but doesn't clarify whether "failed" includes manifest_write_failed (journal persist failure after disk mutation succeeded). The existing test at line 800 covers partial writeOp failures (2 succeed, 1 fails) and correctly returns Result.success. However, there is no explicit test for the case where all mutations succeed but all writeOp calls fail (e.g., ENOSPC on manifest fd while unlinks/moves succeed across different mounts). In that scenario, failures.length === totalRequested would trigger the all-failed branch, returning Result.fail with message "all N purge ops failed" despite disk mutations having succeeded and appendedOps / summary counters remaining at 0.

If this "journal failure suppresses disk success" semantics is intentional (audit-trail philosophy: "if not journaled, didn't happen"), document it explicitly in the docstring or add a test covering it. Otherwise, consider excluding manifest_write_failed entries from the all-failed gate, or distinguish the error message between "no disk effect" and "journal failed after mutation."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/internal/src/remediation/purge.ts` around lines 232 - 311, The
current all-failed check treats manifest_write_failed (journal persist failures
after disk mutations) the same as disk-level failures; update the
final-all-failed gate in the purge function so it ignores manifest_write_failed
entries when deciding to return Result.fail: compute failureCount =
failures.filter(f => !f.reason?.startsWith('manifest_write_failed')).length and
use that against totalRequested (keep the existing
closePurgeManifestWriter(writer, null) behavior and existing failures array), so
that if only manifest_write_failed entries exist (disk mutations succeeded) the
function returns Result.success while still reporting the manifest failures in
failures[].

Comment thread packages/internal/src/remediation/restore.ts
Comment thread README.md
Comment thread README.md
@fabio-dee
fabio-dee merged commit b26ae7e into main Apr 27, 2026
8 checks passed
@fabio-dee
fabio-dee deleted the release/v1.5.0 branch April 27, 2026 12:07
@fabio-dee
fabio-dee restored the release/v1.5.0 branch April 27, 2026 12:10
@fabio-dee
fabio-dee deleted the release/v1.5.0 branch April 27, 2026 12:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant