Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
442db3c
docs(11-01): swap negative-list domain folder to data-engineering
Apr 27, 2026
2534c3a
docs(11-01): rewrite v1.6 roadmap section as abstract API contract prose
Apr 27, 2026
a447176
docs(11-02): document audit-trail invariant in purge.ts
Apr 27, 2026
62b0a14
test(11-02): lock audit-trail invariant with manifest_write_failed: t…
Apr 27, 2026
aa90678
fix(_test-helpers): replace dead void-killed guard with real return g…
Apr 27, 2026
cda2cac
docs(restore-json-envelope.test): sync header docstring with actual e…
Apr 27, 2026
7c71af2
refactor(tmux-e2e): drop redundant stripAnsi(raw) — already stripped …
Apr 27, 2026
5a4e720
refactor(restore-corrupt-manifest.test): replace non-null assertion w…
Apr 27, 2026
cb8cf12
fix(_force-partial-banner): normalize space concatenation; lock with …
Apr 27, 2026
e5f46ac
test(scan-memory): lock Windows-path normalization (A5)
Apr 27, 2026
2817dc2
refactor(change-plan): replace canonical-ID literals with canonicalIt…
Apr 27, 2026
3261410
docs(_glyphs): clarify NO_COLOR honors no-color.org spec (non-empty v…
Apr 27, 2026
7216f13
docs(JSON-SCHEMA): fix MD028 adjacent-blockquote at L126-128 (A2 + C5)
Apr 27, 2026
b702f42
fix(tables/change-plan): align commands-row arrow with padEnd(8) (C1)
Apr 27, 2026
9b61d90
fix(bundle-size-check): format budget in error via formatBytes(); fin…
Apr 27, 2026
4e05513
docs(pagination-500.test): enumerate actual tests in file header (C4)
Apr 27, 2026
74e3b08
docs(11-04): add v1.5.1 polish-release entry to CHANGELOG
Apr 27, 2026
8c16009
chore(release): v1.5.1
Apr 27, 2026
d90fd2e
docs(readme): replace em dashes with hyphens for stylistic consistency
Apr 27, 2026
d5dbb50
docs(readme): correct quoted --force-partial banner text to match run…
Apr 27, 2026
c5fbc1b
chore(docs): bump README current-release label to v1.5.1; strip em da…
Apr 27, 2026
32d6c5e
docs(readme): add --interactive to quickstart commands and ASCII pick…
Apr 27, 2026
7abe543
docs(readme): align stop-folder example with code (game-development) …
Apr 27, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
97 changes: 57 additions & 40 deletions README.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion apps/ccaudit/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
16 changes: 15 additions & 1 deletion apps/ccaudit/scripts/bundle-size-check.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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.');
Expand Down Expand Up @@ -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);
}

Expand Down
9 changes: 5 additions & 4 deletions apps/ccaudit/src/__tests__/_test-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Comment on lines 10 to 18

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

Docstring still mismatches the implementation — graceMs does not bound the poll loop.

The rewritten docstring conflates two distinct timeouts. In the implementation:

  • The poll loop is bounded by the hard-coded earlyExitMs = 1_000 (lines 21, 24), not graceMs.
  • graceMs is only the final sleep added once the poll loop completes without an early exit (lines 29–31), and it is configurable via the parameter (default 300ms), not a fixed 300ms.

So both phrases — "Polls until … graceMs elapses" and "loop runs until graceMs, then a 300ms grace is added" — are inaccurate. Given that doc accuracy is one of this PR's stated goals, worth tightening here.

📝 Suggested docstring
 /**
  * Wait for the spawned picker subprocess to reach its blocking read loop.
- * 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.
+ * Polls (with exponential backoff) for up to ~1s to detect an early exit
+ * (error/crash path). If the child is still alive after that window, sleeps
+ * an additional `graceMs` so the TUI has time to reach its blocking read.
  *
- * Typical path: child never exits during the poll window, loop runs until
- * graceMs, then a 300ms grace is added for the render cycle to settle.
+ * Typical path: the child does not exit during the ~1s poll window, then
+ * `graceMs` (default 300ms) is added for the render cycle to settle.
  */
📝 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
/**
* 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.
*/
/**
* Wait for the spawned picker subprocess to reach its blocking read loop.
* Polls (with exponential backoff) for up to ~1s to detect an early exit
* (error/crash path). If the child is still alive after that window, sleeps
* an additional `graceMs` so the TUI has time to reach its blocking read.
*
* Typical path: the child does not exit during the ~1s poll window, then
* `graceMs` (default 300ms) is added for the render cycle to settle.
*/
🤖 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 - 18, The
docstring is inaccurate: update it to state that the poll loop is bounded by the
hard-coded earlyExitMs (earlyExitMs = 1000) rather than graceMs, and that
graceMs is only the final configurable sleep added after the poll completes
(default 300ms), not the duration that the poll runs; reference the variables
earlyExitMs and graceMs in the docstring and remove the contradictory phrase
that the poll runs until graceMs then a 300ms grace is added.

export async function waitForPicker(child: ChildProcess, graceMs = 300): Promise<void> {
// Short poll to detect early crashes (child exiting prematurely).
Expand Down Expand Up @@ -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 });
});
});
Expand Down
5 changes: 4 additions & 1 deletion apps/ccaudit/src/__tests__/fixtures/tmux-e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
13 changes: 6 additions & 7 deletions apps/ccaudit/src/__tests__/pagination-500.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
7 changes: 5 additions & 2 deletions apps/ccaudit/src/__tests__/restore-corrupt-manifest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;
let parsed: Record<string, unknown> | null = null;
expect(() => {
parsed = JSON.parse(stdout) as Record<string, unknown>;
}, `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.');
});
Expand Down
20 changes: 12 additions & 8 deletions apps/ccaudit/src/__tests__/restore-json-envelope.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,19 @@
* Phase 08 Plan 06 — `ccaudit restore --all-matching <pattern> --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';
Expand Down
2 changes: 2 additions & 0 deletions docs/JSON-SCHEMA.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
47 changes: 39 additions & 8 deletions packages/internal/src/remediation/change-plan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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);
Expand All @@ -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]));
Expand Down
Loading
Loading