Fase 4 token-opt, Item 1 of gap-review NEXT STEP #2. The prompt-cache pattern corpus + TOK scanner were frozen at an "Opus 4.7" framing after CC shipped Opus 4.8 (default, 2.1.154) and Fable 5 (2.1.170). Model-era facts re-verified against the official changelog cache before editing. The patterns are properties of prompt-caching, not of any model, so mechanic text is now model-neutral with a single "current default: Opus 4.8" anchor — preventing a re-freeze at the next model bump. - rename knowledge/opus-4.7-patterns.md -> prompt-cache-patterns.md (git mv, history preserved); 6 reference sites updated - TOK scanner: line-318 finding text (human-facing) made model-neutral; header + cache-prefix-scanner + CLI comments refreshed - configuration-best-practices.md body + footnote 4.7 -> 4.8 - human-facing docs: commands/{tokens,help,manifest}.md, project CLAUDE.md, README, docs/scanner-internals.md - gap-matrix row marked DONE; future Items 2/3 retargeted to new filename Failing-test-first (Iron Law): +2 knowledge staleness guards (era-anchor + no-refreeze) +1 scanner assertion (no stale model anchor in finding text). Suite 853 -> 856 green; zero snapshot drift; self-audit A(97) PASS. CHANGELOG / v5 plan / ratified gap-plan keep historical opus-4.7-patterns refs (correct record of past state). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ter3E2JSi1Khgmuf2kady8
115 lines
4.3 KiB
JavaScript
115 lines
4.3 KiB
JavaScript
/**
|
||
* CPS Scanner — Cache-Prefix Stability Analyzer (v5 N3)
|
||
*
|
||
* Walks the CLAUDE.md cascade and flags volatile content anywhere in the
|
||
* cached prefix (≤ CACHED_PREFIX_LINES). Distinguishes from TOK Pattern A,
|
||
* which only inspects the top 30 lines: CPS catches a `!git log` at line 60
|
||
* or a `${TIMESTAMP}` at line 100. Volatile content anywhere in the cached
|
||
* prefix breaks prompt-cache reuse from that line forward.
|
||
*
|
||
* Volatile patterns extend the TOK set with shell-exec `!` prefix and
|
||
* `${VAR}` substitutions — both common cache-busters in real CLAUDE.md files.
|
||
*
|
||
* Finding ID: CA-CPS-NNN. Severity: medium.
|
||
*
|
||
* Zero external dependencies.
|
||
*/
|
||
|
||
import { readTextFile } from './lib/file-discovery.mjs';
|
||
import { finding, scannerResult } from './lib/output.mjs';
|
||
import { SEVERITY } from './lib/severity.mjs';
|
||
|
||
const SCANNER = 'CPS';
|
||
|
||
// Cache-prefix line threshold: content below this line is unlikely to be
|
||
// part of a stable cached prefix in typical sessions. The number is
|
||
// heuristic — the goal is to flag volatility that genuinely costs cache
|
||
// hits per turn, not to chase every inline date in a long backlog file.
|
||
const CACHED_PREFIX_LINES = 150;
|
||
|
||
// Volatile-pattern set (extends token-hotspots.mjs Pattern A).
|
||
const VOLATILE_PATTERNS = [
|
||
{ rx: /\{timestamp\}/i, label: '{timestamp} placeholder' },
|
||
{ rx: /\{uuid\}/i, label: '{uuid} placeholder' },
|
||
{ rx: /\{date\}/i, label: '{date} placeholder' },
|
||
{ rx: /\{session(?:_id)?\}/i, label: '{session_id} placeholder' },
|
||
{ rx: /\bactivity log\b/i, label: 'activity-log section' },
|
||
{ rx: /^\s*\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/, label: 'ISO timestamp at line start' },
|
||
{ rx: /^\s*\[\d{4}-\d{2}-\d{2}/, label: 'dated log line [YYYY-MM-DD ...]' },
|
||
// v5 N3 extensions:
|
||
{ rx: /^\s*!/, label: 'shell-exec line (! prefix)' },
|
||
{ rx: /\$\{[A-Z_][A-Z0-9_]*\}/, label: '${VAR} substitution' },
|
||
];
|
||
|
||
/**
|
||
* Scan content for volatile lines within the cached prefix window.
|
||
* Returns array of {line, label, snippet}.
|
||
*/
|
||
function findVolatileLines(content) {
|
||
const out = [];
|
||
if (!content) return out;
|
||
const lines = content.split('\n').slice(0, CACHED_PREFIX_LINES);
|
||
for (let i = 0; i < lines.length; i++) {
|
||
for (const { rx, label } of VOLATILE_PATTERNS) {
|
||
if (rx.test(lines[i])) {
|
||
out.push({
|
||
line: i + 1,
|
||
label,
|
||
snippet: lines[i].length > 120 ? lines[i].slice(0, 117) + '...' : lines[i],
|
||
});
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/**
|
||
* Main scanner entry point.
|
||
*
|
||
* @param {string} targetPath
|
||
* @param {{files: Array<{absPath:string, relPath:string, type:string, scope:string, size:number}>}} discovery
|
||
*/
|
||
export async function scan(targetPath, discovery) {
|
||
const start = Date.now();
|
||
const findings = [];
|
||
let filesScanned = 0;
|
||
|
||
for (const f of discovery.files) {
|
||
if (f.type !== 'claude-md') continue;
|
||
filesScanned++;
|
||
const content = await readTextFile(f.absPath);
|
||
if (!content) continue;
|
||
const volatile = findVolatileLines(content);
|
||
if (volatile.length === 0) continue;
|
||
|
||
// Skip volatility that's already covered by TOK Pattern A (lines 1–30) —
|
||
// CPS' value is in the 31–150 range. Pattern A handles 1–30.
|
||
const beyondTopThirty = volatile.filter(v => v.line > 30);
|
||
if (beyondTopThirty.length === 0) continue;
|
||
|
||
const evidence =
|
||
beyondTopThirty.slice(0, 5)
|
||
.map(v => `line ${v.line} (${v.label}): ${v.snippet}`)
|
||
.join('; ');
|
||
findings.push(finding({
|
||
scanner: SCANNER,
|
||
severity: SEVERITY.medium,
|
||
title: 'Volatile content inside cached prefix breaks reuse',
|
||
description:
|
||
`${f.relPath || f.absPath} contains ${beyondTopThirty.length} volatile ` +
|
||
`entr${beyondTopThirty.length === 1 ? 'y' : 'ies'} between lines 31 and ` +
|
||
`${CACHED_PREFIX_LINES}. The prompt cache covers the file's prefix; ` +
|
||
'any volatility forces a fresh cache write from that line down on every turn.',
|
||
file: f.absPath,
|
||
evidence,
|
||
recommendation:
|
||
'Move volatile sections (timestamps, !shell-exec, ${VAR} substitutions, dated logs) ' +
|
||
`below line ${CACHED_PREFIX_LINES} or extract them to an @import-ed file outside the ` +
|
||
'cached prefix. Stable content above, volatile content below.',
|
||
category: 'token-efficiency',
|
||
}));
|
||
}
|
||
|
||
return scannerResult(SCANNER, 'ok', findings, filesScanned, Date.now() - start);
|
||
}
|