Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ checklist.

## [Unreleased]

- **fix(bake): strip model-conditional betas from the baked base set (issue #484 follow-up).** The drift *detection* fix made `computeDrift` ignore the `betaForModel()`-managed betas, but the *bake* path (`capture-and-bake.mjs`) still wrote the raw captured `anthropic_beta` verbatim — so an auto-rebake from a capture that rode `context-1m-2025-08-07` (the drift runner's capture does) re-introduced it to the base, undoing #475. The bake now runs the captured beta header through `stripModelConditionalBetas` (new export, shares `MODEL_CONDITIONAL_BETAS`) before writing, so a rebake produces only the real diff (e.g. dropping `afk-mode-2026-01-31`) and never re-adds the per-request betas. `test/bake-drift-report.mjs` +2 cases (88/88).

- **fix(drift-check): stop the perpetual false-positive auto-rebake loop (issue #484).** `cc-drift-template-watch.yml` re-fired every cycle because `computeDrift` compared the live `--check` capture against the bundle without accounting for two structural, non-drift differences: (1) the **model-conditional betas** `context-1m-2025-08-07` and `fallback-credit-2026-06-01`, which `betaForModel()` appends per-request and the baked base deliberately omits — so a capture carrying them read as "beta added" forever; and (2) the **environment-specific memory directory path** in the system prompt, which differs on every cross-OS bake (e.g. a Windows-baked bundle vs the Linux drift runner's `/root/...` capture). `computeDrift` now filters the `betaForModel()`-managed betas from both sides (new export `MODEL_CONDITIONAL_BETAS`) and normalizes the memory path before the system_prompt diff (new export `normalizeMemoryPath`). Genuine base-beta changes (e.g. `afk-mode` add/removal) and real prompt edits still surface. `test/bake-drift-report.mjs` extended (+6 cases, 81/81). Closes the regression-rebake loop that produced #476 and #483.

## [4.8.55] - 2026-06-09
Expand Down
13 changes: 12 additions & 1 deletion scripts/capture-and-bake.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ import { fileURLToPath } from 'node:url';
import { captureLiveTemplateAsync, findInstalledCC } from '../dist/live-fingerprint.js';
import { scrubTemplate, findUserPathHits } from '../dist/scrub-template.js';
import { PLATFORM_ONLY_TOOLS } from '../dist/cc-template.js';
import { computeDrift, formatDriftReport, interpretDrift, formatDriftSummary } from './drift-report.mjs';
import { computeDrift, formatDriftReport, interpretDrift, formatDriftSummary, stripModelConditionalBetas } from './drift-report.mjs';

const __dirname = dirname(fileURLToPath(import.meta.url));
const repoRoot = join(__dirname, '..');
Expand Down Expand Up @@ -70,6 +70,17 @@ if (!captured) {
log(`captured: CC v${captured._version}, ${captured.tools.length} tools, ${captured.system_prompt.length} char system prompt`);

const scrubbed = scrubTemplate(captured);
// Strip the model-conditional betas betaForModel() appends per-request
// (context-1m on [1m] requests, fallback-credit on fable) so the baked BASE set
// matches #475's design — they're re-added per-request at runtime, not part of
// the canonical base. Without this, a capture that rode one (the drift runner's
// capture carries context-1m) would re-introduce it to the base on every rebake,
// undoing #475. Same set the drift detector ignores (drift-report.mjs).
const beforeBeta = scrubbed.anthropic_beta || '';
scrubbed.anthropic_beta = stripModelConditionalBetas(beforeBeta);
if (scrubbed.anthropic_beta !== beforeBeta) {
log(`stripped model-conditional beta(s) from baked base: ${beforeBeta} → ${scrubbed.anthropic_beta}`);
}
scrubbed._source = 'bundled';
scrubbed._supportedMaxTested = captured._version;

Expand Down
16 changes: 16 additions & 0 deletions scripts/drift-report.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,22 @@ export const MODEL_CONDITIONAL_BETAS = new Set([
'fallback-credit-2026-06-01', // FABLE_FALLBACK_CREDIT_BETA — appended for fable requests only
]);

/**
* Strip the model-conditional betas (the ones betaForModel() appends per-request)
* from a comma-joined beta string. Used by the BAKE path (capture-and-bake.mjs) so
* the canonical base set written to cc-template-data.json never carries them — a
* live capture that happened to ride them (e.g. a `[1m]` request carrying
* context-1m) would otherwise re-introduce them to the base on every rebake,
* undoing #475's design. Mirrors the filter computeDrift applies for detection.
*/
export function stripModelConditionalBetas(beta) {
return (beta || '')
.split(',')
.filter(Boolean)
.filter((b) => !MODEL_CONDITIONAL_BETAS.has(b))
.join(',');
}

/**
* Collapse the environment-specific CC memory directory path to a placeholder so
* a cross-OS bake doesn't read as system_prompt drift: the bundle may be baked on
Expand Down
26 changes: 25 additions & 1 deletion test/bake-drift-report.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
// test runner spawns each file via node:test which is fine for imports
// too, but the existing pattern groups script-imports in serial).

import { unifiedDiff, computeDrift, describeTool, formatDriftReport, interpretDrift, formatDriftSummary, MODEL_CONDITIONAL_BETAS, normalizeMemoryPath } from '../scripts/drift-report.mjs';
import { unifiedDiff, computeDrift, describeTool, formatDriftReport, interpretDrift, formatDriftSummary, MODEL_CONDITIONAL_BETAS, normalizeMemoryPath, stripModelConditionalBetas } from '../scripts/drift-report.mjs';

let pass = 0;
let fail = 0;
Expand Down Expand Up @@ -389,6 +389,30 @@ header('37. computeDrift — real prompt edit still flagged despite path normali
check('diff shows the real edit, not the path', d[0].detail?.some((l) => /CHANGED/.test(l)) && !d[0].detail?.some((l) => /\.claude/.test(l)));
}

// ──────────────────────────────────────────────────────────────────────
// issue #484 — the BAKE must strip model-conditional betas so a rebake can't
// re-introduce them to the base (undoing #475). Mirrors the detection filter.
header('38. stripModelConditionalBetas — removes context-1m / fallback-credit, keeps the rest');
{
const captured = 'claude-code-20250219,context-1m-2025-08-07,interleaved-thinking-2025-05-14,effort-2025-11-24';
const baked = stripModelConditionalBetas(captured);
check('context-1m removed', !baked.includes('context-1m-2025-08-07'));
check('base betas preserved in order', baked === 'claude-code-20250219,interleaved-thinking-2025-05-14,effort-2025-11-24');
check('fallback-credit removed too', stripModelConditionalBetas('claude-code-20250219,fallback-credit-2026-06-01') === 'claude-code-20250219');
check('no-op when no managed betas present', stripModelConditionalBetas('claude-code-20250219,afk-mode-2026-01-31') === 'claude-code-20250219,afk-mode-2026-01-31');
check('empty / undefined safe', stripModelConditionalBetas('') === '' && stripModelConditionalBetas(undefined) === '');
}

header('39. bake-vs-check consistency — a re-baked base no longer drifts from the capture on managed betas');
{
// Simulate: live capture carries context-1m (rode a [1m] request); the bake
// strips it; computeDrift(baked-base, same-capture) must NOT re-flag it.
const capture = makeTemplate({ anthropic_beta: 'claude-code-20250219,context-1m-2025-08-07,effort-2025-11-24' });
const baked = makeTemplate({ anthropic_beta: stripModelConditionalBetas(capture.anthropic_beta) });
check('baked base omits context-1m', !baked.anthropic_beta.includes('context-1m'));
check('no beta drift between baked base and the capture it came from', computeDrift(baked, capture).length === 0);
}

// ──────────────────────────────────────────────────────────────────────
console.log(`\n${pass} pass, ${fail} fail`);
process.exit(fail === 0 ? 0 : 1);