Skip to content
Closed
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
53 changes: 48 additions & 5 deletions packages/squad-cli/src/cli/core/nap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -332,25 +332,68 @@ function archiveDecisions(squadDir: string, dryRun: boolean): NapAction | null {
}
}

// Split: keep entries from last 30 days
// Split: old (dated >30 days), undated (no date), recent (dated ≤30 days)
const recent: typeof entries = [];
const undated: typeof entries = [];
const old: typeof entries = [];
for (const e of entries) {
if (e.daysAgo !== null && e.daysAgo > DECISION_MAX_AGE_DAYS) {
old.push(e);
} else if (e.daysAgo === null) {
undated.push(e);
} else {
recent.push(e);
}
}

if (old.length === 0) return null;
// Phase 1: Archive dated-old entries; also archive undated entries when
// the file is over threshold (Bug 1 fix — undated entries have no date
// provenance and are treated as archivable before recent dated entries).
const toArchive: typeof entries = [...old, ...undated];
let toKeep: typeof entries = [...recent];

// Phase 2: Count-based fallback — if nothing was archived via age/undated
// check but the file is still over threshold, keep the most-recent entries
// that fit under the threshold and archive the rest (Bug 2 fix).
if (toArchive.length === 0) {
// Sort by daysAgo descending so oldest entries come first
const sorted = [...recent].sort((a, b) => {
const da = a.daysAgo ?? Infinity;
const db = b.daysAgo ?? Infinity;
return db - da;
});

const headerEnd = entries.length > 0 ? entries[0]!.start : lines.length;
const headerBytes = Buffer.byteLength(lines.slice(0, headerEnd).join('\n'), 'utf8');

let accumulated = headerBytes;
const keptStarts = new Set<number>();
// Iterate from most-recent (end of sorted) toward oldest, fill to threshold
for (let j = sorted.length - 1; j >= 0; j--) {
const e = sorted[j]!;
const entryBytes = Buffer.byteLength(
lines.slice(e.start, e.end).join('\n') + '\n',
'utf8',
);
if (accumulated + entryBytes <= DECISION_THRESHOLD) {
keptStarts.add(e.start);
accumulated += entryBytes;
}
}

// Preserve original file order
toKeep = recent.filter(e => keptStarts.has(e.start));
toArchive.push(...recent.filter(e => !keptStarts.has(e.start)));
}

if (toArchive.length === 0) return null;

// Header: lines before first ### heading
const headerEnd = entries.length > 0 ? entries[0]!.start : lines.length;
const header = lines.slice(0, headerEnd).join('\n');

const recentContent = header + '\n' + recent.map(e => lines.slice(e.start, e.end).join('\n')).join('\n') + '\n';
const archiveContent = old.map(e => lines.slice(e.start, e.end).join('\n')).join('\n') + '\n';
const recentContent = header + '\n' + toKeep.map(e => lines.slice(e.start, e.end).join('\n')).join('\n') + '\n';
const archiveContent = toArchive.map(e => lines.slice(e.start, e.end).join('\n')).join('\n') + '\n';

const saved = size - Buffer.byteLength(recentContent, 'utf8');

Expand All @@ -365,7 +408,7 @@ function archiveDecisions(squadDir: string, dryRun: boolean): NapAction | null {
return {
type: 'archive',
target: decisionsFile,
description: `Archived ${old.length} old decision entries, kept ${recent.length} recent`,
description: `Archived ${toArchive.length} old decision entries, kept ${toKeep.length} recent`,
bytesSaved: Math.max(0, saved),
};
}
Expand Down
156 changes: 156 additions & 0 deletions test/nap.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -390,6 +390,162 @@ describe('Nap — Decision archival', () => {
const afterSize = statSync(join(squadDir, 'decisions.md')).size;
expect(afterSize).toBeLessThan(Buffer.byteLength(bigDecisions));
});

/**
* Generate decisions.md content with N ### entries.
* maxDaysAgo: oldest entry age in days; entries are spread from 0..maxDaysAgo.
* If maxDaysAgo is null, entries have no date (undated).
*/
function generateDecisions(
count: number,
entrySize = 700,
maxDaysAgo: number | null = 60,
): string {
let content = '# Decisions\n\n';
for (let i = 0; i < count; i++) {
if (maxDaysAgo === null) {
content += `### Decision entry ${i + 1}\n`;
} else {
// Spread entries: entry 0 is today, last entry is maxDaysAgo days ago
const offset = count <= 1
? maxDaysAgo
: Math.floor((maxDaysAgo * i) / (count - 1));
const d = new Date(Date.now() - offset * 24 * 60 * 60 * 1000);
const iso = d.toISOString().slice(0, 10);
content += `### ${iso}: Decision ${i + 1}\n`;
}
content += 'z'.repeat(entrySize) + '\n\n';
}
return content;
}

it('archives undated entries when file exceeds threshold', async () => {
// All entries have NO dates — previously immune to archival (bug 1)
const bigUndated = generateDecisions(30, 700, null);
expect(Buffer.byteLength(bigUndated)).toBeGreaterThan(20 * 1024);

const squadDir = createTestSquadDir({ 'decisions.md': bigUndated });

const result = await runNap({ squadDir });

const archiveActions = result.actions.filter(
(a) => a.type === 'archive' && a.target.includes('decisions'),
);
expect(archiveActions.length).toBeGreaterThan(0);

const archivePath = join(squadDir, 'decisions-archive.md');
expect(existsSync(archivePath)).toBe(true);

const afterSize = statSync(join(squadDir, 'decisions.md')).size;
expect(afterSize).toBeLessThan(Buffer.byteLength(bigUndated));
});

it('archives mixed dated+undated entries — keeps recent dated, archives undated', async () => {
// Half entries: recent dated (<30 days). Half: undated. Total >20KB.
let content = '# Decisions\n\n';
// 15 recent dated entries
for (let i = 0; i < 15; i++) {
const d = new Date(Date.now() - i * 24 * 60 * 60 * 1000);
const iso = d.toISOString().slice(0, 10);
content += `### ${iso}: Recent decision ${i + 1}\n`;
content += 'r'.repeat(700) + '\n\n';
}
// 15 undated entries
for (let i = 0; i < 15; i++) {
content += `### Undated decision ${i + 1}\n`;
content += 'u'.repeat(700) + '\n\n';
}
expect(Buffer.byteLength(content)).toBeGreaterThan(20 * 1024);

const squadDir = createTestSquadDir({ 'decisions.md': content });
await runNap({ squadDir });

const afterContent = readFileSync(join(squadDir, 'decisions.md'), 'utf8');
// Recent dated entries should be kept
expect(afterContent).toContain('Recent decision 1');
// Undated entries should have been archived
expect(afterContent).not.toContain('Undated decision 1');

const archivePath = join(squadDir, 'decisions-archive.md');
expect(existsSync(archivePath)).toBe(true);
const archiveContent = readFileSync(archivePath, 'utf8');
expect(archiveContent).toContain('Undated decision');
});

it('archives entries when all are recent but file exceeds threshold (count-based fallback)', async () => {
// All entries dated within last 14 days — previously returned null (bug 2)
const allRecent = generateDecisions(30, 700, 14);
expect(Buffer.byteLength(allRecent)).toBeGreaterThan(20 * 1024);

const squadDir = createTestSquadDir({ 'decisions.md': allRecent });

const result = await runNap({ squadDir });

const archiveActions = result.actions.filter(
(a) => a.type === 'archive' && a.target.includes('decisions'),
);
expect(archiveActions.length).toBeGreaterThan(0);

const archivePath = join(squadDir, 'decisions-archive.md');
expect(existsSync(archivePath)).toBe(true);

const afterSize = statSync(join(squadDir, 'decisions.md')).size;
expect(afterSize).toBeLessThan(Buffer.byteLength(allRecent));
});

it('preserves all entries — no data loss during archival', async () => {
// Mix of old, recent, and undated entries, >20KB
let content = '# Decisions\n\n';
for (let i = 0; i < 10; i++) {
const d = new Date(Date.now() - (60 + i) * 24 * 60 * 60 * 1000);
content += `### ${d.toISOString().slice(0, 10)}: Old ${i + 1}\n` + 'o'.repeat(900) + '\n\n';
}
for (let i = 0; i < 10; i++) {
const d = new Date(Date.now() - i * 24 * 60 * 60 * 1000);
content += `### ${d.toISOString().slice(0, 10)}: Recent ${i + 1}\n` + 'r'.repeat(900) + '\n\n';
}
for (let i = 0; i < 5; i++) {
content += `### Undated ${i + 1}\n` + 'u'.repeat(900) + '\n\n';
}
expect(Buffer.byteLength(content)).toBeGreaterThan(20 * 1024);

const beforeCount = (content.match(/^###\s/gm) ?? []).length;

const squadDir = createTestSquadDir({ 'decisions.md': content });
await runNap({ squadDir });

const afterDecisions = readFileSync(join(squadDir, 'decisions.md'), 'utf8');
const keptCount = (afterDecisions.match(/^###\s/gm) ?? []).length;

const archivePath = join(squadDir, 'decisions-archive.md');
const archiveContent = existsSync(archivePath)
? readFileSync(archivePath, 'utf8')
: '';
const archivedCount = (archiveContent.match(/^###\s/gm) ?? []).length;

expect(keptCount + archivedCount).toBe(beforeCount);
});

it('secondary pass keeps most recent entries by date, archives oldest', async () => {
// All entries are recent (<7 days), count-based fallback must keep newest
let content = '# Decisions\n\n';
// entries from day 6 down to day 0 (newest = day 0)
for (let i = 6; i >= 0; i--) {
const d = new Date(Date.now() - i * 24 * 60 * 60 * 1000);
const iso = d.toISOString().slice(0, 10);
content += `### ${iso}: Entry day-${i}\n` + 'x'.repeat(3500) + '\n\n';
}
expect(Buffer.byteLength(content)).toBeGreaterThan(20 * 1024);

const squadDir = createTestSquadDir({ 'decisions.md': content });
await runNap({ squadDir });

const afterContent = readFileSync(join(squadDir, 'decisions.md'), 'utf8');
// The most recent entry (day-0) must be kept
expect(afterContent).toContain('Entry day-0');
// The oldest entry (day-6) should have been archived
expect(afterContent).not.toContain('Entry day-6');
});
});

// ============================================================================
Expand Down
Loading