@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,251 @@
1
+ /**
2
+ * GC Daemon — Sidecar background process for autonomous transcript cleanup.
3
+ *
4
+ * Architecture (Pattern B from T751 §2.2):
5
+ * - Spawned via `cleo daemon start` as a detached Node.js process
6
+ * - All three required flags: `detached: true`, file stdio, `child.unref()`
7
+ * - Persists across CLI invocations
8
+ * - Crash recovery via `.cleo/gc-state.json` startup-check
9
+ * - node-cron v4 for scheduling (zero runtime deps, cross-platform)
10
+ *
11
+ * Startup algorithm (systemd `Persistent=true` semantics in pure Node.js):
12
+ * 1. Read gc-state.json
13
+ * 2. If pendingPrune non-empty → resume deletion (crash recovery)
14
+ * 3. If lastRunAt null OR elapsed > 24h → run GC immediately (missed-run recovery)
15
+ * 4. Schedule future runs via node-cron (daily at 03:00 UTC)
16
+ * 5. Write daemonPid to state
17
+ *
18
+ * @see ADR-047 — Autonomous GC and Disk Safety
19
+ * @see T751 §2.2 for sidecar daemon pattern rationale
20
+ * @task T731
21
+ * @epic T726
22
+ */
23
+
24
+ import { spawn } from 'node:child_process';
25
+ import { createWriteStream } from 'node:fs';
26
+ import { mkdir } from 'node:fs/promises';
27
+ import { join } from 'node:path';
28
+ import { fileURLToPath } from 'node:url';
29
+ import cron from 'node-cron';
30
+ import { runGC } from './runner.js';
31
+ import { patchGCState, readGCState } from './state.js';
32
+
33
+ // ---------------------------------------------------------------------------
34
+ // Constants
35
+ // ---------------------------------------------------------------------------
36
+
37
+ /** Cron expression: daily at 03:00 UTC. */
38
+ const GC_CRON_EXPR = '0 3 * * *';
39
+
40
+ /** Interval for missed-run recovery check (24 hours in ms). */
41
+ const GC_INTERVAL_MS = 24 * 60 * 60 * 1000;
42
+
43
+ // ---------------------------------------------------------------------------
44
+ // Daemon Bootstrap (runs when this module is executed as a standalone script)
45
+ // ---------------------------------------------------------------------------
46
+
47
+ /**
48
+ * Bootstrap the GC daemon process.
49
+ *
50
+ * Performs crash recovery, missed-run recovery, and schedules future GC runs.
51
+ * This function runs in the long-lived daemon process.
52
+ *
53
+ * @param cleoDir - Absolute path to the `.cleo/` directory
54
+ */
55
+ export async function bootstrapDaemon(cleoDir: string): Promise<void> {
56
+ const statePath = join(cleoDir, 'gc-state.json');
57
+
58
+ // Register daemon PID in state file
59
+ await patchGCState(statePath, {
60
+ daemonPid: process.pid,
61
+ daemonStartedAt: new Date().toISOString(),
62
+ });
63
+
64
+ const state = await readGCState(statePath);
65
+
66
+ // Step 1: Crash recovery — resume pending prune from prior run
67
+ if (state.pendingPrune && state.pendingPrune.length > 0) {
68
+ try {
69
+ await runGC({ cleoDir, resumeFrom: state.pendingPrune });
70
+ } catch {
71
+ // Crash recovery failure is non-fatal; continue with scheduled runs
72
+ }
73
+ }
74
+
75
+ // Step 2: Missed-run recovery — if last run was > 24h ago, run immediately
76
+ const lastRunTs = state.lastRunAt ? new Date(state.lastRunAt).getTime() : 0;
77
+ const elapsed = Date.now() - lastRunTs;
78
+ if (elapsed > GC_INTERVAL_MS) {
79
+ try {
80
+ await runGC({ cleoDir });
81
+ } catch {
82
+ // Immediate GC failure is non-fatal; cron will retry next cycle
83
+ }
84
+ }
85
+
86
+ // Step 3: Schedule future runs via node-cron
87
+ // noOverlap: true prevents double-runs if a previous run exceeds 24h
88
+ cron.schedule(
89
+ GC_CRON_EXPR,
90
+ async () => {
91
+ try {
92
+ await runGC({ cleoDir });
93
+ } catch {
94
+ // Log failures via stderr (already redirected to gc.log by spawn)
95
+ const state2 = await readGCState(statePath);
96
+ await patchGCState(statePath, {
97
+ consecutiveFailures: state2.consecutiveFailures + 1,
98
+ lastRunResult: 'failed',
99
+ escalationNeeded: state2.consecutiveFailures + 1 >= 3,
100
+ escalationReason:
101
+ state2.consecutiveFailures + 1 >= 3
102
+ ? `GC daemon: ${state2.consecutiveFailures + 1} consecutive failures. Check logs.`
103
+ : state2.escalationReason,
104
+ });
105
+ }
106
+ },
107
+ {
108
+ timezone: 'UTC',
109
+ noOverlap: true,
110
+ name: 'cleo-gc',
111
+ },
112
+ );
113
+ }
114
+
115
+ // ---------------------------------------------------------------------------
116
+ // Spawn Helpers (called by `cleo daemon start` in the parent CLI process)
117
+ // ---------------------------------------------------------------------------
118
+
119
+ /**
120
+ * Spawn the GC daemon as a detached background process.
121
+ *
122
+ * All three requirements from T751 §2.2 are met:
123
+ * 1. `detached: true` — process group leader (survives parent exit)
124
+ * 2. File stdio — stdout/stderr redirected to gc.log (not inherited)
125
+ * 3. `child.unref()` — parent CLI exits immediately
126
+ *
127
+ * @param cleoDir - Absolute path to the `.cleo/` directory
128
+ * @returns PID of the spawned daemon process
129
+ */
130
+ export async function spawnGCDaemon(cleoDir: string): Promise<number> {
131
+ const logsDir = join(cleoDir, 'logs');
132
+ await mkdir(logsDir, { recursive: true });
133
+
134
+ const logPath = join(logsDir, 'gc.log');
135
+ const errPath = join(logsDir, 'gc.err');
136
+
137
+ // File-based stdio: required for detached process to not inherit the TTY
138
+ const outStream = createWriteStream(logPath, { flags: 'a' });
139
+ const errStream = createWriteStream(errPath, { flags: 'a' });
140
+
141
+ // The daemon entry-point script (compiled alongside this module)
142
+ const daemonEntry = join(fileURLToPath(import.meta.url), '..', 'daemon-entry.js');
143
+
144
+ const child = spawn(process.execPath, [daemonEntry, cleoDir], {
145
+ detached: true,
146
+ stdio: ['ignore', outStream, errStream],
147
+ env: { ...process.env, CLEO_GC_DAEMON: '1' },
148
+ });
149
+
150
+ // unref() allows the parent CLI process to exit while the daemon continues
151
+ child.unref();
152
+
153
+ const pid = child.pid ?? 0;
154
+
155
+ // Persist PID so `cleo daemon stop` can find and signal the process
156
+ await patchGCState(join(cleoDir, 'gc-state.json'), {
157
+ daemonPid: pid,
158
+ daemonStartedAt: new Date().toISOString(),
159
+ });
160
+
161
+ return pid;
162
+ }
163
+
164
+ /**
165
+ * Stop the GC daemon by sending SIGTERM to its PID.
166
+ *
167
+ * Uses `process.kill(pid, 0)` as a no-throw liveness probe before signalling.
168
+ *
169
+ * @param cleoDir - Absolute path to the `.cleo/` directory
170
+ * @returns `{ stopped: boolean; pid: number | null; reason: string }`
171
+ */
172
+ export async function stopGCDaemon(
173
+ cleoDir: string,
174
+ ): Promise<{ stopped: boolean; pid: number | null; reason: string }> {
175
+ const statePath = join(cleoDir, 'gc-state.json');
176
+ const state = await readGCState(statePath);
177
+ const pid = state.daemonPid;
178
+
179
+ if (!pid) {
180
+ return { stopped: false, pid: null, reason: 'Daemon PID not found in gc-state.json' };
181
+ }
182
+
183
+ // Liveness probe: process.kill(pid, 0) throws if PID is not running
184
+ try {
185
+ process.kill(pid, 0);
186
+ } catch {
187
+ // Process is not running — clear stale PID from state
188
+ await patchGCState(statePath, { daemonPid: null });
189
+ return {
190
+ stopped: false,
191
+ pid,
192
+ reason: `Daemon PID ${pid} is not running (stale state cleared)`,
193
+ };
194
+ }
195
+
196
+ // Send SIGTERM — daemon should clean up and exit gracefully
197
+ try {
198
+ process.kill(pid, 'SIGTERM');
199
+ // Clear PID from state after successful signal
200
+ await patchGCState(statePath, { daemonPid: null });
201
+ return { stopped: true, pid, reason: `SIGTERM sent to PID ${pid}` };
202
+ } catch (err) {
203
+ const msg = err instanceof Error ? err.message : String(err);
204
+ return { stopped: false, pid, reason: `Failed to send SIGTERM to PID ${pid}: ${msg}` };
205
+ }
206
+ }
207
+
208
+ /**
209
+ * Check whether the GC daemon is currently running.
210
+ *
211
+ * @param cleoDir - Absolute path to the `.cleo/` directory
212
+ * @returns `{ running: boolean; pid: number | null; startedAt: string | null }`
213
+ */
214
+ export async function getGCDaemonStatus(cleoDir: string): Promise<{
215
+ running: boolean;
216
+ pid: number | null;
217
+ startedAt: string | null;
218
+ lastRunAt: string | null;
219
+ lastDiskUsedPct: number | null;
220
+ escalationNeeded: boolean;
221
+ }> {
222
+ const state = await readGCState(join(cleoDir, 'gc-state.json'));
223
+ const pid = state.daemonPid;
224
+
225
+ let running = false;
226
+ if (pid) {
227
+ try {
228
+ process.kill(pid, 0);
229
+ running = true;
230
+ } catch {
231
+ running = false;
232
+ }
233
+ }
234
+
235
+ return {
236
+ running,
237
+ pid: running ? pid : null,
238
+ startedAt: state.daemonStartedAt,
239
+ lastRunAt: state.lastRunAt,
240
+ lastDiskUsedPct: state.lastDiskUsedPct,
241
+ escalationNeeded: state.escalationNeeded,
242
+ };
243
+ }
244
+
245
+ // ---------------------------------------------------------------------------
246
+ // Standalone daemon entry point
247
+ // ---------------------------------------------------------------------------
248
+
249
+ // When this module is executed directly (via `node daemon.js <cleoDir>`),
250
+ // bootstrap the daemon. The daemon-entry.js shim calls bootstrapDaemon().
251
+ // See src/gc/daemon-entry.ts for the entry script.
@@ -0,0 +1,14 @@
1
+ /**
2
+ * @cleocode/core/gc — Autonomous GC daemon public API.
3
+ *
4
+ * Provides transcript cleanup, disk-pressure monitoring, GC state
5
+ * management, and daemon lifecycle (spawn/stop/status).
6
+ *
7
+ * @see ADR-047 — Autonomous GC and Disk Safety
8
+ * @package @cleocode/core
9
+ */
10
+
11
+ export * from './daemon.js';
12
+ export * from './runner.js';
13
+ export * from './state.js';
14
+ export * from './transcript.js';
@@ -0,0 +1,378 @@
1
+ /**
2
+ * GC Runner — Core garbage collection logic for autonomous transcript cleanup.
3
+ *
4
+ * Performs disk-pressure-aware pruning of ephemeral transcript and temp files
5
+ * under `~/.claude/projects/` using the five-tier threshold model from T751.
6
+ *
7
+ * Retention policy (per ADR-047 and docs/specs/memory-architecture-spec.md §8):
8
+ * - `.temp/` files: 24h normal, 1h emergency
9
+ * - Transcript directories (agent-*.jsonl, tool-results/): 7d normal, 1d emergency
10
+ * - `.cleo/logs/`: 30d normal, 7d emergency
11
+ * - `.cleo/agent-outputs/*.md` (committed artifacts): NEVER auto-pruned
12
+ *
13
+ * Circuit breaker: if `ANTHROPIC_API_KEY` is absent AND no local model configured,
14
+ * skip extraction and only delete transcripts older than 30 days.
15
+ *
16
+ * @see ADR-047 — Autonomous GC and Disk Safety
17
+ * @see docs/specs/memory-architecture-spec.md §8
18
+ * @task T731
19
+ * @epic T726
20
+ */
21
+
22
+ import { lstat, readdir, rm, stat } from 'node:fs/promises';
23
+ import { homedir } from 'node:os';
24
+ import { join } from 'node:path';
25
+ import checkDiskSpaceModule from 'check-disk-space';
26
+
27
+ /**
28
+ * Checks free + total bytes on the filesystem containing the given path.
29
+ *
30
+ * `check-disk-space@3.4.0` publishes its function as `export { x as default }`
31
+ * in the .d.ts, which TS 6.0 strict resolution treats as a namespaced re-export
32
+ * rather than a callable default. The runtime module itself exposes a callable;
33
+ * we bridge the type gap by typing `checkDiskSpaceModule` as the callable
34
+ * shape at the single import boundary.
35
+ */
36
+ const checkDiskSpace = checkDiskSpaceModule as unknown as (path: string) => Promise<{
37
+ diskPath: string;
38
+ free: number;
39
+ size: number;
40
+ }>;
41
+
42
+ import { patchGCState, readGCState } from './state.js';
43
+
44
+ // ---------------------------------------------------------------------------
45
+ // Threshold Tiers (from T751 §3.2 and ADR-047)
46
+ // ---------------------------------------------------------------------------
47
+
48
+ /**
49
+ * Disk usage percentage thresholds.
50
+ *
51
+ * Values mirror the five-tier model recommended by T751 research §3.2:
52
+ * - OK: < 70% — routine cleanup by age policy only
53
+ * - WATCH: 70-85% — log + schedule next GC sooner
54
+ * - WARN: 85-90% — log + set escalation flag for next CLI invocation
55
+ * - URGENT: 90-95% — auto-prune oldest transcripts immediately
56
+ * - EMERGENCY: ≥ 95% — auto-prune all transcripts > 1d, pause new writes
57
+ */
58
+ export const DISK_THRESHOLDS = {
59
+ WATCH: 70,
60
+ WARN: 85,
61
+ URGENT: 90,
62
+ EMERGENCY: 95,
63
+ } as const;
64
+
65
+ /** Human-readable tier labels. */
66
+ export type DiskTier = 'ok' | 'watch' | 'warn' | 'urgent' | 'emergency';
67
+
68
+ /**
69
+ * Result of a single GC run.
70
+ */
71
+ export interface GCResult {
72
+ /** Disk usage percentage at time of GC run (0–100). */
73
+ diskUsedPct: number;
74
+ /** Disk tier classification. */
75
+ threshold: DiskTier;
76
+ /** Files pruned during this run. */
77
+ pruned: Array<{ path: string; bytes: number }>;
78
+ /** Total bytes freed. */
79
+ bytesFreed: number;
80
+ /** Whether escalation flag was set (disk ≥ WARN). */
81
+ escalationSet: boolean;
82
+ /** Human-readable escalation reason (set when escalationSet=true). */
83
+ escalationReason: string | null;
84
+ /** ISO-8601 timestamp of run completion. */
85
+ completedAt: string;
86
+ }
87
+
88
+ /**
89
+ * Options for a GC run.
90
+ */
91
+ export interface GCRunOptions {
92
+ /**
93
+ * Absolute path to the `.cleo/` directory (used for state file and disk check).
94
+ * Defaults to `~/.cleo`.
95
+ */
96
+ cleoDir?: string;
97
+ /**
98
+ * Override the default `~/.claude/projects/` scan directory.
99
+ * Primarily used in tests to point at a temp directory.
100
+ */
101
+ projectsDir?: string;
102
+ /**
103
+ * Paths from a previous crashed run to resume deletion from.
104
+ * Written to `pendingPrune` in gc-state.json BEFORE starting deletion.
105
+ */
106
+ resumeFrom?: string[];
107
+ /**
108
+ * Dry-run mode: compute what would be pruned, but make zero filesystem changes.
109
+ */
110
+ dryRun?: boolean;
111
+ }
112
+
113
+ // ---------------------------------------------------------------------------
114
+ // Helpers
115
+ // ---------------------------------------------------------------------------
116
+
117
+ /**
118
+ * Classify a disk usage percentage into a tier.
119
+ *
120
+ * @param pct - Disk usage percentage (0–100)
121
+ * @returns DiskTier
122
+ */
123
+ export function classifyDiskTier(pct: number): DiskTier {
124
+ if (pct >= DISK_THRESHOLDS.EMERGENCY) return 'emergency';
125
+ if (pct >= DISK_THRESHOLDS.URGENT) return 'urgent';
126
+ if (pct >= DISK_THRESHOLDS.WARN) return 'warn';
127
+ if (pct >= DISK_THRESHOLDS.WATCH) return 'watch';
128
+ return 'ok';
129
+ }
130
+
131
+ /**
132
+ * Compute retention threshold in milliseconds based on disk tier.
133
+ *
134
+ * Higher disk pressure → shorter retention → more aggressive pruning.
135
+ *
136
+ * @param tier - Current disk tier
137
+ * @returns Maximum age in milliseconds for transcript retention
138
+ */
139
+ export function retentionMs(tier: DiskTier): number {
140
+ switch (tier) {
141
+ case 'emergency':
142
+ return 1 * 24 * 60 * 60 * 1000; // 1 day
143
+ case 'urgent':
144
+ return 3 * 24 * 60 * 60 * 1000; // 3 days
145
+ case 'warn':
146
+ return 7 * 24 * 60 * 60 * 1000; // 7 days
147
+ default:
148
+ return 30 * 24 * 60 * 60 * 1000; // 30 days (watch + ok)
149
+ }
150
+ }
151
+
152
+ /**
153
+ * Get the size of a path in bytes (file or directory recursively).
154
+ * Returns 0 if the path does not exist.
155
+ *
156
+ * @param targetPath - Path to measure
157
+ * @returns Size in bytes
158
+ */
159
+ export async function getPathBytes(targetPath: string): Promise<number> {
160
+ try {
161
+ const info = await lstat(targetPath);
162
+ if (info.isFile()) return info.size;
163
+ if (!info.isDirectory()) return 0;
164
+
165
+ const entries = await readdir(targetPath, { withFileTypes: true });
166
+ let total = 0;
167
+ for (const entry of entries) {
168
+ total += await getPathBytes(join(targetPath, entry.name));
169
+ }
170
+ return total;
171
+ } catch {
172
+ return 0;
173
+ }
174
+ }
175
+
176
+ /**
177
+ * Idempotently delete a path (file or directory).
178
+ *
179
+ * Silently ignores ENOENT — safe to call if path was already deleted.
180
+ * Uses `force: true` to suppress errors on missing paths.
181
+ *
182
+ * @param targetPath - Path to delete
183
+ */
184
+ export async function idempotentRm(targetPath: string): Promise<void> {
185
+ try {
186
+ await rm(targetPath, { recursive: true, force: true });
187
+ } catch (err) {
188
+ const nodeErr = err as NodeJS.ErrnoException;
189
+ if (nodeErr.code === 'ENOENT') return; // already gone — idempotent
190
+ throw err;
191
+ }
192
+ }
193
+
194
+ /**
195
+ * Gather transcript session directories under `~/.claude/projects/` that are
196
+ * older than `maxAgeMs`.
197
+ *
198
+ * Only session UUID directories are candidates (not the root JSONL files —
199
+ * those are the main transcript). The `tool-results/` subdirectory within a
200
+ * session directory is always included in the prune candidate once the session
201
+ * is old enough.
202
+ *
203
+ * Committed artifact files (`.cleo/agent-outputs/*.md`) are NEVER included.
204
+ *
205
+ * @param maxAgeMs - Maximum age in ms; sessions older than this are candidates
206
+ * @returns Array of absolute directory paths eligible for pruning
207
+ */
208
+ async function gatherPruneCandidates(maxAgeMs: number, projectsDir?: string): Promise<string[]> {
209
+ const resolvedProjectsDir = projectsDir ?? join(homedir(), '.claude', 'projects');
210
+ const candidates: string[] = [];
211
+ const now = Date.now();
212
+
213
+ let projectSlugs: string[];
214
+ try {
215
+ const entries = await readdir(resolvedProjectsDir, { withFileTypes: true });
216
+ projectSlugs = entries.filter((e) => e.isDirectory()).map((e) => e.name);
217
+ } catch {
218
+ // ~/.claude/projects/ doesn't exist yet
219
+ return candidates;
220
+ }
221
+
222
+ for (const slug of projectSlugs) {
223
+ const slugDir = join(resolvedProjectsDir, slug);
224
+
225
+ // Collect root JSONL files (HOT/WARM main session transcripts)
226
+ let slugEntries: import('fs').Dirent[];
227
+ try {
228
+ slugEntries = await readdir(slugDir, { withFileTypes: true });
229
+ } catch {
230
+ continue;
231
+ }
232
+
233
+ for (const entry of slugEntries) {
234
+ const entryPath = join(slugDir, entry.name);
235
+
236
+ if (entry.isFile() && entry.name.endsWith('.jsonl')) {
237
+ // Root-level session JSONL — check age
238
+ try {
239
+ const info = await stat(entryPath);
240
+ const ageMs = now - info.mtimeMs;
241
+ if (ageMs > maxAgeMs) {
242
+ candidates.push(entryPath);
243
+ }
244
+ } catch {
245
+ // File disappeared between readdir and stat — skip
246
+ }
247
+ } else if (entry.isDirectory()) {
248
+ // Session UUID directory — check mtime of the directory itself
249
+ try {
250
+ const info = await stat(entryPath);
251
+ const ageMs = now - info.mtimeMs;
252
+ if (ageMs > maxAgeMs) {
253
+ candidates.push(entryPath);
254
+ }
255
+ } catch {
256
+ // Directory disappeared — skip
257
+ }
258
+ }
259
+ }
260
+ }
261
+
262
+ return candidates;
263
+ }
264
+
265
+ // ---------------------------------------------------------------------------
266
+ // Main GC Runner
267
+ // ---------------------------------------------------------------------------
268
+
269
+ /**
270
+ * Execute a GC run: check disk pressure, determine retention threshold,
271
+ * prune eligible transcript files, update gc-state.json.
272
+ *
273
+ * This function is idempotent and safe to call multiple times. Crash recovery
274
+ * is implemented via the `pendingPrune` field in gc-state.json:
275
+ * 1. Write paths to `pendingPrune` BEFORE starting deletion
276
+ * 2. Remove each path from `pendingPrune` AFTER successful deletion
277
+ * 3. Clear `pendingPrune` when the job completes
278
+ *
279
+ * @param opts - GC run options
280
+ * @returns GC run results
281
+ */
282
+ export async function runGC(opts: GCRunOptions = {}): Promise<GCResult> {
283
+ const cleoDir = opts.cleoDir ?? join(homedir(), '.cleo');
284
+ const statePath = join(cleoDir, 'gc-state.json');
285
+ const dryRun = opts.dryRun ?? false;
286
+ const projectsDir = opts.projectsDir;
287
+
288
+ // Step 1: Crash recovery — resume any pending prune from prior run
289
+ const initialState = await readGCState(statePath);
290
+ const resumePaths = opts.resumeFrom ?? initialState.pendingPrune ?? [];
291
+
292
+ // Step 2: Check disk space on the filesystem containing .cleo/
293
+ let diskUsedPct = 0;
294
+ try {
295
+ const { free, size } = await checkDiskSpace(cleoDir);
296
+ diskUsedPct = size > 0 ? ((size - free) / size) * 100 : 0;
297
+ } catch {
298
+ // Disk check failure is non-fatal; proceed with default tier
299
+ diskUsedPct = 0;
300
+ }
301
+
302
+ const tier = classifyDiskTier(diskUsedPct);
303
+ const maxAgeMs = retentionMs(tier);
304
+
305
+ // Step 3: Gather prune candidates
306
+ const candidatesFromScan =
307
+ resumePaths.length > 0 ? resumePaths : await gatherPruneCandidates(maxAgeMs, projectsDir);
308
+
309
+ // Step 4: Write pendingPrune to state BEFORE any deletion (crash-safe)
310
+ if (!dryRun && candidatesFromScan.length > 0) {
311
+ await patchGCState(statePath, { pendingPrune: candidatesFromScan });
312
+ }
313
+
314
+ // Step 5: Delete candidates and accumulate results
315
+ const pruned: GCResult['pruned'] = [];
316
+ let bytesFreed = 0;
317
+ const remaining = [...candidatesFromScan];
318
+
319
+ for (const candidatePath of candidatesFromScan) {
320
+ const bytes = await getPathBytes(candidatePath);
321
+
322
+ if (dryRun) {
323
+ // Dry run: record what would be deleted, make no changes
324
+ pruned.push({ path: candidatePath, bytes });
325
+ bytesFreed += bytes;
326
+ continue;
327
+ }
328
+
329
+ try {
330
+ await idempotentRm(candidatePath);
331
+ pruned.push({ path: candidatePath, bytes });
332
+ bytesFreed += bytes;
333
+ // Remove successfully-deleted path from the pending list
334
+ const idx = remaining.indexOf(candidatePath);
335
+ if (idx !== -1) remaining.splice(idx, 1);
336
+ // Persist updated pendingPrune after each deletion (crash-safe)
337
+ await patchGCState(statePath, {
338
+ pendingPrune: remaining.length > 0 ? remaining : null,
339
+ });
340
+ } catch {
341
+ // Deletion failure: leave in pendingPrune for next run
342
+ }
343
+ }
344
+
345
+ // Step 6: Determine escalation state
346
+ const escalationSet = tier === 'warn' || tier === 'urgent' || tier === 'emergency';
347
+ let escalationReason: string | null = null;
348
+ if (escalationSet) {
349
+ escalationReason = `Disk at ${diskUsedPct.toFixed(1)}% (${tier.toUpperCase()}): ${pruned.length} paths pruned, ${bytesFreed} bytes freed`;
350
+ }
351
+
352
+ const completedAt = new Date().toISOString();
353
+
354
+ // Step 7: Update gc-state.json with run results
355
+ if (!dryRun) {
356
+ await patchGCState(statePath, {
357
+ lastRunAt: completedAt,
358
+ lastRunResult: remaining.length === 0 ? 'success' : 'partial',
359
+ lastRunBytesFreed: bytesFreed,
360
+ pendingPrune: remaining.length > 0 ? remaining : null,
361
+ consecutiveFailures: remaining.length > 0 ? initialState.consecutiveFailures + 1 : 0,
362
+ diskThresholdBreached: diskUsedPct >= DISK_THRESHOLDS.WATCH,
363
+ lastDiskUsedPct: diskUsedPct,
364
+ escalationNeeded: escalationSet || initialState.escalationNeeded,
365
+ escalationReason: escalationReason ?? initialState.escalationReason,
366
+ });
367
+ }
368
+
369
+ return {
370
+ diskUsedPct,
371
+ threshold: tier,
372
+ pruned,
373
+ bytesFreed,
374
+ escalationSet,
375
+ escalationReason,
376
+ completedAt,
377
+ };
378
+ }