@cleocode/core 2026.4.98 → 2026.4.99

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/dist/gc/daemon-entry.d.ts +15 -0
  2. package/dist/gc/daemon-entry.d.ts.map +1 -0
  3. package/dist/gc/daemon.d.ts +71 -0
  4. package/dist/gc/daemon.d.ts.map +1 -0
  5. package/dist/gc/index.d.ts +14 -0
  6. package/dist/gc/index.d.ts.map +1 -0
  7. package/dist/gc/runner.d.ts +132 -0
  8. package/dist/gc/runner.d.ts.map +1 -0
  9. package/dist/gc/state.d.ts +94 -0
  10. package/dist/gc/state.d.ts.map +1 -0
  11. package/dist/gc/transcript.d.ts +130 -0
  12. package/dist/gc/transcript.d.ts.map +1 -0
  13. package/dist/sentient/daemon-entry.d.ts +11 -0
  14. package/dist/sentient/daemon-entry.d.ts.map +1 -0
  15. package/dist/sentient/daemon.d.ts +160 -0
  16. package/dist/sentient/daemon.d.ts.map +1 -0
  17. package/dist/sentient/index.d.ts +18 -0
  18. package/dist/sentient/index.d.ts.map +1 -0
  19. package/dist/sentient/ingesters/brain-ingester.d.ts +44 -0
  20. package/dist/sentient/ingesters/brain-ingester.d.ts.map +1 -0
  21. package/dist/sentient/ingesters/nexus-ingester.d.ts +45 -0
  22. package/dist/sentient/ingesters/nexus-ingester.d.ts.map +1 -0
  23. package/dist/sentient/ingesters/test-ingester.d.ts +43 -0
  24. package/dist/sentient/ingesters/test-ingester.d.ts.map +1 -0
  25. package/dist/sentient/proposal-rate-limiter.d.ts +93 -0
  26. package/dist/sentient/proposal-rate-limiter.d.ts.map +1 -0
  27. package/dist/sentient/propose-tick.d.ts +105 -0
  28. package/dist/sentient/propose-tick.d.ts.map +1 -0
  29. package/dist/sentient/state.d.ts +143 -0
  30. package/dist/sentient/state.d.ts.map +1 -0
  31. package/dist/sentient/tick.d.ts +193 -0
  32. package/dist/sentient/tick.d.ts.map +1 -0
  33. package/package.json +76 -8
  34. package/src/gc/__tests__/runner.test.ts +367 -0
  35. package/src/gc/__tests__/state.test.ts +169 -0
  36. package/src/gc/__tests__/transcript.test.ts +371 -0
  37. package/src/gc/daemon-entry.ts +26 -0
  38. package/src/gc/daemon.ts +251 -0
  39. package/src/gc/index.ts +14 -0
  40. package/src/gc/runner.ts +378 -0
  41. package/src/gc/state.ts +140 -0
  42. package/src/gc/transcript.ts +380 -0
  43. package/src/sentient/__tests__/brain-ingester.test.ts +154 -0
  44. package/src/sentient/__tests__/daemon.test.ts +472 -0
  45. package/src/sentient/__tests__/dream-tick.test.ts +200 -0
  46. package/src/sentient/__tests__/nexus-ingester.test.ts +138 -0
  47. package/src/sentient/__tests__/proposal-rate-limiter.test.ts +247 -0
  48. package/src/sentient/__tests__/propose-tick.test.ts +296 -0
  49. package/src/sentient/__tests__/test-ingester.test.ts +104 -0
  50. package/src/sentient/daemon-entry.ts +20 -0
  51. package/src/sentient/daemon.ts +471 -0
  52. package/src/sentient/index.ts +18 -0
  53. package/src/sentient/ingesters/brain-ingester.ts +122 -0
  54. package/src/sentient/ingesters/nexus-ingester.ts +171 -0
  55. package/src/sentient/ingesters/test-ingester.ts +205 -0
  56. package/src/sentient/proposal-rate-limiter.ts +172 -0
  57. package/src/sentient/propose-tick.ts +415 -0
  58. package/src/sentient/state.ts +229 -0
  59. package/src/sentient/tick.ts +688 -0
@@ -0,0 +1,169 @@
1
+ /**
2
+ * GC State Tests (T735)
3
+ *
4
+ * Covers:
5
+ * - readGCState: returns default state when file missing
6
+ * - readGCState: merges missing fields with defaults on schema version bump
7
+ * - writeGCState: writes atomically via tmp+rename
8
+ * - patchGCState: merges patch over current state
9
+ *
10
+ * Uses real temp directories (mkdtemp). No mocked filesystem.
11
+ *
12
+ * @task T735
13
+ * @epic T726
14
+ */
15
+
16
+ import { mkdir, mkdtemp, readFile, rm } from 'node:fs/promises';
17
+ import { tmpdir } from 'node:os';
18
+ import { join } from 'node:path';
19
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
20
+
21
+ import {
22
+ DEFAULT_GC_STATE,
23
+ GC_STATE_SCHEMA_VERSION,
24
+ patchGCState,
25
+ readGCState,
26
+ writeGCState,
27
+ } from '../state.js';
28
+
29
+ describe('readGCState', () => {
30
+ let tmpDir: string;
31
+
32
+ beforeEach(async () => {
33
+ tmpDir = await mkdtemp(join(tmpdir(), 'cleo-gc-state-test-'));
34
+ });
35
+
36
+ afterEach(async () => {
37
+ await rm(tmpDir, { recursive: true, force: true });
38
+ });
39
+
40
+ it('returns default state when file does not exist', async () => {
41
+ const nonExistentPath = join(tmpDir, 'nonexistent', 'gc-state.json');
42
+ const state = await readGCState(nonExistentPath);
43
+ expect(state).toEqual(DEFAULT_GC_STATE);
44
+ });
45
+
46
+ it('returns default state when file contains invalid JSON', async () => {
47
+ const statePath = join(tmpDir, 'gc-state.json');
48
+ await mkdir(tmpDir, { recursive: true });
49
+ // Write invalid JSON
50
+ await import('node:fs/promises').then((fs) =>
51
+ fs.writeFile(statePath, 'not valid json {{{', 'utf-8'),
52
+ );
53
+
54
+ const state = await readGCState(statePath);
55
+ expect(state).toEqual(DEFAULT_GC_STATE);
56
+ });
57
+
58
+ it('merges partial state with defaults (forward compatibility)', async () => {
59
+ const statePath = join(tmpDir, 'gc-state.json');
60
+ // Write a state with only some fields (simulates old schema version)
61
+ const partial = { schemaVersion: '1.0', lastRunAt: '2026-04-10T03:00:00.000Z' };
62
+ await import('node:fs/promises').then((fs) =>
63
+ fs.writeFile(statePath, JSON.stringify(partial), 'utf-8'),
64
+ );
65
+
66
+ const state = await readGCState(statePath);
67
+
68
+ // Explicitly set fields are preserved
69
+ expect(state.lastRunAt).toBe('2026-04-10T03:00:00.000Z');
70
+ // Missing fields default to the DEFAULT_GC_STATE values
71
+ expect(state.consecutiveFailures).toBe(0);
72
+ expect(state.escalationNeeded).toBe(false);
73
+ expect(state.daemonPid).toBeNull();
74
+ });
75
+ });
76
+
77
+ describe('writeGCState', () => {
78
+ let tmpDir: string;
79
+
80
+ beforeEach(async () => {
81
+ tmpDir = await mkdtemp(join(tmpdir(), 'cleo-gc-write-test-'));
82
+ });
83
+
84
+ afterEach(async () => {
85
+ await rm(tmpDir, { recursive: true, force: true });
86
+ });
87
+
88
+ it('creates parent directories if they do not exist', async () => {
89
+ const statePath = join(tmpDir, 'nested', 'deep', 'gc-state.json');
90
+ await writeGCState(statePath, { ...DEFAULT_GC_STATE });
91
+
92
+ const raw = await readFile(statePath, 'utf-8');
93
+ const parsed = JSON.parse(raw);
94
+ expect(parsed.schemaVersion).toBe(GC_STATE_SCHEMA_VERSION);
95
+ });
96
+
97
+ it('writes valid JSON with correct schema version', async () => {
98
+ const statePath = join(tmpDir, 'gc-state.json');
99
+ const state = { ...DEFAULT_GC_STATE, daemonPid: 12345, lastRunAt: '2026-04-15T03:00:00Z' };
100
+
101
+ await writeGCState(statePath, state);
102
+
103
+ const raw = await readFile(statePath, 'utf-8');
104
+ const parsed = JSON.parse(raw);
105
+ expect(parsed.schemaVersion).toBe('1.0');
106
+ expect(parsed.daemonPid).toBe(12345);
107
+ expect(parsed.lastRunAt).toBe('2026-04-15T03:00:00Z');
108
+ });
109
+
110
+ it('overwrites existing state file', async () => {
111
+ const statePath = join(tmpDir, 'gc-state.json');
112
+ const initial = { ...DEFAULT_GC_STATE, consecutiveFailures: 1 };
113
+ await writeGCState(statePath, initial);
114
+
115
+ const updated = { ...DEFAULT_GC_STATE, consecutiveFailures: 0, lastRunAt: 'now' };
116
+ await writeGCState(statePath, updated);
117
+
118
+ const raw = await readFile(statePath, 'utf-8');
119
+ const parsed = JSON.parse(raw);
120
+ expect(parsed.consecutiveFailures).toBe(0);
121
+ expect(parsed.lastRunAt).toBe('now');
122
+ });
123
+ });
124
+
125
+ describe('patchGCState', () => {
126
+ let tmpDir: string;
127
+
128
+ beforeEach(async () => {
129
+ tmpDir = await mkdtemp(join(tmpdir(), 'cleo-gc-patch-test-'));
130
+ });
131
+
132
+ afterEach(async () => {
133
+ await rm(tmpDir, { recursive: true, force: true });
134
+ });
135
+
136
+ it('creates file with patch merged over defaults when file does not exist', async () => {
137
+ const statePath = join(tmpDir, 'gc-state.json');
138
+ const patched = await patchGCState(statePath, { daemonPid: 9999 });
139
+
140
+ expect(patched.daemonPid).toBe(9999);
141
+ // Other fields default
142
+ expect(patched.consecutiveFailures).toBe(0);
143
+ expect(patched.escalationNeeded).toBe(false);
144
+ });
145
+
146
+ it('preserves existing fields not in the patch', async () => {
147
+ const statePath = join(tmpDir, 'gc-state.json');
148
+ await writeGCState(statePath, { ...DEFAULT_GC_STATE, consecutiveFailures: 3, daemonPid: 100 });
149
+
150
+ const patched = await patchGCState(statePath, { escalationNeeded: true });
151
+
152
+ // Patched field updated
153
+ expect(patched.escalationNeeded).toBe(true);
154
+ // Unpatched fields preserved
155
+ expect(patched.consecutiveFailures).toBe(3);
156
+ expect(patched.daemonPid).toBe(100);
157
+ });
158
+
159
+ it('allows null to clear a field', async () => {
160
+ const statePath = join(tmpDir, 'gc-state.json');
161
+ await writeGCState(statePath, {
162
+ ...DEFAULT_GC_STATE,
163
+ pendingPrune: ['path/a', 'path/b'],
164
+ });
165
+
166
+ const patched = await patchGCState(statePath, { pendingPrune: null });
167
+ expect(patched.pendingPrune).toBeNull();
168
+ });
169
+ });
@@ -0,0 +1,371 @@
1
+ /**
2
+ * Transcript Lifecycle Tests (T735)
3
+ *
4
+ * Covers memory-architecture-spec.md §12.1 acceptance criteria:
5
+ * - TL-F5: prune --dry-run makes zero filesystem mutations
6
+ * - TL-F6: Budget cap triggers early prune when total size > threshold
7
+ * - TL-F7: API key absent falls back to 30d-only deletion
8
+ *
9
+ * Additional unit tests:
10
+ * - classifyTranscriptTier: correct hot/warm/cold at all boundary values
11
+ * - scanTranscripts: correct hot/warm counts for a known directory layout
12
+ * - pruneTranscripts: correct deletion with confirm=true
13
+ * - parseDurationMs: correct parsing of all supported formats
14
+ *
15
+ * Uses real temp directories (mkdtemp). No mocked filesystem.
16
+ *
17
+ * @task T735
18
+ * @epic T726
19
+ */
20
+
21
+ import { mkdir, mkdtemp, rm, utimes, writeFile } from 'node:fs/promises';
22
+ import { tmpdir } from 'node:os';
23
+ import { join } from 'node:path';
24
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
25
+
26
+ import {
27
+ classifyTranscriptTier,
28
+ parseDurationMs,
29
+ pruneTranscripts,
30
+ scanTranscripts,
31
+ } from '../transcript.js';
32
+
33
+ // ---------------------------------------------------------------------------
34
+ // Helpers
35
+ // ---------------------------------------------------------------------------
36
+
37
+ /**
38
+ * Create a fake session JSONL under `projectsDir/<slug>/`.
39
+ * Sets the mtime to `now - ageMs` so tier classification works correctly.
40
+ */
41
+ async function createSession(
42
+ projectsDir: string,
43
+ slug: string,
44
+ sessionId: string,
45
+ ageMs: number,
46
+ ): Promise<string> {
47
+ const slugDir = join(projectsDir, slug);
48
+ await mkdir(slugDir, { recursive: true });
49
+
50
+ const jsonlPath = join(slugDir, `${sessionId}.jsonl`);
51
+ await writeFile(jsonlPath, `{"type":"user","text":"session ${sessionId}"}\n`, 'utf-8');
52
+
53
+ // Back-date the mtime to simulate age
54
+ const pastDate = new Date(Date.now() - ageMs);
55
+ await utimes(jsonlPath, pastDate, pastDate);
56
+
57
+ return jsonlPath;
58
+ }
59
+
60
+ // ---------------------------------------------------------------------------
61
+ // classifyTranscriptTier
62
+ // ---------------------------------------------------------------------------
63
+
64
+ describe('classifyTranscriptTier', () => {
65
+ it('returns hot for age < 24h', () => {
66
+ expect(classifyTranscriptTier(0)).toBe('hot');
67
+ expect(classifyTranscriptTier(1 * 60 * 60 * 1000)).toBe('hot'); // 1h
68
+ expect(classifyTranscriptTier(23 * 60 * 60 * 1000)).toBe('hot'); // 23h
69
+ });
70
+
71
+ it('returns warm for age 24h–7d', () => {
72
+ const ONE_DAY = 24 * 60 * 60 * 1000;
73
+ const SEVEN_DAYS = 7 * ONE_DAY;
74
+ expect(classifyTranscriptTier(ONE_DAY)).toBe('warm');
75
+ expect(classifyTranscriptTier(ONE_DAY + 1000)).toBe('warm'); // just past 24h
76
+ expect(classifyTranscriptTier(3 * ONE_DAY)).toBe('warm'); // 3 days
77
+ expect(classifyTranscriptTier(SEVEN_DAYS - 1000)).toBe('warm'); // just under 7d
78
+ });
79
+
80
+ it('returns cold for age >= 7d', () => {
81
+ const SEVEN_DAYS = 7 * 24 * 60 * 60 * 1000;
82
+ expect(classifyTranscriptTier(SEVEN_DAYS)).toBe('cold');
83
+ expect(classifyTranscriptTier(SEVEN_DAYS + 1000)).toBe('cold');
84
+ expect(classifyTranscriptTier(30 * 24 * 60 * 60 * 1000)).toBe('cold'); // 30d
85
+ });
86
+ });
87
+
88
+ // ---------------------------------------------------------------------------
89
+ // parseDurationMs
90
+ // ---------------------------------------------------------------------------
91
+
92
+ describe('parseDurationMs', () => {
93
+ it('parses days correctly', () => {
94
+ expect(parseDurationMs('7d')).toBe(7 * 24 * 60 * 60 * 1000);
95
+ expect(parseDurationMs('1d')).toBe(24 * 60 * 60 * 1000);
96
+ expect(parseDurationMs('30d')).toBe(30 * 24 * 60 * 60 * 1000);
97
+ });
98
+
99
+ it('parses hours correctly', () => {
100
+ expect(parseDurationMs('24h')).toBe(24 * 60 * 60 * 1000);
101
+ expect(parseDurationMs('1h')).toBe(60 * 60 * 1000);
102
+ expect(parseDurationMs('168h')).toBe(168 * 60 * 60 * 1000);
103
+ });
104
+
105
+ it('parses minutes correctly', () => {
106
+ expect(parseDurationMs('30m')).toBe(30 * 60 * 1000);
107
+ expect(parseDurationMs('1m')).toBe(60 * 1000);
108
+ });
109
+
110
+ it('parses seconds correctly', () => {
111
+ expect(parseDurationMs('60s')).toBe(60 * 1000);
112
+ });
113
+
114
+ it('throws on invalid format', () => {
115
+ expect(() => parseDurationMs('invalid')).toThrow();
116
+ expect(() => parseDurationMs('7')).toThrow();
117
+ expect(() => parseDurationMs('')).toThrow();
118
+ expect(() => parseDurationMs('7w')).toThrow(); // weeks not supported
119
+ });
120
+ });
121
+
122
+ // ---------------------------------------------------------------------------
123
+ // scanTranscripts — correct hot/warm counts
124
+ // ---------------------------------------------------------------------------
125
+
126
+ describe('scanTranscripts', () => {
127
+ let tmpDir: string;
128
+ let projectsDir: string;
129
+
130
+ beforeEach(async () => {
131
+ tmpDir = await mkdtemp(join(tmpdir(), 'cleo-transcript-scan-'));
132
+ projectsDir = join(tmpDir, 'projects');
133
+ await mkdir(projectsDir, { recursive: true });
134
+ });
135
+
136
+ afterEach(async () => {
137
+ await rm(tmpDir, { recursive: true, force: true });
138
+ });
139
+
140
+ it('returns empty result when projects dir does not exist', async () => {
141
+ const result = await scanTranscripts(join(tmpDir, 'nonexistent'));
142
+ expect(result.totalSessions).toBe(0);
143
+ expect(result.hot).toHaveLength(0);
144
+ expect(result.warm).toHaveLength(0);
145
+ });
146
+
147
+ it('correctly identifies hot sessions (< 24h)', async () => {
148
+ // 1 hour old → hot
149
+ await createSession(projectsDir, 'proj', 'hot-session', 1 * 60 * 60 * 1000);
150
+
151
+ const result = await scanTranscripts(projectsDir);
152
+
153
+ expect(result.hot).toHaveLength(1);
154
+ expect(result.warm).toHaveLength(0);
155
+ expect(result.hot[0]?.sessionId).toBe('hot-session');
156
+ expect(result.hot[0]?.tier).toBe('hot');
157
+ });
158
+
159
+ it('correctly identifies warm sessions (1–7d)', async () => {
160
+ // 3 days old → warm
161
+ await createSession(projectsDir, 'proj', 'warm-session', 3 * 24 * 60 * 60 * 1000);
162
+
163
+ const result = await scanTranscripts(projectsDir);
164
+
165
+ expect(result.warm).toHaveLength(1);
166
+ expect(result.hot).toHaveLength(0);
167
+ expect(result.warm[0]?.sessionId).toBe('warm-session');
168
+ expect(result.warm[0]?.tier).toBe('warm');
169
+ });
170
+
171
+ it('cold sessions (>7d, already deleted) do not appear in scan', async () => {
172
+ // Create a file but COLD sessions should have already been deleted from disk
173
+ // We verify cold sessions don't appear in warm/hot lists
174
+ const result = await scanTranscripts(projectsDir);
175
+ // No sessions created yet → all empty
176
+ expect(result.totalSessions).toBe(0);
177
+ });
178
+
179
+ it('counts sessions across multiple projects', async () => {
180
+ await createSession(projectsDir, 'proj-a', 'session-1', 1 * 60 * 60 * 1000); // hot
181
+ await createSession(projectsDir, 'proj-a', 'session-2', 2 * 24 * 60 * 60 * 1000); // warm
182
+ await createSession(projectsDir, 'proj-b', 'session-3', 5 * 24 * 60 * 60 * 1000); // warm
183
+
184
+ const result = await scanTranscripts(projectsDir);
185
+
186
+ expect(result.totalSessions).toBe(3);
187
+ expect(result.hot).toHaveLength(1);
188
+ expect(result.warm).toHaveLength(2);
189
+ });
190
+
191
+ it('totalBytes is the sum of all session file sizes', async () => {
192
+ await createSession(projectsDir, 'proj', 'size-session', 1 * 60 * 60 * 1000);
193
+
194
+ const result = await scanTranscripts(projectsDir);
195
+ expect(result.totalBytes).toBeGreaterThan(0);
196
+ });
197
+ });
198
+
199
+ // ---------------------------------------------------------------------------
200
+ // TL-F5: prune --dry-run makes zero filesystem mutations
201
+ // ---------------------------------------------------------------------------
202
+
203
+ describe('pruneTranscripts dry-run (TL-F5)', () => {
204
+ let tmpDir: string;
205
+ let projectsDir: string;
206
+
207
+ beforeEach(async () => {
208
+ tmpDir = await mkdtemp(join(tmpdir(), 'cleo-transcript-prune-dr-'));
209
+ projectsDir = join(tmpDir, 'projects');
210
+ await mkdir(projectsDir, { recursive: true });
211
+ });
212
+
213
+ afterEach(async () => {
214
+ await rm(tmpDir, { recursive: true, force: true });
215
+ });
216
+
217
+ it('dry-run (confirm=false) makes zero filesystem mutations', async () => {
218
+ // Use 35d to be beyond the 30d API-key-absent circuit breaker minimum
219
+ const OLD_SESSION_AGE = 35 * 24 * 60 * 60 * 1000; // 35 days
220
+ const jsonlPath = await createSession(projectsDir, 'proj', 'old-session', OLD_SESSION_AGE);
221
+
222
+ const result = await pruneTranscripts({
223
+ olderThanMs: 7 * 24 * 60 * 60 * 1000, // 7d threshold
224
+ confirm: false, // dry-run
225
+ projectsDir,
226
+ });
227
+
228
+ // File must still exist after dry-run
229
+ const { access } = await import('node:fs/promises');
230
+ await expect(access(jsonlPath)).resolves.toBeUndefined();
231
+
232
+ expect(result.dryRun).toBe(true);
233
+ expect(result.pruned).toBeGreaterThan(0);
234
+ expect(result.deletedPaths).toContain(jsonlPath);
235
+ });
236
+
237
+ it('dry-run reports correct byte count', async () => {
238
+ const content = 'x'.repeat(1024); // 1 KB
239
+ const slugDir = join(projectsDir, 'proj');
240
+ await mkdir(slugDir, { recursive: true });
241
+ const jsonlPath = join(slugDir, 'big-session.jsonl');
242
+ await writeFile(jsonlPath, content, 'utf-8');
243
+ // Use 35d to exceed the 30d API-key-absent circuit breaker minimum
244
+ const past = new Date(Date.now() - 35 * 24 * 60 * 60 * 1000);
245
+ await utimes(jsonlPath, past, past);
246
+
247
+ const result = await pruneTranscripts({
248
+ olderThanMs: 7 * 24 * 60 * 60 * 1000,
249
+ confirm: false,
250
+ projectsDir,
251
+ });
252
+
253
+ expect(result.bytesFreed).toBeGreaterThanOrEqual(1024);
254
+ });
255
+ });
256
+
257
+ // ---------------------------------------------------------------------------
258
+ // pruneTranscripts with confirm=true (actual deletion)
259
+ // ---------------------------------------------------------------------------
260
+
261
+ describe('pruneTranscripts confirm=true', () => {
262
+ let tmpDir: string;
263
+ let projectsDir: string;
264
+
265
+ beforeEach(async () => {
266
+ tmpDir = await mkdtemp(join(tmpdir(), 'cleo-transcript-prune-'));
267
+ projectsDir = join(tmpDir, 'projects');
268
+ await mkdir(projectsDir, { recursive: true });
269
+ });
270
+
271
+ afterEach(async () => {
272
+ await rm(tmpDir, { recursive: true, force: true });
273
+ });
274
+
275
+ it('deletes sessions older than the threshold', async () => {
276
+ // Use 35d to be beyond the 30d API-key-absent circuit breaker minimum
277
+ const OLD_AGE = 35 * 24 * 60 * 60 * 1000; // 35 days
278
+ const jsonlPath = await createSession(projectsDir, 'proj', 'old-del', OLD_AGE);
279
+
280
+ const result = await pruneTranscripts({
281
+ olderThanMs: 7 * 24 * 60 * 60 * 1000,
282
+ confirm: true,
283
+ projectsDir,
284
+ });
285
+
286
+ // File must be gone
287
+ const { access } = await import('node:fs/promises');
288
+ await expect(access(jsonlPath)).rejects.toThrow();
289
+
290
+ expect(result.dryRun).toBe(false);
291
+ expect(result.pruned).toBeGreaterThan(0);
292
+ });
293
+
294
+ it('does NOT delete sessions younger than the threshold', async () => {
295
+ const RECENT_AGE = 1 * 24 * 60 * 60 * 1000; // 1 day
296
+ const jsonlPath = await createSession(projectsDir, 'proj', 'recent-keep', RECENT_AGE);
297
+
298
+ await pruneTranscripts({
299
+ olderThanMs: 7 * 24 * 60 * 60 * 1000,
300
+ confirm: true,
301
+ projectsDir,
302
+ });
303
+
304
+ // Recent file must still exist
305
+ const { access } = await import('node:fs/promises');
306
+ await expect(access(jsonlPath)).resolves.toBeUndefined();
307
+ });
308
+ });
309
+
310
+ // ---------------------------------------------------------------------------
311
+ // TL-F7: ANTHROPIC_API_KEY absent → 30d-only deletion (circuit breaker)
312
+ // ---------------------------------------------------------------------------
313
+
314
+ describe('pruneTranscripts API key circuit breaker (TL-F7)', () => {
315
+ let tmpDir: string;
316
+ let projectsDir: string;
317
+ let savedApiKey: string | undefined;
318
+
319
+ beforeEach(async () => {
320
+ tmpDir = await mkdtemp(join(tmpdir(), 'cleo-transcript-apikey-'));
321
+ projectsDir = join(tmpDir, 'projects');
322
+ await mkdir(projectsDir, { recursive: true });
323
+ // Save and clear API key
324
+ savedApiKey = process.env['ANTHROPIC_API_KEY'];
325
+ delete process.env['ANTHROPIC_API_KEY'];
326
+ });
327
+
328
+ afterEach(async () => {
329
+ await rm(tmpDir, { recursive: true, force: true });
330
+ // Restore API key
331
+ if (savedApiKey !== undefined) {
332
+ process.env['ANTHROPIC_API_KEY'] = savedApiKey;
333
+ }
334
+ });
335
+
336
+ it('skips deletion of sessions < 30d when API key is absent', async () => {
337
+ // 10-day-old session; caller requests 7d threshold
338
+ const TEN_DAYS = 10 * 24 * 60 * 60 * 1000;
339
+ const jsonlPath = await createSession(projectsDir, 'proj', 'apikey-test', TEN_DAYS);
340
+
341
+ const result = await pruneTranscripts({
342
+ olderThanMs: 7 * 24 * 60 * 60 * 1000, // 7d requested
343
+ confirm: true,
344
+ projectsDir,
345
+ });
346
+
347
+ // Circuit breaker: no API key → 30d minimum. 10-day session is NOT deleted.
348
+ const { access } = await import('node:fs/promises');
349
+ await expect(access(jsonlPath)).resolves.toBeUndefined();
350
+
351
+ expect(result.pruned).toBe(0);
352
+ });
353
+
354
+ it('allows deletion of sessions > 30d even without API key', async () => {
355
+ // 35-day-old session — beyond the 30d circuit-breaker minimum
356
+ const THIRTY_FIVE_DAYS = 35 * 24 * 60 * 60 * 1000;
357
+ const jsonlPath = await createSession(projectsDir, 'proj', 'old-apikey', THIRTY_FIVE_DAYS);
358
+
359
+ const result = await pruneTranscripts({
360
+ olderThanMs: 7 * 24 * 60 * 60 * 1000,
361
+ confirm: true,
362
+ projectsDir,
363
+ });
364
+
365
+ // 35-day session IS deleted (beyond 30d fallback minimum)
366
+ const { access } = await import('node:fs/promises');
367
+ await expect(access(jsonlPath)).rejects.toThrow();
368
+
369
+ expect(result.pruned).toBeGreaterThan(0);
370
+ });
371
+ });
@@ -0,0 +1,26 @@
1
+ /**
2
+ * GC Daemon Entry Point — Standalone script executed by `spawnGCDaemon()`.
3
+ *
4
+ * This script is spawned as a detached background process by `cleo daemon start`.
5
+ * It must NOT import from the main CLI shim (no citty, no commander). It only
6
+ * imports from the gc/ module subtree.
7
+ *
8
+ * The cleoDir is passed as argv[2] by `spawnGCDaemon()`.
9
+ *
10
+ * @see gc/daemon.ts for the spawn logic
11
+ * @task T731
12
+ * @epic T726
13
+ */
14
+
15
+ import { homedir } from 'node:os';
16
+ import { join } from 'node:path';
17
+ import { bootstrapDaemon } from './daemon.js';
18
+
19
+ const cleoDir = process.argv[2] ?? join(homedir(), '.cleo');
20
+
21
+ bootstrapDaemon(cleoDir).catch((err: unknown) => {
22
+ const message = err instanceof Error ? err.message : String(err);
23
+ // stderr is redirected to gc.err by the parent spawn call
24
+ process.stderr.write(`[CLEO GC] Fatal daemon error: ${message}\n`);
25
+ process.exit(1);
26
+ });