From 442db3c66c8805d4e2e4691f176e3ea646483328 Mon Sep 17 00:00:00 2001 From: Fabio-D <> Date: Mon, 27 Apr 2026 15:37:32 +0200 Subject: [PATCH 01/23] docs(11-01): swap negative-list domain folder to data-engineering Replace the example domain-folder name in README.md's negative-list narrative without changing scanner behavior. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index ca1d9bb..074f46c 100644 --- a/README.md +++ b/README.md @@ -618,7 +618,7 @@ tier that matches wins; later tiers do not override earlier ones. > **Critical negative finding.** Folder names like `engineering/`, `design/`, > `marketing/`, `testing/`, `sales/`, `integrations/`, `strategy/`, > `project-management/`, `support/`, `paid-media/`, `spatial-computing/`, -> `examples/`, `scripts/`, `product/`, `specialized/`, `game-development/`, +> `examples/`, `scripts/`, `product/`, `specialized/`, `data-engineering/`, > `agents/`, and `skills/` are **domain organisation** folders, not frameworks. > ccaudit refuses to group them, even if they happen to cluster by filename. > This is enforced twice: once by gating folder-segment matches through the From 2534c3ae49ba78e6646dc96c96b8ab44814aa9df Mon Sep 17 00:00:00 2001 From: Fabio-D <> Date: Mon, 27 Apr 2026 15:37:57 +0200 Subject: [PATCH 02/23] docs(11-01): rewrite v1.6 roadmap section as abstract API contract prose - Replace concrete subcommand examples with stability guarantees - Preserve link to docs/JSON-SCHEMA.md and apiVersion mention - Removes release-blocker leaks ahead of v1.5.1 --- README.md | 22 ++++++---------------- 1 file changed, 6 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 074f46c..968e2f4 100644 --- a/README.md +++ b/README.md @@ -701,21 +701,10 @@ context, lets you multi-select with the same keybindings as the archive picker, and signals selected processes (TERM by default, KILL on confirm). For when one rogue session is pinning context across half your machine. -### v1.6 game CLI API contract +### v1.6 stable CLI/JSON API contract -A separate, stable JSON CLI surface scoped to embedding tools — initial -consumer is the standalone **ccaudit Ghost Town** game. The contract: - -```bash -ccaudit game scan --json -ccaudit game bust --ids --json -ccaudit game vault --json -ccaudit game restore --ids --json -ccaudit game agent-context --id --json -ccaudit game dry-run --ids --json # optional -``` - -Stability guarantees external consumers will rely on: +v1.6 will freeze a public JSON CLI surface scoped to embedding tools and +downstream consumers. Stability guarantees external consumers will rely on: - TUI behavior is **not** part of the contract. - Human-rendered tables are **not** part of the contract. @@ -724,8 +713,9 @@ Stability guarantees external consumers will rely on: - Path-shaped canonical IDs are not public unless explicitly declared. The envelope follows the existing `--json` shape (see -[docs/JSON-SCHEMA.md](./docs/JSON-SCHEMA.md)) with a `command: "game.*"` and -`apiVersion` field for forward compatibility. +[docs/JSON-SCHEMA.md](./docs/JSON-SCHEMA.md)) with an `apiVersion` field for +forward compatibility. Subcommand names and the full contract surface will land +in the v1.6 release notes. ### A new frontend — surprise From a4471769648dd2e18028a1548520a1e4b9aeccb2 Mon Sep 17 00:00:00 2001 From: Fabio-D <> Date: Mon, 27 Apr 2026 15:40:32 +0200 Subject: [PATCH 03/23] docs(11-02): document audit-trail invariant in purge.ts - Extend top-of-file block-comment with "Audit-trail invariant" subsection - Formalize the philosophical contract: "if it isn't journaled, it didn't happen" - Document why manifest_write_failed: failures count identically to disk-mutation failures in the all-failed gate - No production code changes; documentation-only addition --- packages/internal/src/remediation/purge.ts | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/packages/internal/src/remediation/purge.ts b/packages/internal/src/remediation/purge.ts index 79c07a4..17e72e4 100644 --- a/packages/internal/src/remediation/purge.ts +++ b/packages/internal/src/remediation/purge.ts @@ -17,6 +17,27 @@ // - Every successful mutation produces a single append-only archive_purge op. // - moveArchiveToSource refuses to overwrite existing source (helper // preserves the reclaim INV). +// +// Audit-trail invariant +// --------------------- +// The append-only journal at ~/.claude/ccaudit/manifests/ is the source of +// truth for every mutation purge performs. A purge operation is only +// "complete" when both the disk mutation AND its corresponding op record have +// landed. +// +// If the disk mutation succeeds but writer.writeOp(...) subsequently throws, +// the operation is treated as a FULL FAILURE — the failure reason is +// prefixed with `manifest_write_failed:` and the op is counted in the +// failure tally, not the success tally. The principle: "if it isn't +// journaled, it didn't happen" — restore / reclaim / a future purge cannot +// observe an undocumented mutation, so for the user-visible audit surface +// the mutation effectively did not occur. +// +// That is why the all-failed gate counts manifest_write_failed: reasons +// identically to disk-mutation failures. Carving manifest_write_failed: out +// of the all-failed gate would mean reporting partial success for a state +// the audit trail cannot describe — the exact failure mode this invariant +// forbids. import type { ArchiveOp, ArchivePurgeOp, ManifestOp, ManifestWriter } from './manifest.ts'; import { buildArchivePurgeOp, closePurgeManifestWriter } from './manifest.ts'; From 62b0a143450ce8a2bd60e9c3dcdef6329ef45b02 Mon Sep 17 00:00:00 2001 From: Fabio-D <> Date: Mon, 27 Apr 2026 15:44:22 +0200 Subject: [PATCH 04/23] test(11-02): lock audit-trail invariant with manifest_write_failed: tests - Add in-source tests asserting writeOp throw produces a failure record carrying the manifest_write_failed: prefix and counted in the failure tally (not success tally) - Add companion single-op test confirming all-failed gate fires identically for manifest_write_failed: as for disk-mutation failures - Locks Option (a): carving manifest_write_failed: out of the all-failed gate is now a regression --- packages/internal/src/remediation/purge.ts | 106 +++++++++++++++++++++ 1 file changed, 106 insertions(+) diff --git a/packages/internal/src/remediation/purge.ts b/packages/internal/src/remediation/purge.ts index 17e72e4..5818ff6 100644 --- a/packages/internal/src/remediation/purge.ts +++ b/packages/internal/src/remediation/purge.ts @@ -884,6 +884,112 @@ if (import.meta.vitest) { } }); + it('audit-trail invariant — disk mutation succeeds, writeOp throws → failure carries manifest_write_failed: prefix and is counted in failure tally (not success tally)', async () => { + // Locks the "if it isn't journaled, it didn't happen" contract + // documented in the top-of-file block comment. Two-op plan so the + // success channel stays open and we can observe BOTH the prefix on + // the failure record AND the tally separation. The single-op + // all-failed companion case is covered immediately below. + const goodOp = archiveOp({ + op_id: 'audit-trail-good', + archive_path: '/a/audit-good', + source_path: '/s/audit-good', + }); + const badOp = archiveOp({ + op_id: 'audit-trail-bad', + archive_path: '/a/audit-bad', + source_path: '/s/audit-bad', + }); + const unlinkFile = vi.fn(async () => undefined); + + let writeOpCallCount = 0; + const writeOpFn = vi.fn(async () => { + writeOpCallCount += 1; + // First call (goodOp) succeeds; second call (badOp) throws. + if (writeOpCallCount === 2) throw new Error('synthetic writer failure'); + }); + const mockWriter = { + writeOp: writeOpFn, + close: vi.fn(async () => undefined), + filePath: '/fake/manifests/purge-audit-trail.jsonl', + elapsedMs: 0, + } as unknown as ManifestWriter; + const createPurgeManifestWriter = vi.fn(async () => ({ + writer: mockWriter, + path: '/fake/manifests/purge-audit-trail.jsonl', + })); + const plan: PurgePlan = { + reclaim: [], + drop: [ + { op: goodOp, reason: 'source_occupied' }, + { op: badOp, reason: 'source_occupied' }, + ], + skip: [], + }; + + const result = await executePurge(plan, fakeDeps({ unlinkFile, createPurgeManifestWriter }), { + dryRun: false, + }); + + // Both disk mutations were attempted (the loop continues after writeOp throw). + expect(unlinkFile).toHaveBeenCalledTimes(2); + // writeOp was invoked twice (once per successful unlink). + expect(writeOpFn).toHaveBeenCalledTimes(2); + + expect(Result.isSuccess(result)).toBe(true); + if (Result.isSuccess(result)) { + // Tally separation: the writeOp-throw op counts in failures, NOT in successes. + expect(result.value.summary.purgedCount).toBe(1); + expect(result.value.failures).toHaveLength(1); + expect(result.value.failures[0]!.op_id).toBe('audit-trail-bad'); + // Exact prefix (case-sensitive, includes the trailing colon). + expect(result.value.failures[0]!.reason.startsWith('manifest_write_failed:')).toBe(true); + // appendedOps reflects only the journaled mutation. + expect(result.value.appendedOps).toHaveLength(1); + expect(result.value.appendedOps[0]!.original_op_id).toBe('audit-trail-good'); + } + }); + + it('audit-trail invariant — single-op plan: writeOp throw fires the all-failed gate (Result.fail)', async () => { + // Companion to the prefix/tally test above. With one op, a + // manifest_write_failed: failure must trip the all-failed gate + // identically to a disk-mutation failure — this is the carve-out + // the invariant forbids. + const op = archiveOp({ op_id: 'audit-trail-solo' }); + const unlinkFile = vi.fn(async () => undefined); + const writeOpFn = vi.fn(async () => { + throw new Error('synthetic writer failure'); + }); + const mockWriter = { + writeOp: writeOpFn, + close: vi.fn(async () => undefined), + filePath: '/fake/manifests/purge-audit-solo.jsonl', + elapsedMs: 0, + } as unknown as ManifestWriter; + const createPurgeManifestWriter = vi.fn(async () => ({ + writer: mockWriter, + path: '/fake/manifests/purge-audit-solo.jsonl', + })); + const plan: PurgePlan = { + reclaim: [], + drop: [{ op, reason: 'source_occupied' }], + skip: [], + }; + + const result = await executePurge(plan, fakeDeps({ unlinkFile, createPurgeManifestWriter }), { + dryRun: false, + }); + + expect(unlinkFile).toHaveBeenCalledTimes(1); + expect(writeOpFn).toHaveBeenCalledTimes(1); + expect(Result.isFailure(result)).toBe(true); + if (Result.isFailure(result)) { + // The all-failed gate counts manifest_write_failed: identically to + // disk-mutation failures (the invariant we are locking). + expect(result.error.message).toMatch(/all 1 purge ops failed/); + } + }); + it('empty plan does not open the writer (no orphan empty manifest file)', async () => { const createPurgeManifestWriter = vi.fn(async () => ({ writer: makeMockWriter().writer, From aa9067853f9cc970fa597468672ff6252a459c59 Mon Sep 17 00:00:00 2001 From: Fabio-D <> Date: Mon, 27 Apr 2026 15:41:31 +0200 Subject: [PATCH 05/23] fix(_test-helpers): replace dead void-killed guard with real return guard; sync graceMs doc --- apps/ccaudit/src/__tests__/_test-helpers.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/apps/ccaudit/src/__tests__/_test-helpers.ts b/apps/ccaudit/src/__tests__/_test-helpers.ts index 19c82fc..5388ca0 100644 --- a/apps/ccaudit/src/__tests__/_test-helpers.ts +++ b/apps/ccaudit/src/__tests__/_test-helpers.ts @@ -9,12 +9,12 @@ import { spawn, type ChildProcess } from 'node:child_process'; /** * Wait for the spawned picker subprocess to reach its blocking read loop. - * Polls until the child exits (error/crash path), or until maxWaitMs elapses, + * Polls until the child exits (error/crash path), or until graceMs elapses, * with exponential backoff — whichever comes first. Then adds a final grace * delay so the TUI is ready for key input. * * Typical path: child never exits during the poll window, loop runs until - * maxWaitMs, then a 300ms grace is added for the render cycle to settle. + * graceMs, then a 300ms grace is added for the render cycle to settle. */ export async function waitForPicker(child: ChildProcess, graceMs = 300): Promise { // Short poll to detect early crashes (child exiting prematurely). @@ -257,8 +257,9 @@ export function runCcauditGhost( }); child.on('close', (code: number | null) => { clearTimeout(timer); - // Resolve in both killed and non-killed cases so callers always get output. - void killed; + // If the process was already killed (timeout path), do not call + // resolve(...) — the timeout already rejected the promise. + if (killed) return; resolve({ stdout, stderr, exitCode: code, durationMs: Date.now() - start }); }); }); From cda2cacea05e77e4d5efbfce71268ee9a26e3d94 Mon Sep 17 00:00:00 2001 From: Fabio-D <> Date: Mon, 27 Apr 2026 15:42:49 +0200 Subject: [PATCH 06/23] docs(restore-json-envelope.test): sync header docstring with actual envelope shape --- .../__tests__/restore-json-envelope.test.ts | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/apps/ccaudit/src/__tests__/restore-json-envelope.test.ts b/apps/ccaudit/src/__tests__/restore-json-envelope.test.ts index e3ca7bd..52ae44a 100644 --- a/apps/ccaudit/src/__tests__/restore-json-envelope.test.ts +++ b/apps/ccaudit/src/__tests__/restore-json-envelope.test.ts @@ -2,15 +2,19 @@ * Phase 08 Plan 06 — `ccaudit restore --all-matching --json` * envelope contract (D8-16, D8-17). * - * Asserts the v1.5 additive fields land in the envelope: - * - `meta.command === 'restore'`, `meta.exitCode === 0` - * - `status === 'success'` - * - `selectionFilter.mode === 'subset'` - * - `selectionFilter.ids` has exactly 2 entries (pencil-dev + pencil-review) - * - `Array.isArray(skipped)` is true (empty on the happy path, but present) + * Restore JSON envelope shape — what the tests below assert: * - * Source-exists skip + skipped[] contents are covered by - * restore-interactive-source-exists.test.ts. + * - meta.command // e.g. "restore" + * - meta.exitCode // camelCase number + * - status // top-level enum string ("success" on happy path) + * - selectionFilter // top-level object describing applied filter + * // .mode === "subset" + * // .ids has exactly 2 entries (pencil-dev + pencil-review) + * - skipped[] // top-level array of items skipped at restore time + * // (empty on the happy path, but present) + * + * See docs/JSON-SCHEMA.md for the full envelope contract. Source-exists skip + * + skipped[] contents are covered by restore-interactive-source-exists.test.ts. */ import { describe, it, expect, beforeAll, beforeEach, afterEach } from 'vitest'; import { existsSync } from 'node:fs'; From 7c71af2b2f0b64ee479cc1002eaa2fc5c905e931 Mon Sep 17 00:00:00 2001 From: Fabio-D <> Date: Mon, 27 Apr 2026 15:44:00 +0200 Subject: [PATCH 07/23] =?UTF-8?q?refactor(tmux-e2e):=20drop=20redundant=20?= =?UTF-8?q?stripAnsi(raw)=20=E2=80=94=20already=20stripped=20internally?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/ccaudit/src/__tests__/fixtures/tmux-e2e.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/ccaudit/src/__tests__/fixtures/tmux-e2e.ts b/apps/ccaudit/src/__tests__/fixtures/tmux-e2e.ts index e2018e8..b25dccc 100644 --- a/apps/ccaudit/src/__tests__/fixtures/tmux-e2e.ts +++ b/apps/ccaudit/src/__tests__/fixtures/tmux-e2e.ts @@ -152,7 +152,10 @@ export class TmuxE2ESession { startLine: opts.startLine ?? -200, ansi: opts.stripAnsi === false, }); - lastCapture = opts.stripAnsi === false ? raw : stripAnsi(raw); + // capture() already strips ANSI internally when ansi !== true, and when + // opts.stripAnsi === false we explicitly want the raw bytes. Either way + // `raw` is in the desired shape — no second strip needed. + lastCapture = raw; const matched = typeof needle === 'string' ? lastCapture.includes(needle) : needle.test(lastCapture); if (matched) return lastCapture; From 5a4e7204829bfa352e776fa9407e65e25f0d1a27 Mon Sep 17 00:00:00 2001 From: Fabio-D <> Date: Mon, 27 Apr 2026 15:45:07 +0200 Subject: [PATCH 08/23] refactor(restore-corrupt-manifest.test): replace non-null assertion with explicit narrowing --- .../ccaudit/src/__tests__/restore-corrupt-manifest.test.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/ccaudit/src/__tests__/restore-corrupt-manifest.test.ts b/apps/ccaudit/src/__tests__/restore-corrupt-manifest.test.ts index fdaa52f..830a0b8 100644 --- a/apps/ccaudit/src/__tests__/restore-corrupt-manifest.test.ts +++ b/apps/ccaudit/src/__tests__/restore-corrupt-manifest.test.ts @@ -87,12 +87,15 @@ describe.skipIf(process.platform === 'win32')( // stdout must be a parseable JSON object (structured envelope or error envelope) const stdout = r.stdout.trim(); expect(stdout.length, 'stdout must not be empty for --json').toBeGreaterThan(0); - let parsed: Record; + let parsed: Record | null = null; expect(() => { parsed = JSON.parse(stdout) as Record; }, `stdout must be valid JSON, got:\n${stdout}`).not.toThrow(); + if (parsed === null) { + throw new Error('expected parsed envelope to be non-null at this point'); + } // The envelope must have a meta block (standard ccaudit JSON envelope shape) - expect(parsed!).toHaveProperty('meta'); + expect(parsed).toHaveProperty('meta'); // Must not be a raw stack trace in stdout expect(stdout).not.toContain('at Object.'); }); From cb8cf128fa92b3f51822ae6046213235d595c82b Mon Sep 17 00:00:00 2001 From: Fabio-D <> Date: Mon, 27 Apr 2026 15:46:29 +0200 Subject: [PATCH 09/23] fix(_force-partial-banner): normalize space concatenation; lock with tightening test --- .../terminal/src/tui/_force-partial-banner.ts | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/packages/terminal/src/tui/_force-partial-banner.ts b/packages/terminal/src/tui/_force-partial-banner.ts index 325625f..c9f24fa 100644 --- a/packages/terminal/src/tui/_force-partial-banner.ts +++ b/packages/terminal/src/tui/_force-partial-banner.ts @@ -20,7 +20,7 @@ const BASE_TEXT = '--force-partial active: framework protection DISABLED. ' + 'Partial framework splits may corrupt dependent setups.'; -const ZERO_PROTECTED_SUFFIX = ' (no protected items in this scan)'; +const ZERO_PROTECTED_SUFFIX = '(no protected items in this scan)'; export interface RenderForcePartialBannerOptions { active: boolean; @@ -37,7 +37,7 @@ export function renderForcePartialBanner(opts: RenderForcePartialBannerOptions): if (!opts.active) return ''; const glyph = opts.ascii ? '!' : '⚠'; const suffix = opts.protectedCount === 0 ? ZERO_PROTECTED_SUFFIX : ''; - return `${glyph} ${BASE_TEXT}${suffix}`; + return `${glyph} ${BASE_TEXT}${suffix ? ' ' + suffix : ''}`; } export interface BannerHeightOptions { @@ -103,6 +103,18 @@ if (import.meta.vitest) { expect(out.endsWith('(no protected items in this scan)')).toBe(true); }); + it('joins ZERO_PROTECTED_SUFFIX with exactly one space (no double-space drift)', () => { + // Tightening test (B6): the joining whitespace between BASE_TEXT and the + // suffix must be exactly one character. Asserts that neither BASE_TEXT + // nor the suffix carries an embedded leading/trailing space that would + // double the gap. + const out = renderForcePartialBanner({ active: true, protectedCount: 0, ascii: false }); + const tail = out.split(BASE_TEXT)[1]; + expect(tail).toBe(' (no protected items in this scan)'); + // No "BASE_TEXT suffix" double-space anywhere. + expect(out).not.toContain(' (no protected'); + }); + it('contains no ANSI escape sequences (picker adds color at render time)', () => { const out = renderForcePartialBanner({ active: true, protectedCount: 1, ascii: false }); // eslint-disable-next-line no-control-regex From e5f46ac3bae99ff28baf3526eb745191c8474574 Mon Sep 17 00:00:00 2001 From: Fabio-D <> Date: Mon, 27 Apr 2026 15:47:48 +0200 Subject: [PATCH 10/23] test(scan-memory): lock Windows-path normalization (A5) --- packages/internal/src/scanner/scan-memory.ts | 45 ++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/packages/internal/src/scanner/scan-memory.ts b/packages/internal/src/scanner/scan-memory.ts index 9b86fb9..c09e991 100644 --- a/packages/internal/src/scanner/scan-memory.ts +++ b/packages/internal/src/scanner/scan-memory.ts @@ -537,6 +537,51 @@ if (import.meta.vitest) { expect(result[0]!.importDepth).toBeUndefined(); }); + // ── A5: Windows-shape path normalization in import-chain row names ───── + // Locks the backslash→forward-slash normalization at scanImportChain + // (`path.relative(...).replace(/\\/g, '/')`). Production code is correct; + // these tests prevent silent regression if someone reorders the chain or + // drops the `.replace()`. + describe('Windows-path normalization in import-chain row names (A5)', () => { + it('emits forward slashes in the name even when the imported file lives in a nested subdirectory', async () => { + // Build: projRoot/CLAUDE.md → @sub/dir/child.md + // The relative path computed via path.relative() on POSIX is already + // `sub/dir/child.md`. The .replace(/\\/g, '/') pass is a no-op on + // POSIX but the *output* shape is what we lock here: callers must + // be able to assume forward slashes in the name field. + const projPath = path.join(tmpDir3, 'win-shape-proj'); + const subDir = path.join(projPath, 'sub', 'dir'); + await mkdir(subDir, { recursive: true }); + const child = path.join(subDir, 'child.md'); + await writeFile(child, '# Child'); + const root = path.join(projPath, 'CLAUDE.md'); + await writeFile(root, '@sub/dir/child.md\n# Root'); + + const result = await scanMemoryFiles( + { legacy: path.join(tmpDir3, 'legacy'), xdg: path.join(tmpDir3, 'xdg') }, + [projPath], + ); + const chainItem = result.find((r) => r.importDepth !== undefined); + expect(chainItem, 'expected a chain row for the imported child').toBeDefined(); + // Forward slashes only — no backslashes leak into the name. + expect(chainItem!.name).toBe('CLAUDE.md @ sub/dir/child.md'); + expect(chainItem!.name).not.toMatch(/\\/); + }); + + it('the .replace(/\\\\/g, "/") step turns a synthetic backslash-bearing input into forward slashes', () => { + // Direct sanity check on the normalization step, isolated from filesystem + // walking. Mirrors the production expression at scanImportChain. + const winShape = 'C:\\Users\\me\\.claude\\agents\\foo.md'; + expect(winShape.replace(/\\/g, '/')).toBe('C:/Users/me/.claude/agents/foo.md'); + + const posixShape = '/home/me/.claude/agents/foo.md'; + expect(posixShape.replace(/\\/g, '/')).toBe('/home/me/.claude/agents/foo.md'); + + const mixed = 'C:\\Users/me\\.claude/agents\\foo.md'; + expect(mixed.replace(/\\/g, '/')).toBe('C:/Users/me/.claude/agents/foo.md'); + }); + }); + it('import inside fenced code block is NOT followed', async () => { const legacyDir = path.join(tmpDir3, 'legacy'); await mkdir(legacyDir, { recursive: true }); From 2817dc2ca56d0c64226b9d6188f55668cac2dae2 Mon Sep 17 00:00:00 2001 From: Fabio-D <> Date: Mon, 27 Apr 2026 15:49:35 +0200 Subject: [PATCH 11/23] refactor(change-plan): replace canonical-ID literals with canonicalItemId(...) calls (A3) --- .../internal/src/remediation/change-plan.ts | 47 +++++++++++++++---- 1 file changed, 39 insertions(+), 8 deletions(-) diff --git a/packages/internal/src/remediation/change-plan.ts b/packages/internal/src/remediation/change-plan.ts index 39a9093..d40ea77 100644 --- a/packages/internal/src/remediation/change-plan.ts +++ b/packages/internal/src/remediation/change-plan.ts @@ -311,9 +311,22 @@ if (import.meta.vitest) { 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`; + // Build canonical ids via the helper so the test cannot drift if the + // canonical-id format string changes (A3 — re-use `canonicalItemId`). + const idA = canonicalItemId({ + name: 'agentA', + path: '/tmp/agentA', + scope: 'global', + category: 'agent', + projectPath: null, + }); + const idB = canonicalItemId({ + name: 'agentB', + path: '/tmp/agentB', + scope: 'global', + category: 'agent', + projectPath: null, + }); const result = filterChangePlan(plan, new Set([idA, idB])); expect(result.archive).toHaveLength(2); expect(result.disable).toHaveLength(0); @@ -326,10 +339,22 @@ if (import.meta.vitest) { makeResult({ category: 'agent', tier: 'definite-ghost', name: 'a2', tokens: 200 }), makeResult({ category: 'mcp-server', tier: 'definite-ghost', name: 'm1', tokens: 500 }), ]); - const idA1 = `agent|global||/tmp/a1`; - // mcp-server canonical id: mcp-server|scope|projectPath|name|path - // makeResult produces: name='m1', path='/tmp/m1', scope='global', projectPath=null - const idM1 = `mcp-server|global||m1|/tmp/m1`; + // A3: re-use `canonicalItemId` so the canonical-id shape is sourced from + // the helper rather than mirrored as a literal string here. + const idA1 = canonicalItemId({ + name: 'a1', + path: '/tmp/a1', + scope: 'global', + category: 'agent', + projectPath: null, + }); + const idM1 = canonicalItemId({ + name: 'm1', + path: '/tmp/m1', + scope: 'global', + category: 'mcp-server', + projectPath: null, + }); const result = filterChangePlan(plan, new Set([idA1, idM1])); // counts: 1 agent + 1 mcp expect(result.counts.agents).toBe(1); @@ -351,7 +376,13 @@ if (import.meta.vitest) { it('Test 5: unknown ids in the set are silently ignored (no throw)', () => { const plan = makePlan3(); - const unknownId = 'agent|global||/nonexistent/path'; + const unknownId = canonicalItemId({ + name: 'nonexistent', + path: '/nonexistent/path', + scope: 'global', + category: 'agent', + projectPath: null, + }); // Should not throw; the unknown id just matches nothing expect(() => filterChangePlan(plan, new Set([unknownId]))).not.toThrow(); const result = filterChangePlan(plan, new Set([unknownId])); From 3261410a3fc49cef688df9194391f27d744ad755 Mon Sep 17 00:00:00 2001 From: Fabio-D <> Date: Mon, 27 Apr 2026 15:50:56 +0200 Subject: [PATCH 12/23] docs(_glyphs): clarify NO_COLOR honors no-color.org spec (non-empty value) --- packages/terminal/src/tui/_glyphs.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/terminal/src/tui/_glyphs.ts b/packages/terminal/src/tui/_glyphs.ts index 3999d82..d61fbcd 100644 --- a/packages/terminal/src/tui/_glyphs.ts +++ b/packages/terminal/src/tui/_glyphs.ts @@ -8,7 +8,7 @@ * * Triggers for the ASCII fallback (first match wins): * 1. `CCAUDIT_ASCII_ONLY=1` env var - * 2. `NO_COLOR` env var set (any value; convention is truthy-presence) + * 2. `NO_COLOR` env var set to a non-empty value (per no-color.org spec) * 3. `TERM=dumb` * 4. `opts.noColor === true` (mirrors the --no-color CLI flag at call sites) * @@ -69,9 +69,10 @@ export function resolveGlyphSet(env: NodeJS.ProcessEnv, opts?: ResolveGlyphSetOp export function shouldUseAsciiGlyphs(env: NodeJS.ProcessEnv, opts?: ResolveGlyphSetOpts): boolean { if (opts?.noColor === true) return true; if (env['CCAUDIT_ASCII_ONLY'] === '1') return true; - // NO_COLOR convention: any non-empty value => suppress color (and thus - // glyphs that rely on color for distinction are still safe — we switch - // to ASCII so they're distinguishable structurally). + // NO_COLOR is honored when set to a non-empty value (per no-color.org spec). + // Empty string is NOT "set" — see the dedicated test below. When set, we + // switch to ASCII so glyphs remain distinguishable structurally even with + // color suppressed. // https://no-color.org/ const noColor = env['NO_COLOR']; if (typeof noColor === 'string' && noColor !== '') return true; From 7216f136cbb872ac486a0c8e37153af847240cd6 Mon Sep 17 00:00:00 2001 From: Fabio-D <> Date: Mon, 27 Apr 2026 15:52:11 +0200 Subject: [PATCH 13/23] docs(JSON-SCHEMA): fix MD028 adjacent-blockquote at L126-128 (A2 + C5) Inserted '---' separator between the manifest-casing exception blockquote and the pre-dispatch validation envelope blockquote. Full-file scan found no additional MD028 instances. --- docs/JSON-SCHEMA.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/JSON-SCHEMA.md b/docs/JSON-SCHEMA.md index 8a9883a..bf6e093 100644 --- a/docs/JSON-SCHEMA.md +++ b/docs/JSON-SCHEMA.md @@ -125,6 +125,8 @@ fields surfacing the subset-restore surface landed in v1.5: > 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: From b702f424c43e7b39e20c24a6cbe199b7cd99691e Mon Sep 17 00:00:00 2001 From: Fabio-D <> Date: Mon, 27 Apr 2026 15:53:24 +0200 Subject: [PATCH 14/23] fix(tables/change-plan): align commands-row arrow with padEnd(8) (C1) --- packages/terminal/src/tables/change-plan.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/terminal/src/tables/change-plan.ts b/packages/terminal/src/tables/change-plan.ts index d9f4b4a..8fae2d9 100644 --- a/packages/terminal/src/tables/change-plan.ts +++ b/packages/terminal/src/tables/change-plan.ts @@ -74,17 +74,17 @@ export function renderChangePlan(plan: ChangePlan, opts?: ChangePlanRenderOption lines.push(colorize.bold('Will ARCHIVE (reversible via `ccaudit restore `):')); 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/`, ); } lines.push(''); From 9b61d906cb26d9f5258b770d0c071610e6619ae6 Mon Sep 17 00:00:00 2001 From: Fabio-D <> Date: Mon, 27 Apr 2026 15:54:58 +0200 Subject: [PATCH 15/23] fix(bundle-size-check): format budget in error via formatBytes(); finite-number guard already present (C2) --- apps/ccaudit/scripts/bundle-size-check.mjs | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/apps/ccaudit/scripts/bundle-size-check.mjs b/apps/ccaudit/scripts/bundle-size-check.mjs index 285707f..c1177d3 100644 --- a/apps/ccaudit/scripts/bundle-size-check.mjs +++ b/apps/ccaudit/scripts/bundle-size-check.mjs @@ -13,6 +13,18 @@ const distPath = resolve(scriptsDir, '../dist/index.js'); const baselinePath = resolve(scriptsDir, 'bundle-baseline.txt'); const BUDGET_BYTES = 15 * 1024; // 15360 bytes per D-04 +/** Format a byte count as a short human string (e.g. 15360 -> 15 KB). */ +function formatBytes(bytes) { + if (!Number.isFinite(bytes)) return `${bytes}B`; + if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + if (bytes >= 1024) { + const kb = bytes / 1024; + // Prefer integer KB when exact; one decimal otherwise. + return Number.isInteger(kb) ? `${kb} KB` : `${kb.toFixed(1)} KB`; + } + return `${bytes} B`; +} + if (!existsSync(distPath)) { console.error(`[bundle-size] FAIL: dist/index.js not found at ${distPath}`); console.error('[bundle-size] Run `pnpm -w build` first.'); @@ -44,7 +56,9 @@ console.log( ); if (delta > BUDGET_BYTES) { - console.error(`[bundle-size] FAIL: delta exceeds 15 KB budget (${delta} > ${BUDGET_BYTES})`); + console.error( + `[bundle-size] FAIL: delta exceeds ${formatBytes(BUDGET_BYTES)} budget (${delta} > ${BUDGET_BYTES})`, + ); process.exit(1); } From 4e05513564fb8902c96323255c32a5036c38fc22 Mon Sep 17 00:00:00 2001 From: Fabio-D <> Date: Mon, 27 Apr 2026 15:56:15 +0200 Subject: [PATCH 16/23] docs(pagination-500.test): enumerate actual tests in file header (C4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Note: the file contains 4 it() cases, not 5 as the design source assumed. The header now mirrors reality — tests are the source of truth. --- apps/ccaudit/src/__tests__/pagination-500.test.ts | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/apps/ccaudit/src/__tests__/pagination-500.test.ts b/apps/ccaudit/src/__tests__/pagination-500.test.ts index e8c2940..2655490 100644 --- a/apps/ccaudit/src/__tests__/pagination-500.test.ts +++ b/apps/ccaudit/src/__tests__/pagination-500.test.ts @@ -7,13 +7,12 @@ * keystroke decoding — which existing Phase 3.1 / 5 tests already * cover end-to-end. * - * Asserts: - * 1. With 500 ghosts and a bounded viewport, the rendered frame contains - * only a viewport-sized slice of agent rows (no terminal overflow). - * 2. End jumps cursor to last row; an above-indicator is visible. - * 3. The applyScroll reducer round-trips cursor position across filter - * on/off and clamps when the row set narrows below the saved cursor. - * 4. Toggling many rows keeps the rendered frame bounded. + * Asserts (file-header inventory of the actual `it(...)` cases below): + * 1. renders only a viewport-sized slice on a 500-item tab (no overflow) + * 2. End jumps cursor to last row; above-indicator rendered + * 3. applyScroll reducer round-trips cursor across filter on/off and + * clamps on narrowing + * 4. toggling across 500 items keeps the rendered frame bounded */ import { describe, it, expect } from 'vitest'; import { TabbedGhostPicker } from '../../../../packages/terminal/src/tui/tabbed-picker.ts'; From 74e3b08878d2bf89abb3a485a929f46061efe24d Mon Sep 17 00:00:00 2001 From: Fabio-D <> Date: Mon, 27 Apr 2026 16:00:57 +0200 Subject: [PATCH 17/23] docs(11-04): add v1.5.1 polish-release entry to CHANGELOG - Insert ## [1.5.1] - 2026-04-27 section above v1.5.0 header - Three subsections: Changed (README + _glyphs doc), Fixed (purge audit- trail, JSON-SCHEMA MD028, change-plan canonical-ID, scan-memory Windows paths), Tests (B1-B6 + Tier C fold-ins) - v1.5.0 entry preserved byte-for-byte --- CHANGELOG.md | 47 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a009278..0393d5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,53 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.5.1] - 2026-04-27 + +Polish release — documentation accuracy, test reliability, and edge-case +observability. No user-visible behavior changes. + +### Changed + +- README Roadmap copy refreshed: clearer framing of the v1.6 stable + CLI/JSON API contract section (abstract guarantees only — concrete + subcommand naming defers to the v1.6 release notes). Domain-folder + negative list updated. +- `_glyphs.ts` doc clarified: `NO_COLOR` is honored when set to a + non-empty value, per the no-color.org spec (code was already + spec-correct; only the doc was stale). + +### Fixed + +- `purge.ts` audit-trail invariant documented: a `manifest_write_failed:` + failure (disk mutation succeeded, journal-write failed) is treated as a + full failure by the all-failed gate. The "if it isn't journaled, it + didn't happen" contract is now part of the file's docstring and locked + by an in-source test. +- `docs/JSON-SCHEMA.md`: fixed an MD028 markdownlint violation (adjacent + blockquotes separated by a blank line) and verified the rest of the + file for similar instances. +- `change-plan.ts`: replaced hard-coded canonical-ID literal strings with + calls to the exported `canonicalItemId(...)` helper, eliminating drift + risk if the format string changes. +- `scan-memory.ts`: added Windows path-normalization regression coverage + (in-source test). The normalization itself was already correct; the + test locks it. + +### Tests + +- Test-helper polish: replaced a dead `void killed` no-op with a real + `if (killed) return;` guard; synced the `graceMs` parameter doc; synced + the `restore --json` envelope test header docstring with the actual + envelope shape; removed a redundant `stripAnsi(raw)` call in the tmux + e2e fixture; replaced a non-null assertion with explicit narrowing in + the corrupt-manifest test; normalized the force-partial banner string + concatenation and added a tightening assertion that the joining space + is exactly one character. Aligned the `commands` row arrow with the + `agents`/`skills` rows via `padEnd(8)`. Added a `formatBytes()` helper + to the bundle-size check and replaced the hard-coded budget string + with the formatted value. Updated the `pagination-500.test.ts` file- + header comment to enumerate the actual tests in the file. + ## [1.5.0] - 2026-04-26 ### Changed From 8c160097b74bb3df3597e245a3e5b7e15709670b Mon Sep 17 00:00:00 2001 From: Fabio-D <> Date: Mon, 27 Apr 2026 17:08:56 +0200 Subject: [PATCH 18/23] chore(release): v1.5.1 --- apps/ccaudit/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/ccaudit/package.json b/apps/ccaudit/package.json index e992f8c..e66c27d 100644 --- a/apps/ccaudit/package.json +++ b/apps/ccaudit/package.json @@ -1,6 +1,6 @@ { "name": "ccaudit-cli", - "version": "1.5.0", + "version": "1.5.1", "description": "Audit Claude Code ghost inventory — agents, skills, MCP servers, and memory files", "keywords": [ "audit", From d90fd2e027ac4c2fd11ff0e690a6657f162d95fd Mon Sep 17 00:00:00 2001 From: Fabio-D <> Date: Mon, 27 Apr 2026 17:30:17 +0200 Subject: [PATCH 19/23] docs(readme): replace em dashes with hyphens for stylistic consistency --- README.md | 40 ++++++++++++++++++++-------------------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 968e2f4..4a31b86 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # ccaudit -**~11% of your Opus 4.7 1M-token context is gone before you type a word.** (~108k tokens of ghost inventory — roughly half a Sonnet 4.6 200k context. See snapshot below.) +**~11% of your Opus 4.7 1M-token context is gone before you type a word.** (~108k tokens of ghost inventory - roughly half a Sonnet 4.6 200k context. See snapshot below.) Unused agents, skills, MCP servers, commands, hooks, and memory files (call them ghosts) load into context every session.
ccaudit finds them, shows you the cost, and moves them to `~/.claude/ccaudit/archived/` in one command.
@@ -13,14 +13,14 @@ ccaudit does. None of them cover the full picture: | Tool | Covers | Doesn't cover | | ----------------------- | --------------------------------------------- | ------------------------------------------------------------------------------------------------ | -| `/skills t-sort` | Skills only — list, reorder. | Agents, MCP servers, hooks, memory, commands. No token math. No archive / rollback. | +| `/skills t-sort` | Skills only - list, reorder. | Agents, MCP servers, hooks, memory, commands. No token math. No archive / rollback. | | `/usage` | Aggregate token + cost reporting per session. | Per-component ghost identification. Doesn't tell you what to remove or move anything. | | `claude plugin disable` | Plugin-level on/off. | Granular agent / skill / MCP / hook visibility. No token cost. No manifest of what was disabled. | ccaudit's differentiator: **cross-component scope** (agents + skills + MCP + memory + commands + hooks in one pass), **regime-aware token math** (eager vs deferred-tools once `cc ≥ 2.1.7` flips the ToolSearch threshold), and -**archive-with-rollback** — every action is manifest-logged and reversible +**archive-with-rollback** - every action is manifest-logged and reversible via `ccaudit restore`. ```bash @@ -31,12 +31,12 @@ npx ccaudit-cli --dangerously-bust-ghosts # archive ghosts (nothing deleted, un > ccusage tells you what you spent. ccaudit tells you what's wasting it. -Current release: **v1.5.0** — interactive archive picker, interactive restore picker, fuzzy-match restore by name, archive purge. See [CHANGELOG.md](./CHANGELOG.md). +Current release: **v1.5.0** - interactive archive picker, interactive restore picker, fuzzy-match restore by name, archive purge. See [CHANGELOG.md](./CHANGELOG.md). ![Image](https://github.com/user-attachments/assets/2afbe339-539b-4a8d-9f73-8d43746c9ace) > **"But doesn't `/context` already do this?"** -> `/context` shows what's loaded _right now_ in the current session. ccaudit shows what's been ghost-loading for _weeks_ — agents, skills, MCP servers, and memory files you forgot you installed, silently eating your context budget every session. One is a live snapshot; the other is the longitudinal audit. +> `/context` shows what's loaded _right now_ in the current session. ccaudit shows what's been ghost-loading for _weeks_ - agents, skills, MCP servers, and memory files you forgot you installed, silently eating your context budget every session. One is a live snapshot; the other is the longitudinal audit. --- @@ -74,7 +74,7 @@ A typical audit looks like this (default mode, hooks advisory, not included in t ```text ┌──────────────────────────────────────────────────────────────────────────────┐ │ CCAUDIT - ~64k tokens/session wasted │ -│ 👻 Ghost Inventory — Last 7 days │ +│ 👻 Ghost Inventory - Last 7 days │ ├──────────────┬──────────────┬─────────────┬──────────────────────────────────┤ │ Agents │ Defined: 177 │ Used: 12 │ Ghost: 165 ~24k tokens/session │ │ │ │ │ (70 in frameworks above) │ @@ -93,7 +93,7 @@ A typical audit looks like this (default mode, hooks advisory, not included in t │ Total ghost overhead: ~64k tokens (~32% of 200k context window) │ │ (global: ~62k tokens + worst project ~/projects/my-project: ~2.1k tokens) │ │ Health grade: D (Poor) │ -│ Hooks (advisory — not included in total): ~18k tokens upper-bound (9 hooks) │ +│ Hooks (advisory - not included in total): ~18k tokens upper-bound (9 hooks) │ │ Pass --include-hooks to add to grand total. │ │ 💡 Potential savings after ccaudit --dangerously-bust-ghosts: ~64k │ │ tokens/session reclaimed │ @@ -177,7 +177,7 @@ npx ccaudit-cli ghost --interactive └──────────────────────────────────────────────────────────────────────┘ ``` -> Hook archival deferred — selectable archive coming in a future phase. +> Hook archival deferred - selectable archive coming in a future phase. ### The confirmation screen @@ -214,8 +214,8 @@ npx ccaudit-cli ghost --interactive | `Esc` / `Ctrl+C` / `q` | Cancel with "No changes made." (exit 0) | Framework-protected rows render dimmed with a `[🔒]` glyph and are not -selectable by default — pass `--force-partial` to unlock them for the -current run (a `--force-partial active — partial-framework busts allowed` +selectable by default - pass `--force-partial` to unlock them for the +current run (a `--force-partial active - partial-framework busts allowed` banner appears at the top of the picker). See `CLAUDE.md`'s Safety invariants section for the full rationale behind framework-as-unit protection. @@ -326,10 +326,10 @@ Notes: | _(no args)_ | | Restore all items from **every** bust manifest (deduplicated, newer-wins). | | `` | | Restore a single archived item by name: basename without extension for agents/skills/commands (e.g. `restore code-reviewer`), or server name for MCP entries. | | `--interactive` | `-i` | Open a TUI picker listing every archived item across all manifests. Select a subset to restore. Requires a TTY; may be combined with `--json` for the final result envelope. | -| `--name ` | | Fuzzy single-match restore (case-insensitive substring). Ambiguous patterns error with a candidate list — never auto-resolve. | +| `--name ` | | Fuzzy single-match restore (case-insensitive substring). Ambiguous patterns error with a candidate list - never auto-resolve. | | `--all-matching ` | | Bulk restore of every item matching the fuzzy pattern. Exits `1` with `no archived item matches ""` on stderr if nothing matches. | | `--list` | | List all archived items across all bust manifests (read-only). | -| `--json` | `-j` | Output as JSON with a `meta` envelope. Includes additive `selectionFilter`, `skipped[]`, and `filteredStaleCount` fields — see [docs/JSON-SCHEMA.md](./docs/JSON-SCHEMA.md). | +| `--json` | `-j` | Output as JSON with a `meta` envelope. Includes additive `selectionFilter`, `skipped[]`, and `filteredStaleCount` fields - see [docs/JSON-SCHEMA.md](./docs/JSON-SCHEMA.md). | | `--csv` | | RFC 4180 CSV export. | | `--quiet` | `-q` | Machine-readable TSV only. | | `--verbose` | `-v` | Show detailed output including warnings. | @@ -408,7 +408,7 @@ Everything is reversible: ```bash npx ccaudit-cli restore # restore every archived item across all manifests -npx ccaudit-cli restore --interactive # TUI picker — select a subset to restore (v1.5) +npx ccaudit-cli restore --interactive # TUI picker - select a subset to restore (v1.5) npx ccaudit-cli restore --name code-reviewer # fuzzy single-match restore (v1.5) npx ccaudit-cli restore --all-matching gsd- # bulk-restore everything matching the pattern (v1.5) npx ccaudit-cli restore # restore one archived item by exact canonical id @@ -463,12 +463,12 @@ ccaudit purge-archive --json # structured envelope, combinable with --yes Classification per manifest-union archive op: -- **Reclaim** — archive exists, source path is free. The archive is moved back to source (same shared mover as `reclaim`). -- **Drop / source_occupied** — archive exists, source path is already occupied. The archive is unlinked. The source file is **never** overwritten. -- **Drop / stale_archive_missing** — archive is already gone, source exists (already-restored or test residue). No disk mutation; a follow-up manifest op is still appended so restore listings stop surfacing it. -- **Skip / both_missing** — both paths are absent. Preserved for diagnosis; never auto-resolved. +- **Reclaim** - archive exists, source path is free. The archive is moved back to source (same shared mover as `reclaim`). +- **Drop / source_occupied** - archive exists, source path is already occupied. The archive is unlinked. The source file is **never** overwritten. +- **Drop / stale_archive_missing** - archive is already gone, source exists (already-restored or test residue). No disk mutation; a follow-up manifest op is still appended so restore listings stop surfacing it. +- **Skip / both_missing** - both paths are absent. Preserved for diagnosis; never auto-resolved. -Scope is **archive ops only**. Flag ops (memory frontmatter) and MCP disable ops (the `name → ccaudit-disabled:name` key-rename written by `bust`) are untouched by this command. Each executed mutation is recorded as an append-only `archive_purge` op in a fresh `purge--.jsonl` manifest — the original archive op stays intact for audit. Real purge requires an **explicit `--yes`**; there is no prompt fallback. Failures on individual items are reported in `purge.failures[]` but do not abort the batch. +Scope is **archive ops only**. Flag ops (memory frontmatter) and MCP disable ops (the `name → ccaudit-disabled:name` key-rename written by `bust`) are untouched by this command. Each executed mutation is recorded as an append-only `archive_purge` op in a fresh `purge--.jsonl` manifest - the original archive op stays intact for audit. Real purge requires an **explicit `--yes`**; there is no prompt fallback. Failures on individual items are reported in `purge.failures[]` but do not abort the batch. See [docs/JSON-SCHEMA.md § Purge](./docs/JSON-SCHEMA.md) for the `purge.summary` / `purge.failures[]` envelope contract. @@ -690,7 +690,7 @@ wins. Place more-specific entries before more-general ones to avoid shadowing. ## Roadmap -Items planned for upcoming releases. No firm dates — order may shift. +Items planned for upcoming releases. No firm dates - order may shift. ### Interactive process killer @@ -717,7 +717,7 @@ The envelope follows the existing `--json` shape (see forward compatibility. Subcommand names and the full contract surface will land in the v1.6 release notes. -### A new frontend — surprise +### A new frontend - surprise Something visual is in the works. More when it lands. From d5dbb50caba4558c7b642a8463abd093d6df59cc Mon Sep 17 00:00:00 2001 From: Fabio-D <> Date: Mon, 27 Apr 2026 17:35:43 +0200 Subject: [PATCH 20/23] docs(readme): correct quoted --force-partial banner text to match runtime output --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 4a31b86..c1bd297 100644 --- a/README.md +++ b/README.md @@ -215,7 +215,7 @@ npx ccaudit-cli ghost --interactive Framework-protected rows render dimmed with a `[🔒]` glyph and are not selectable by default - pass `--force-partial` to unlock them for the -current run (a `--force-partial active - partial-framework busts allowed` +current run (a `--force-partial active: framework protection DISABLED. Partial framework splits may corrupt dependent setups.` banner appears at the top of the picker). See `CLAUDE.md`'s Safety invariants section for the full rationale behind framework-as-unit protection. From c5fbc1b543931d921c10f1b5fe739e4dfce1a47f Mon Sep 17 00:00:00 2001 From: Fabio-D <> Date: Mon, 27 Apr 2026 17:58:27 +0200 Subject: [PATCH 21/23] chore(docs): bump README current-release label to v1.5.1; strip em dashes from v1.5.1 CHANGELOG entry --- CHANGELOG.md | 4 ++-- README.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0393d5d..5a63bfe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,13 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [1.5.1] - 2026-04-27 -Polish release — documentation accuracy, test reliability, and edge-case +Polish release - documentation accuracy, test reliability, and edge-case observability. No user-visible behavior changes. ### Changed - README Roadmap copy refreshed: clearer framing of the v1.6 stable - CLI/JSON API contract section (abstract guarantees only — concrete + CLI/JSON API contract section (abstract guarantees only - concrete subcommand naming defers to the v1.6 release notes). Domain-folder negative list updated. - `_glyphs.ts` doc clarified: `NO_COLOR` is honored when set to a diff --git a/README.md b/README.md index c1bd297..1ba40cf 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ npx ccaudit-cli --dangerously-bust-ghosts # archive ghosts (nothing deleted, un > ccusage tells you what you spent. ccaudit tells you what's wasting it. -Current release: **v1.5.0** - interactive archive picker, interactive restore picker, fuzzy-match restore by name, archive purge. See [CHANGELOG.md](./CHANGELOG.md). +Current release: **v1.5.1** - interactive archive picker, interactive restore picker, fuzzy-match restore by name, archive purge. See [CHANGELOG.md](./CHANGELOG.md). ![Image](https://github.com/user-attachments/assets/2afbe339-539b-4a8d-9f73-8d43746c9ace) From 32d6c5e0a1decb3dafba30f932ea93f68a9468cb Mon Sep 17 00:00:00 2001 From: Fabio-D <> Date: Mon, 27 Apr 2026 18:43:39 +0200 Subject: [PATCH 22/23] docs(readme): add --interactive to quickstart commands and ASCII picker example --- README.md | 33 ++++++++++++++++++++++++++++++--- 1 file changed, 30 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 1ba40cf..cacab20 100644 --- a/README.md +++ b/README.md @@ -24,9 +24,10 @@ vs deferred-tools once `cc ≥ 2.1.7` flips the ToolSearch threshold), and via `ccaudit restore`. ```bash -npx ccaudit-cli@latest # see what's loading vs. what's used -npx ccaudit-cli --dry-run # see the archive plan, no files touched -npx ccaudit-cli --dangerously-bust-ghosts # archive ghosts (nothing deleted, undo with restore) +npx ccaudit-cli@latest # see what's loading vs. what's used +npx ccaudit-cli --interactive # pick exactly which ghosts to archive (TUI) +npx ccaudit-cli --dry-run # see the archive plan, no files touched +npx ccaudit-cli --dangerously-bust-ghosts # archive ghosts (nothing deleted, undo with restore) ``` > ccusage tells you what you spent. ccaudit tells you what's wasting it. @@ -35,6 +36,32 @@ Current release: **v1.5.1** - interactive archive picker, interactive restore pi ![Image](https://github.com/user-attachments/assets/2afbe339-539b-4a8d-9f73-8d43746c9ace) +The `--interactive` picker (v1.5) lets you tab through every category, toggle individual ghosts, and confirm before any disk write: + +```text +AGENTS │ SKILLS │ MCP SERVERS │ MEMORY │ COMMANDS +AGENTS (9/179 · 743 tok) + ── Ungrouped ── + ◯ brand-guardian 65 tok ~/.claude/agents/design/b… + ◉ image-prompt-engineer 97 tok ~/.claude/agents/design/i… +› ◉ inclusive-visuals-specialist 64 tok ~/.claude/agents/design/i… + ◉ ui-designer 85 tok ~/.claude/agents/design/u… + ◉ ux-architect 64 tok ~/.claude/agents/design/u… + ◉ ux-researcher 84 tok ~/.claude/agents/design/u… + ◉ visual-storyteller 101 tok ~/.claude/agents/design/v… + ◉ whimsy-injector 83 tok ~/.claude/agents/design/w… + ◉ ai-engineer 95 tok ~/.claude/agents/engineer… + ◉ autonomous-optimization-architect 70 tok ~/.claude/agents/engineer… + ◯ backend-architect 82 tok ~/.claude/agents/engineer… + ◯ data-engineer 96 tok ~/.claude/agents/engineer… + ◯ devops-automator 59 tok ~/.claude/agents/engineer… + ◯ embedded-firmware-engineer 69 tok ~/.claude/agents/engineer… + ◯ frontend-developer 66 tok ~/.claude/agents/engineer… +↓ 165 more below +Tab ← → tabs · ↑↓ nav · / search · ? help · Space toggle · a tab-all · Enter → · q cancel +14 of 323 selected across all tabs · ≈ 3k tokens saved +``` + > **"But doesn't `/context` already do this?"** > `/context` shows what's loaded _right now_ in the current session. ccaudit shows what's been ghost-loading for _weeks_ - agents, skills, MCP servers, and memory files you forgot you installed, silently eating your context budget every session. One is a live snapshot; the other is the longitudinal audit. From 7abe543f308b2cac0416dd947019ccd0017388cc Mon Sep 17 00:00:00 2001 From: Fabio-D <> Date: Mon, 27 Apr 2026 18:52:17 +0200 Subject: [PATCH 23/23] docs(readme): align stop-folder example with code (game-development) and bump package-version label to 1.5.1 Addresses CodeRabbit findings on PR #6: - Stop-folder list cited data-engineering/ but DOMAIN_STOP_FOLDERS in packages/internal/src/framework/stop-lists.ts contains game-development/. - Version stanza near footer still showed 1.5.0; release is 1.5.1. --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index cacab20..28324c7 100644 --- a/README.md +++ b/README.md @@ -645,7 +645,7 @@ tier that matches wins; later tiers do not override earlier ones. > **Critical negative finding.** Folder names like `engineering/`, `design/`, > `marketing/`, `testing/`, `sales/`, `integrations/`, `strategy/`, > `project-management/`, `support/`, `paid-media/`, `spatial-computing/`, -> `examples/`, `scripts/`, `product/`, `specialized/`, `data-engineering/`, +> `examples/`, `scripts/`, `product/`, `specialized/`, `game-development/`, > `agents/`, and `skills/` are **domain organisation** folders, not frameworks. > ccaudit refuses to group them, even if they happen to cluster by filename. > This is enforced twice: once by gating folder-segment matches through the @@ -710,7 +710,7 @@ wins. Place more-specific entries before more-general ones to avoid shadowing. ## Version -- Current package version: **1.5.0** +- Current package version: **1.5.1** - Build source of truth: `apps/ccaudit/package.json` and `apps/ccaudit/src/_version.ts` ---