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
5 changes: 5 additions & 0 deletions .changeset/nap-history-archive-guard.md
Original file line number Diff line number Diff line change
@@ -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.
58 changes: 52 additions & 6 deletions packages/squad-cli/src/cli/core/nap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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,
Expand All @@ -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[] {
Expand Down Expand Up @@ -548,7 +594,7 @@ export async function runNap(options: NapOptions): Promise<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);
}
}
Expand Down Expand Up @@ -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);
}
}
Expand Down
114 changes: 114 additions & 0 deletions test/cli/nap-archival-safety.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
Loading