diff --git a/.changeset/nap-history-archive-guard.md b/.changeset/nap-history-archive-guard.md new file mode 100644 index 000000000..8a463f0a7 --- /dev/null +++ b/.changeset/nap-history-archive-guard.md @@ -0,0 +1,5 @@ +--- +"@bradygaster/squad-cli": patch +--- + +`squad nap` no longer loses agent history when `.squad/` is excluded from git. History compression appended the older sections of a tracked `history.md` to a new `history-archive.md` and then trimmed `history.md`. When `.squad/` is in `.git/info/exclude`, the new archive is ignored, so the trim got committed and the archived sections never did. History compression now follows the same rules as decision archival: it refuses when `history.md` would be committed but its archive could not, and it rewrites `history.md` only after checking that the archive gained every archived section. A `.squad/` that is ignored as a whole still compresses as before. diff --git a/packages/squad-cli/src/cli/core/nap.ts b/packages/squad-cli/src/cli/core/nap.ts index a3871a75b..603248159 100644 --- a/packages/squad-cli/src/cli/core/nap.ts +++ b/packages/squad-cli/src/cli/core/nap.ts @@ -161,6 +161,7 @@ function compressHistory( filePath: string, keepEntries: number, dryRun: boolean, + repoRoot: string, ): NapAction | null { if (!storage.existsSync(filePath)) return null; const size = fileSize(filePath); @@ -239,17 +240,57 @@ function compressHistory( const newContent = newLines.join('\n'); const archiveContent = archiveLines.join('\n'); const saved = size - Buffer.byteLength(newContent, 'utf8'); + const relPath = path.basename(path.dirname(filePath)); if (!dryRun) { const archivePath = filePath.replace(/\.md$/, '-archive.md'); - // Append to archive - if (archiveContent.trim()) { - storage.appendSync(archivePath, archiveContent + '\n'); + + // The same rules decision archival follows (see archiveDecisions). + // Rule 2 — nothing to append means nothing to remove. + if (!archiveContent.trim()) { + return { + type: 'compress', + target: filePath, + description: `History compression skipped: ${relPath}/history.md produced no content to archive. Source left intact.`, + bytesSaved: 0, + }; + } + + // Rule 1 — never move sections out of a history.md git will commit into an + // archive it cannot commit (#1783): the trim would commit and the archive + // would not. A history.md that is itself ignored has nothing to lose. + if (isCommittableDestination(filePath, repoRoot) && !isCommittableDestination(archivePath, repoRoot)) { + return { + type: 'compress', + target: filePath, + description: + `History compression refused: ${archivePath} is untracked and git-ignored, so archived sections ` + + `could never be committed. ${relPath}/history.md left in place.`, + bytesSaved: 0, + }; + } + + const before = storage.readSync(archivePath) ?? ''; + storage.appendSync(archivePath, archiveContent + '\n'); + + // Rule 2 — verify the append landed, by section count, before rewriting the source. + const after = storage.readSync(archivePath) ?? ''; + const expected = countSectionHeadings(before) + archive.length; + const actual = countSectionHeadings(after); + if (actual !== expected) { + return { + type: 'compress', + target: filePath, + description: + `History compression aborted: expected ${expected} sections in ${relPath}/history-archive.md after append, ` + + `measured ${actual}. Source left intact.`, + bytesSaved: 0, + }; } + storage.writeSync(filePath, newContent); } - const relPath = path.basename(path.dirname(filePath)); return { type: 'compress', target: filePath, @@ -258,6 +299,11 @@ function compressHistory( }; } +/** `##` headings, matched the way compressHistory splits sections. */ +function countSectionHeadings(markdown: string): number { + return markdown.split('\n').filter(line => /^##\s/.test(line)).length; +} + // ─── Log pruning ──────────────────────────────────────────────────────── function pruneLogs(dir: string, dryRun: boolean): NapAction[] { @@ -548,7 +594,7 @@ export async function runNap(options: NapOptions): Promise { for (const name of storage.listSync(agentsDir)) { if (!storage.isDirectorySync(path.join(agentsDir, name))) continue; const hf = path.join(agentsDir, name, 'history.md'); - const action = compressHistory(hf, keepEntries, dryRun); + const action = compressHistory(hf, keepEntries, dryRun, path.dirname(squadDir)); if (action) actions.push(action); } } @@ -604,7 +650,7 @@ export function runNapSync(options: NapOptions): NapResult { for (const name of storage.listSync(agentsDir)) { if (!storage.isDirectorySync(path.join(agentsDir, name))) continue; const hf = path.join(agentsDir, name, 'history.md'); - const action = compressHistory(hf, keepEntries, dryRun); + const action = compressHistory(hf, keepEntries, dryRun, path.dirname(squadDir)); if (action) actions.push(action); } } diff --git a/test/cli/nap-archival-safety.test.ts b/test/cli/nap-archival-safety.test.ts index 83707f8f8..9e19e1bc6 100644 --- a/test/cli/nap-archival-safety.test.ts +++ b/test/cli/nap-archival-safety.test.ts @@ -282,3 +282,117 @@ describe('Nap archival — #1760: `###` inside a fence is not a record boundary' expect(archived).not.toContain('SENTINEL_TAIL'); }); }); + +// ─── History compression follows the same rules ───────────────────────── + +const HISTORY = '.squad/agents/fenster/history.md'; +const HISTORY_ARCHIVE = '.squad/agents/fenster/history-archive.md'; + +/** `##` sections other than Core Context, the unit nap compresses. */ +function countSections(markdown: string): number { + return markdown.split('\n').filter((l) => /^## /.test(l) && !/^## Core Context/.test(l)).length; +} + +function countSectionsInCommit(repoRoot: string, relPath: string): number { + try { + return countSections(git(['show', `HEAD:${relPath}`], repoRoot)); + } catch { + return 0; + } +} + +/** history.md above nap's 15 KB HISTORY_THRESHOLD with 12 sections, so 7 get archived. */ +function buildHistory(): string { + const filler = 'Learned something worth keeping about this codebase. '.repeat(40); + const lines = ['# Fenster history', '', '## Core Context', '', 'Backend specialist.', '']; + for (let i = 0; i < 12; i++) lines.push(`## Session ${i}`, '', filler, ''); + return lines.join('\n'); +} + +function createHistoryRepo(opts: { excludeSquad: boolean; archiveExists: boolean }): { + repoRoot: string; + squadDir: string; +} { + const repoRoot = mkdtempSync(join(tmpdir(), 'squad-nap-history-')); + tmpDirs.push(repoRoot); + + git(['init', '--initial-branch=main'], repoRoot); + git(['config', 'user.email', 'fido@squad.test'], repoRoot); + git(['config', 'user.name', 'FIDO'], repoRoot); + git(['config', 'commit.gpgsign', 'false'], repoRoot); + + const squadDir = join(repoRoot, '.squad'); + mkdirSync(join(squadDir, 'agents', 'fenster'), { recursive: true }); + writeFileSync(join(repoRoot, HISTORY), buildHistory(), 'utf8'); + if (opts.archiveExists) { + writeFileSync(join(repoRoot, HISTORY_ARCHIVE), '# Fenster history archive\n', 'utf8'); + } + + git(['add', '-A'], repoRoot); + git(['commit', '-m', 'seed'], repoRoot); + + if (opts.excludeSquad) { + appendFileSync(join(repoRoot, '.git', 'info', 'exclude'), '\n.squad/\n', 'utf8'); + } + + return { repoRoot, squadDir }; +} + +describe('Nap history compression — #1783: destination must be committable', () => { + it('does not move sections into a git-ignored archive that cannot be committed', async () => { + const { repoRoot, squadDir } = createHistoryRepo({ excludeSquad: true, archiveExists: false }); + const totalBefore = countSectionsInCommit(repoRoot, HISTORY); + expect(totalBefore).toBe(12); + + await runNap({ squadDir }); + commitLikeAnAgent(repoRoot); + + expect(countSectionsInCommit(repoRoot, HISTORY) + countSectionsInCommit(repoRoot, HISTORY_ARCHIVE)).toBe(totalBefore); + }); + + it('reports the refusal and writes nothing', async () => { + const { repoRoot, squadDir } = createHistoryRepo({ excludeSquad: true, archiveExists: false }); + const before = readFileSync(join(repoRoot, HISTORY), 'utf8'); + + const result = await runNap({ squadDir }); + + expect(result.actions.find((a) => a.type === 'compress')?.description).toMatch(/^History compression refused/); + expect(readFileSync(join(repoRoot, HISTORY), 'utf8')).toBe(before); + expect(existsSync(join(repoRoot, HISTORY_ARCHIVE))).toBe(false); + expect(git(['status', '--porcelain'], repoRoot).trim()).toBe(''); + }); + + it('still compresses when the archive is already tracked', async () => { + const { repoRoot, squadDir } = createHistoryRepo({ excludeSquad: true, archiveExists: true }); + + await runNap({ squadDir }); + commitLikeAnAgent(repoRoot); + + expect(countSectionsInCommit(repoRoot, HISTORY)).toBe(5); + expect(countSectionsInCommit(repoRoot, HISTORY_ARCHIVE)).toBe(7); + }); + + it('still compresses when .squad/ was never committed and is ignored as a whole', async () => { + const repoRoot = mkdtempSync(join(tmpdir(), 'squad-nap-history-ignored-')); + tmpDirs.push(repoRoot); + git(['init', '--initial-branch=main'], repoRoot); + writeFileSync(join(repoRoot, '.gitignore'), '/.squad/\n', 'utf8'); + mkdirSync(join(repoRoot, '.squad', 'agents', 'fenster'), { recursive: true }); + writeFileSync(join(repoRoot, HISTORY), buildHistory(), 'utf8'); + + await runNap({ squadDir: join(repoRoot, '.squad') }); + + // Nothing under .squad/ reaches a commit, so there is no boundary to protect. + expect(countSections(readFileSync(join(repoRoot, HISTORY), 'utf8'))).toBe(5); + expect(countSections(readFileSync(join(repoRoot, HISTORY_ARCHIVE), 'utf8'))).toBe(7); + }); + + it('compresses normally when .squad/ is not excluded', async () => { + const { repoRoot, squadDir } = createHistoryRepo({ excludeSquad: false, archiveExists: false }); + + await runNap({ squadDir }); + + expect(countSections(readFileSync(join(repoRoot, HISTORY), 'utf8'))).toBe(5); + expect(countSections(readFileSync(join(repoRoot, HISTORY_ARCHIVE), 'utf8'))).toBe(7); + }); +});