@cleocode/core 2026.4.98 → 2026.4.100

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 (85) 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/daemon.js +481 -0
  6. package/dist/gc/daemon.js.map +7 -0
  7. package/dist/gc/index.d.ts +14 -0
  8. package/dist/gc/index.d.ts.map +1 -0
  9. package/dist/gc/index.js +669 -0
  10. package/dist/gc/index.js.map +7 -0
  11. package/dist/gc/runner.d.ts +132 -0
  12. package/dist/gc/runner.d.ts.map +1 -0
  13. package/dist/gc/runner.js +360 -0
  14. package/dist/gc/runner.js.map +7 -0
  15. package/dist/gc/state.d.ts +94 -0
  16. package/dist/gc/state.d.ts.map +1 -0
  17. package/dist/gc/state.js +49 -0
  18. package/dist/gc/state.js.map +7 -0
  19. package/dist/gc/transcript.d.ts +130 -0
  20. package/dist/gc/transcript.d.ts.map +1 -0
  21. package/dist/gc/transcript.js +209 -0
  22. package/dist/gc/transcript.js.map +7 -0
  23. package/dist/memory/brain-backfill.js +14643 -0
  24. package/dist/memory/brain-backfill.js.map +7 -0
  25. package/dist/memory/precompact-flush.js +47725 -0
  26. package/dist/memory/precompact-flush.js.map +7 -0
  27. package/dist/sentient/daemon-entry.d.ts +11 -0
  28. package/dist/sentient/daemon-entry.d.ts.map +1 -0
  29. package/dist/sentient/daemon.d.ts +160 -0
  30. package/dist/sentient/daemon.d.ts.map +1 -0
  31. package/dist/sentient/daemon.js +1100 -0
  32. package/dist/sentient/daemon.js.map +7 -0
  33. package/dist/sentient/index.d.ts +18 -0
  34. package/dist/sentient/index.d.ts.map +1 -0
  35. package/dist/sentient/index.js +1162 -0
  36. package/dist/sentient/index.js.map +7 -0
  37. package/dist/sentient/ingesters/brain-ingester.d.ts +44 -0
  38. package/dist/sentient/ingesters/brain-ingester.d.ts.map +1 -0
  39. package/dist/sentient/ingesters/nexus-ingester.d.ts +45 -0
  40. package/dist/sentient/ingesters/nexus-ingester.d.ts.map +1 -0
  41. package/dist/sentient/ingesters/test-ingester.d.ts +43 -0
  42. package/dist/sentient/ingesters/test-ingester.d.ts.map +1 -0
  43. package/dist/sentient/proposal-rate-limiter.d.ts +93 -0
  44. package/dist/sentient/proposal-rate-limiter.d.ts.map +1 -0
  45. package/dist/sentient/propose-tick.d.ts +105 -0
  46. package/dist/sentient/propose-tick.d.ts.map +1 -0
  47. package/dist/sentient/propose-tick.js +549 -0
  48. package/dist/sentient/propose-tick.js.map +7 -0
  49. package/dist/sentient/state.d.ts +143 -0
  50. package/dist/sentient/state.d.ts.map +1 -0
  51. package/dist/sentient/state.js +85 -0
  52. package/dist/sentient/state.js.map +7 -0
  53. package/dist/sentient/tick.d.ts +193 -0
  54. package/dist/sentient/tick.d.ts.map +1 -0
  55. package/dist/sentient/tick.js +396 -0
  56. package/dist/sentient/tick.js.map +7 -0
  57. package/dist/system/platform-paths.js +36 -0
  58. package/dist/system/platform-paths.js.map +7 -0
  59. package/package.json +76 -8
  60. package/src/gc/__tests__/runner.test.ts +367 -0
  61. package/src/gc/__tests__/state.test.ts +169 -0
  62. package/src/gc/__tests__/transcript.test.ts +371 -0
  63. package/src/gc/daemon-entry.ts +26 -0
  64. package/src/gc/daemon.ts +251 -0
  65. package/src/gc/index.ts +14 -0
  66. package/src/gc/runner.ts +378 -0
  67. package/src/gc/state.ts +140 -0
  68. package/src/gc/transcript.ts +380 -0
  69. package/src/sentient/__tests__/brain-ingester.test.ts +154 -0
  70. package/src/sentient/__tests__/daemon.test.ts +472 -0
  71. package/src/sentient/__tests__/dream-tick.test.ts +200 -0
  72. package/src/sentient/__tests__/nexus-ingester.test.ts +138 -0
  73. package/src/sentient/__tests__/proposal-rate-limiter.test.ts +247 -0
  74. package/src/sentient/__tests__/propose-tick.test.ts +296 -0
  75. package/src/sentient/__tests__/test-ingester.test.ts +104 -0
  76. package/src/sentient/daemon-entry.ts +20 -0
  77. package/src/sentient/daemon.ts +471 -0
  78. package/src/sentient/index.ts +18 -0
  79. package/src/sentient/ingesters/brain-ingester.ts +122 -0
  80. package/src/sentient/ingesters/nexus-ingester.ts +171 -0
  81. package/src/sentient/ingesters/test-ingester.ts +205 -0
  82. package/src/sentient/proposal-rate-limiter.ts +172 -0
  83. package/src/sentient/propose-tick.ts +415 -0
  84. package/src/sentient/state.ts +229 -0
  85. package/src/sentient/tick.ts +688 -0
@@ -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
+ }
@@ -0,0 +1,140 @@
1
+ /**
2
+ * GC State — Persistent crash-recovery state for the autonomous GC daemon.
3
+ *
4
+ * Stored in `.cleo/gc-state.json` (plain JSON, not SQLite) to avoid
5
+ * SQLite WAL conflicts between the long-running daemon process and the
6
+ * main CLEO CLI process. Human-readable for debugging.
7
+ *
8
+ * The file is gitignored (see .gitignore §.cleo/ section) and created empty
9
+ * by `cleo init`. It is NOT included in `cleo backup restore` scope because
10
+ * it is ephemeral operational state — only the `daemonPid` and `lastRunAt`
11
+ * fields survive between process restarts.
12
+ *
13
+ * @see ADR-047 — Autonomous GC and Disk Safety
14
+ * @task T731
15
+ * @epic T726
16
+ */
17
+
18
+ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
19
+ import { dirname, join } from 'node:path';
20
+
21
+ /** Schema version for gc-state.json. Bump on breaking field changes. */
22
+ export const GC_STATE_SCHEMA_VERSION = '1.0' as const;
23
+
24
+ /**
25
+ * Persistent GC daemon state written to `.cleo/gc-state.json`.
26
+ *
27
+ * Design principles:
28
+ * - `pendingPrune` enables idempotent crash recovery: populate BEFORE deletion,
29
+ * clear each entry AFTER successful deletion, clear entirely when job completes.
30
+ * - `diskThresholdBreached` is a sticky flag: cleared only when disk drops
31
+ * below the WATCH tier (70%).
32
+ * - `escalationNeeded` is set by the daemon when disk is in WARN/URGENT range;
33
+ * cleared by the CLI after displaying the escalation banner.
34
+ */
35
+ export interface GCState {
36
+ /** JSON schema version for forward-compatibility checks. */
37
+ schemaVersion: typeof GC_STATE_SCHEMA_VERSION;
38
+ /** ISO-8601 timestamp of last COMPLETED GC run. null = never run. */
39
+ lastRunAt: string | null;
40
+ /** Outcome of the last GC run. */
41
+ lastRunResult: 'success' | 'partial' | 'failed' | null;
42
+ /** Bytes freed in the last completed GC run. */
43
+ lastRunBytesFreed: number;
44
+ /**
45
+ * Paths queued for deletion but not yet deleted.
46
+ * Written BEFORE starting deletion; cleared entry-by-entry on success.
47
+ * Enables idempotent crash recovery on daemon restart.
48
+ */
49
+ pendingPrune: string[] | null;
50
+ /** Number of consecutive GC failures. Triggers escalation banner after 3. */
51
+ consecutiveFailures: number;
52
+ /** Sticky flag: true when disk is ≥ WATCH tier (70%). Cleared when disk < 70%. */
53
+ diskThresholdBreached: boolean;
54
+ /** Current disk usage percentage (0–100) from the last GC run. */
55
+ lastDiskUsedPct: number | null;
56
+ /**
57
+ * Escalation banner flag. Set by daemon when disk is in WARN+ range.
58
+ * Cleared by CLI after displaying the banner to the user.
59
+ */
60
+ escalationNeeded: boolean;
61
+ /** Escalation reason shown in the CLI banner. */
62
+ escalationReason: string | null;
63
+ /** PID of the currently running daemon process. null = daemon not running. */
64
+ daemonPid: number | null;
65
+ /** ISO-8601 timestamp when the daemon was last started. */
66
+ daemonStartedAt: string | null;
67
+ }
68
+
69
+ /** Default (empty) GC state for fresh initialisation. */
70
+ export const DEFAULT_GC_STATE: GCState = {
71
+ schemaVersion: GC_STATE_SCHEMA_VERSION,
72
+ lastRunAt: null,
73
+ lastRunResult: null,
74
+ lastRunBytesFreed: 0,
75
+ pendingPrune: null,
76
+ consecutiveFailures: 0,
77
+ diskThresholdBreached: false,
78
+ lastDiskUsedPct: null,
79
+ escalationNeeded: false,
80
+ escalationReason: null,
81
+ daemonPid: null,
82
+ daemonStartedAt: null,
83
+ };
84
+
85
+ /**
86
+ * Read the GC state from disk.
87
+ *
88
+ * Returns the default state if the file does not exist or is malformed.
89
+ * Never throws — GC state file absence is not an error condition.
90
+ *
91
+ * @param statePath - Absolute path to gc-state.json
92
+ * @returns Parsed GC state, merged with defaults for any missing fields
93
+ */
94
+ export async function readGCState(statePath: string): Promise<GCState> {
95
+ try {
96
+ const raw = await readFile(statePath, 'utf-8');
97
+ const parsed = JSON.parse(raw) as Partial<GCState>;
98
+ // Merge with defaults so new fields added in future schema versions
99
+ // don't cause undefined access on old state files.
100
+ return { ...DEFAULT_GC_STATE, ...parsed };
101
+ } catch {
102
+ // ENOENT (file not yet created) or JSON parse error → use defaults
103
+ return { ...DEFAULT_GC_STATE };
104
+ }
105
+ }
106
+
107
+ /**
108
+ * Write the GC state to disk atomically via tmp-then-rename.
109
+ *
110
+ * Atomic write prevents partial reads if the daemon crashes mid-write.
111
+ * Idempotent: safe to call multiple times.
112
+ *
113
+ * @param statePath - Absolute path to gc-state.json
114
+ * @param state - GC state to persist
115
+ */
116
+ export async function writeGCState(statePath: string, state: GCState): Promise<void> {
117
+ const dir = dirname(statePath);
118
+ await mkdir(dir, { recursive: true });
119
+
120
+ const tmpPath = join(dir, `.gc-state-${process.pid}.tmp`);
121
+ const json = JSON.stringify(state, null, 2);
122
+
123
+ await writeFile(tmpPath, json, 'utf-8');
124
+ await rename(tmpPath, statePath);
125
+ }
126
+
127
+ /**
128
+ * Patch a subset of fields in the GC state file.
129
+ *
130
+ * Convenience wrapper: reads current state, merges patch, writes back.
131
+ *
132
+ * @param statePath - Absolute path to gc-state.json
133
+ * @param patch - Partial state to merge over the existing state
134
+ */
135
+ export async function patchGCState(statePath: string, patch: Partial<GCState>): Promise<GCState> {
136
+ const current = await readGCState(statePath);
137
+ const updated: GCState = { ...current, ...patch };
138
+ await writeGCState(statePath, updated);
139
+ return updated;
140
+ }