@onebrain-ai/cli 2.0.0

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.
@@ -0,0 +1,741 @@
1
+ /**
2
+ * checkpoint.test.ts — tests for checkpoint command (stop/precompact/postcompact/reset)
3
+ */
4
+
5
+ import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
6
+ import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
7
+ import { tmpdir } from 'node:os';
8
+ import { join } from 'node:path';
9
+ import {
10
+ handlePostcompact,
11
+ handlePrecompact,
12
+ handleReset,
13
+ handleStop,
14
+ readState,
15
+ writeState,
16
+ } from './checkpoint.js';
17
+
18
+ // ---------------------------------------------------------------------------
19
+ // Helpers
20
+ // ---------------------------------------------------------------------------
21
+
22
+ async function makeTmpDir(): Promise<string> {
23
+ const base = join(tmpdir(), `ob-cp-test-${Math.random().toString(36).slice(2)}`);
24
+ await mkdir(base, { recursive: true });
25
+ return base;
26
+ }
27
+
28
+ const TOKEN = '41928';
29
+ const VALID_VAULT_YML = `
30
+ method: onebrain
31
+ update_channel: stable
32
+ folders:
33
+ inbox: 00-inbox
34
+ logs: 07-logs
35
+ checkpoint:
36
+ messages: 5
37
+ minutes: 10
38
+ `.trim();
39
+
40
+ function stateFile(tmpDir: string, token: string): string {
41
+ return join(tmpDir, `onebrain-${token}.state`);
42
+ }
43
+
44
+ async function readStateRaw(tmpDir: string, token: string): Promise<string> {
45
+ return readFile(stateFile(tmpDir, token), 'utf8');
46
+ }
47
+
48
+ // Capture stdout written via process.stdout.write
49
+ function captureStdout(): { stop: () => string } {
50
+ const chunks: string[] = [];
51
+ const original = process.stdout.write.bind(process.stdout);
52
+ process.stdout.write = (chunk: string | Uint8Array) => {
53
+ chunks.push(typeof chunk === 'string' ? chunk : new TextDecoder().decode(chunk));
54
+ return true;
55
+ };
56
+ return {
57
+ stop: () => {
58
+ process.stdout.write = original;
59
+ return chunks.join('');
60
+ },
61
+ };
62
+ }
63
+
64
+ // ---------------------------------------------------------------------------
65
+ // readState / writeState
66
+ // ---------------------------------------------------------------------------
67
+
68
+ describe('readState / writeState', () => {
69
+ let tmpDir: string;
70
+
71
+ beforeEach(async () => {
72
+ tmpDir = await makeTmpDir();
73
+ });
74
+ afterEach(async () => {
75
+ await rm(tmpDir, { recursive: true, force: true });
76
+ });
77
+
78
+ it('returns default state when no file exists', () => {
79
+ const state = readState(TOKEN, tmpDir);
80
+ expect(state.count).toBe(0);
81
+ expect(state.last_ts).toBe(0);
82
+ expect(state.last_stop_nn).toBe('00');
83
+ expect(state.pending_stub).toBeUndefined();
84
+ });
85
+
86
+ it('reads 3-field state correctly', async () => {
87
+ await writeFile(stateFile(tmpDir, TOKEN), '3:1000000:02', 'utf8');
88
+ const state = readState(TOKEN, tmpDir);
89
+ expect(state.count).toBe(3);
90
+ expect(state.last_ts).toBe(1000000);
91
+ expect(state.last_stop_nn).toBe('02');
92
+ expect(state.pending_stub).toBeUndefined();
93
+ });
94
+
95
+ it('reads 4-field state (pending_stub) correctly', async () => {
96
+ await writeFile(
97
+ stateFile(tmpDir, TOKEN),
98
+ '0:1000000:03:2026-04-23-41928-checkpoint-04.md',
99
+ 'utf8',
100
+ );
101
+ const state = readState(TOKEN, tmpDir);
102
+ expect(state.count).toBe(0);
103
+ expect(state.last_ts).toBe(1000000);
104
+ expect(state.last_stop_nn).toBe('03');
105
+ expect(state.pending_stub).toBe('2026-04-23-41928-checkpoint-04.md');
106
+ });
107
+
108
+ it('treats v1 2-field state as parse error → resets to 0:0:00', async () => {
109
+ await writeFile(stateFile(tmpDir, TOKEN), '5:1000000', 'utf8');
110
+ const state = readState(TOKEN, tmpDir);
111
+ expect(state.count).toBe(0);
112
+ expect(state.last_ts).toBe(0);
113
+ expect(state.last_stop_nn).toBe('00');
114
+ // Verify file was eagerly rewritten on disk with 3-field format
115
+ const raw = await Bun.file(stateFile(tmpDir, TOKEN)).text();
116
+ expect(raw).toMatch(/^0:\d+:00$/);
117
+ });
118
+
119
+ it('treats malformed state as parse error → resets to 0:0:00', async () => {
120
+ await writeFile(stateFile(tmpDir, TOKEN), 'bad:data:here', 'utf8');
121
+ const state = readState(TOKEN, tmpDir);
122
+ expect(state.count).toBe(0);
123
+ expect(state.last_ts).toBe(0);
124
+ expect(state.last_stop_nn).toBe('00');
125
+ // Verify file was eagerly rewritten on disk with 3-field format
126
+ const raw = await Bun.file(stateFile(tmpDir, TOKEN)).text();
127
+ expect(raw).toMatch(/^0:\d+:00$/);
128
+ });
129
+
130
+ it('writeState writes 3-field format when no pending_stub', () => {
131
+ writeState(TOKEN, { count: 2, last_ts: 999, last_stop_nn: '01' }, tmpDir);
132
+ const state = readState(TOKEN, tmpDir);
133
+ expect(state.count).toBe(2);
134
+ expect(state.last_ts).toBe(999);
135
+ expect(state.last_stop_nn).toBe('01');
136
+ expect(state.pending_stub).toBeUndefined();
137
+ });
138
+
139
+ it('writeState writes 4-field format when pending_stub is set', () => {
140
+ writeState(
141
+ TOKEN,
142
+ { count: 0, last_ts: 999, last_stop_nn: '02', pending_stub: 'some-file.md' },
143
+ tmpDir,
144
+ );
145
+ const state = readState(TOKEN, tmpDir);
146
+ expect(state.pending_stub).toBe('some-file.md');
147
+ expect(state.last_stop_nn).toBe('02');
148
+ });
149
+ });
150
+
151
+ // ---------------------------------------------------------------------------
152
+ // handleReset
153
+ // ---------------------------------------------------------------------------
154
+
155
+ describe('handleReset', () => {
156
+ let tmpDir: string;
157
+
158
+ beforeEach(async () => {
159
+ tmpDir = await makeTmpDir();
160
+ });
161
+ afterEach(async () => {
162
+ await rm(tmpDir, { recursive: true, force: true });
163
+ });
164
+
165
+ it('writes 0:<now>:00 to state file', async () => {
166
+ const now = 1700000000;
167
+ const cap = captureStdout();
168
+ handleReset(TOKEN, now, tmpDir);
169
+ const out = cap.stop();
170
+ const raw = await readStateRaw(tmpDir, TOKEN);
171
+ expect(raw).toBe(`0:${now}:00`);
172
+ expect(out).toBe('');
173
+ });
174
+
175
+ it('overwrites existing v1 state file cleanly', async () => {
176
+ await writeFile(stateFile(tmpDir, TOKEN), '99:123456789', 'utf8');
177
+ const now = 1700000001;
178
+ handleReset(TOKEN, now, tmpDir);
179
+ const raw = await readStateRaw(tmpDir, TOKEN);
180
+ expect(raw).toBe(`0:${now}:00`);
181
+ });
182
+
183
+ it('produces no stdout', () => {
184
+ const cap = captureStdout();
185
+ handleReset(TOKEN, 1000000, tmpDir);
186
+ const out = cap.stop();
187
+ expect(out).toBe('');
188
+ });
189
+ });
190
+
191
+ // ---------------------------------------------------------------------------
192
+ // handleStop
193
+ // ---------------------------------------------------------------------------
194
+
195
+ describe('handleStop', () => {
196
+ let tmpDir: string;
197
+ let vaultDir: string;
198
+
199
+ beforeEach(async () => {
200
+ tmpDir = await makeTmpDir();
201
+ vaultDir = await makeTmpDir();
202
+ await writeFile(join(vaultDir, 'vault.yml'), VALID_VAULT_YML, 'utf8');
203
+ });
204
+ afterEach(async () => {
205
+ await rm(tmpDir, { recursive: true, force: true });
206
+ await rm(vaultDir, { recursive: true, force: true });
207
+ });
208
+
209
+ it('SKIP_WINDOW: count=0 and last_ts within 60s → exit 0, state unchanged', async () => {
210
+ const now = 1700000100;
211
+ const recentTs = now - 30; // 30s ago
212
+ writeState(TOKEN, { count: 0, last_ts: recentTs, last_stop_nn: '01' }, tmpDir);
213
+
214
+ const cap = captureStdout();
215
+ handleStop(TOKEN, vaultDir, now, tmpDir);
216
+ const out = cap.stop();
217
+
218
+ expect(out).toBe('');
219
+ // State should be unchanged
220
+ const state = readState(TOKEN, tmpDir);
221
+ expect(state.count).toBe(0);
222
+ expect(state.last_ts).toBe(recentTs);
223
+ });
224
+
225
+ it('SKIP_WINDOW: count=0 but last_ts > 60s ago → NOT skipped, count increments to 1', () => {
226
+ const now = 1700000100;
227
+ const oldTs = now - 90; // 90s ago
228
+ writeState(TOKEN, { count: 0, last_ts: oldTs, last_stop_nn: '00' }, tmpDir);
229
+
230
+ const cap = captureStdout();
231
+ handleStop(TOKEN, vaultDir, now, tmpDir);
232
+ cap.stop();
233
+
234
+ // count should be 1 (below MIN_ACTIVITY=2, no emit, but state updated)
235
+ const state = readState(TOKEN, tmpDir);
236
+ expect(state.count).toBe(1);
237
+ expect(state.last_ts).toBe(oldTs); // preserved when threshold not met / min_activity guard
238
+ });
239
+
240
+ it('threshold not met → no stdout, state updated with incremented count', () => {
241
+ // messages_threshold=5, count=2, elapsed small
242
+ const now = 1700000100;
243
+ writeState(TOKEN, { count: 2, last_ts: now - 10, last_stop_nn: '00' }, tmpDir);
244
+
245
+ const cap = captureStdout();
246
+ handleStop(TOKEN, vaultDir, now, tmpDir);
247
+ const out = cap.stop();
248
+
249
+ expect(out).toBe('');
250
+ const state = readState(TOKEN, tmpDir);
251
+ expect(state.count).toBe(3); // incremented
252
+ });
253
+
254
+ it('MIN_ACTIVITY guard: count increments to 1, threshold met by time, but no emit', () => {
255
+ // Even if elapsed > threshold, count < 2 → no emit
256
+ // messages_threshold=5, minutes_threshold=10min=600s
257
+ const now = 1700001000;
258
+ const oldTs = now - 700; // 700s > 600s threshold
259
+ writeState(TOKEN, { count: 0, last_ts: oldTs, last_stop_nn: '01' }, tmpDir);
260
+
261
+ const cap = captureStdout();
262
+ handleStop(TOKEN, vaultDir, now, tmpDir);
263
+ const out = cap.stop();
264
+
265
+ expect(out).toBe('');
266
+ const state = readState(TOKEN, tmpDir);
267
+ expect(state.count).toBe(1); // incremented but no emit
268
+ });
269
+
270
+ it('threshold met by count (count reaches messages_threshold), emits block JSON', () => {
271
+ // count=4, after increment = 5 = messages_threshold
272
+ const now = 1700000500;
273
+ writeState(TOKEN, { count: 4, last_ts: now - 10, last_stop_nn: '00' }, tmpDir);
274
+
275
+ const cap = captureStdout();
276
+ handleStop(TOKEN, vaultDir, now, tmpDir);
277
+ const out = cap.stop();
278
+
279
+ const parsed = JSON.parse(out.trim());
280
+ expect(parsed.decision).toBe('block');
281
+ // reason contains filename "since start" (last_stop_nn was '00')
282
+ expect(parsed.reason).toMatch(/checkpoint-01\.md since start$/);
283
+
284
+ // State reset: count=0, last_ts=now, last_stop_nn='01'
285
+ const state = readState(TOKEN, tmpDir);
286
+ expect(state.count).toBe(0);
287
+ expect(state.last_ts).toBe(now);
288
+ expect(state.last_stop_nn).toBe('01');
289
+ });
290
+
291
+ // Update snapshots: bun test --update-snapshots
292
+ it('stop block JSON shape matches snapshot { decision: "block", reason: "...-checkpoint-NN.md since ..." }', () => {
293
+ // count=4 → increment to 5 = messages_threshold → emit block
294
+ const now = 1700001500;
295
+ writeState(TOKEN, { count: 4, last_ts: now - 10, last_stop_nn: '00' }, tmpDir);
296
+
297
+ const cap = captureStdout();
298
+ handleStop(TOKEN, vaultDir, now, tmpDir);
299
+ const out = cap.stop();
300
+
301
+ const parsed = JSON.parse(out.trim()) as Record<string, unknown>;
302
+
303
+ // Lock the field names of the block decision shape.
304
+ expect(Object.keys(parsed).sort()).toMatchSnapshot();
305
+ // Lock the decision value ("block" is the only valid value).
306
+ expect(parsed.decision).toMatchSnapshot();
307
+ // Lock the reason pattern: must contain a checkpoint filename and a "since" clause.
308
+ expect(typeof parsed.reason).toMatchSnapshot();
309
+ expect(String(parsed.reason)).toMatch(/-checkpoint-\d{2}\.md since /);
310
+ });
311
+
312
+ it('threshold met by elapsed time, emits block JSON', () => {
313
+ // elapsed > minutes_threshold (10 min = 600s), count >= 2
314
+ const now = 1700001000;
315
+ const oldTs = now - 700; // 700s elapsed
316
+ writeState(TOKEN, { count: 2, last_ts: oldTs, last_stop_nn: '02' }, tmpDir);
317
+
318
+ const cap = captureStdout();
319
+ handleStop(TOKEN, vaultDir, now, tmpDir);
320
+ const out = cap.stop();
321
+
322
+ const parsed = JSON.parse(out.trim());
323
+ expect(parsed.decision).toBe('block');
324
+ expect(parsed.reason).toMatch(/checkpoint-03\.md since checkpoint-02$/);
325
+
326
+ const state = readState(TOKEN, tmpDir);
327
+ expect(state.last_stop_nn).toBe('03');
328
+ });
329
+
330
+ it('"since start" format when last_stop_nn is "00"', () => {
331
+ const now = 1700001000;
332
+ writeState(TOKEN, { count: 4, last_ts: now - 10, last_stop_nn: '00' }, tmpDir);
333
+
334
+ const cap = captureStdout();
335
+ handleStop(TOKEN, vaultDir, now, tmpDir);
336
+ const out = cap.stop();
337
+
338
+ const parsed = JSON.parse(out.trim());
339
+ expect(parsed.reason).toMatch(/since start$/);
340
+ });
341
+
342
+ it('"since checkpoint-NN" format when last_stop_nn is non-zero', () => {
343
+ const now = 1700001000;
344
+ writeState(TOKEN, { count: 4, last_ts: now - 10, last_stop_nn: '03' }, tmpDir);
345
+
346
+ const cap = captureStdout();
347
+ handleStop(TOKEN, vaultDir, now, tmpDir);
348
+ const out = cap.stop();
349
+
350
+ const parsed = JSON.parse(out.trim());
351
+ expect(parsed.reason).toMatch(/since checkpoint-03$/);
352
+ // next NN should be 04
353
+ expect(parsed.reason).toMatch(/checkpoint-04\.md/);
354
+ });
355
+
356
+ it('elapsed calc: last_ts=0 → elapsed=0, never triggers time threshold alone', () => {
357
+ const now = 1700001000;
358
+ // last_ts=0 sentinel (post-compact), count=1 (below min_activity)
359
+ writeState(TOKEN, { count: 1, last_ts: 0, last_stop_nn: '00' }, tmpDir);
360
+
361
+ const cap = captureStdout();
362
+ handleStop(TOKEN, vaultDir, now, tmpDir);
363
+ const out = cap.stop();
364
+
365
+ // count becomes 2, but elapsed=0 so time threshold not met, count=2 < 5 messages threshold
366
+ expect(out).toBe('');
367
+ const state = readState(TOKEN, tmpDir);
368
+ expect(state.count).toBe(2);
369
+ });
370
+
371
+ it('precompact-then-stop same turn: count=0, not in SKIP_WINDOW → count=1, below MIN_ACTIVITY, no emit', () => {
372
+ // precompact sets count=0 but does NOT update last_ts; last_ts is old
373
+ const now = 1700002000;
374
+ const oldTs = now - 900; // old, not in skip window
375
+ writeState(TOKEN, { count: 0, last_ts: oldTs, last_stop_nn: '02' }, tmpDir);
376
+
377
+ const cap = captureStdout();
378
+ handleStop(TOKEN, vaultDir, now, tmpDir);
379
+ const out = cap.stop();
380
+
381
+ expect(out).toBe('');
382
+ const state = readState(TOKEN, tmpDir);
383
+ expect(state.count).toBe(1);
384
+ });
385
+
386
+ it('no state file (first run): count starts at 0, increments to 1, no emit', () => {
387
+ const now = 1700000000;
388
+ const cap = captureStdout();
389
+ handleStop(TOKEN, vaultDir, now, tmpDir);
390
+ cap.stop();
391
+ const state = readState(TOKEN, tmpDir);
392
+ expect(state.count).toBe(1);
393
+ });
394
+
395
+ it('last_ts=0 + count=4 (below threshold=5 after increment to 5) → decision:block, last_ts updated, last_stop_nn incremented', () => {
396
+ // messagesThreshold is 5 from vault.yml; count=4 → increment to 5 = threshold → emit
397
+ const now = 1700000800;
398
+ writeState(TOKEN, { count: 4, last_ts: 0, last_stop_nn: '02' }, tmpDir);
399
+
400
+ const cap = captureStdout();
401
+ handleStop(TOKEN, vaultDir, now, tmpDir);
402
+ const out = cap.stop();
403
+
404
+ const parsed = JSON.parse(out.trim());
405
+ expect(parsed.decision).toBe('block');
406
+
407
+ const state = readState(TOKEN, tmpDir);
408
+ // last_ts must have been updated (was 0, now should be `now`)
409
+ expect(state.last_ts).toBe(now);
410
+ expect(state.last_stop_nn).toBe('03');
411
+ });
412
+
413
+ it('last_ts=0 + count=0 → SKIP_WINDOW does NOT fire (guard needs last_ts > 0), out is empty, count increments to 1, last_ts stays 0', () => {
414
+ // last_ts=0 → the SKIP_WINDOW guard (last_ts > 0 && elapsed < 60) does not fire
415
+ // count=0 increments to 1, below MIN_ACTIVITY=2 → no emit
416
+ const now = 1700001500;
417
+ writeState(TOKEN, { count: 0, last_ts: 0, last_stop_nn: '00' }, tmpDir);
418
+
419
+ const cap = captureStdout();
420
+ handleStop(TOKEN, vaultDir, now, tmpDir);
421
+ const out = cap.stop();
422
+
423
+ expect(out).toBe('');
424
+ const state = readState(TOKEN, tmpDir);
425
+ expect(state.count).toBe(1);
426
+ // last_ts stays 0 when threshold not met
427
+ expect(state.last_ts).toBe(0);
428
+ });
429
+
430
+ it('falls back to defaults when vault.yml is missing (messages=15, minutes=30)', () => {
431
+ // vaultDir has no vault.yml — use separate dir
432
+ const emptyVault = tmpDir; // no vault.yml
433
+ const now = 1700001000;
434
+ // count=14 → should NOT emit (threshold=15)
435
+ writeState(TOKEN, { count: 14, last_ts: now - 10, last_stop_nn: '00' }, tmpDir);
436
+
437
+ const cap = captureStdout();
438
+ handleStop(TOKEN, emptyVault, now, tmpDir);
439
+ const out = cap.stop();
440
+
441
+ // With default threshold=15, count=15 after increment → EMIT
442
+ const parsed = JSON.parse(out.trim());
443
+ expect(parsed.decision).toBe('block');
444
+ });
445
+ });
446
+
447
+ // ---------------------------------------------------------------------------
448
+ // handlePrecompact
449
+ // ---------------------------------------------------------------------------
450
+
451
+ describe('handlePrecompact', () => {
452
+ let tmpDir: string;
453
+ let vaultDir: string;
454
+
455
+ beforeEach(async () => {
456
+ tmpDir = await makeTmpDir();
457
+ vaultDir = await makeTmpDir();
458
+ await writeFile(join(vaultDir, 'vault.yml'), VALID_VAULT_YML, 'utf8');
459
+ });
460
+ afterEach(async () => {
461
+ await rm(tmpDir, { recursive: true, force: true });
462
+ await rm(vaultDir, { recursive: true, force: true });
463
+ });
464
+
465
+ it('recent checkpoint (last_ts within 5min) → exit 0, no stub file, state unchanged', async () => {
466
+ const now = 1700001000;
467
+ const recentTs = now - 100; // 100s < 300s
468
+ writeState(TOKEN, { count: 0, last_ts: recentTs, last_stop_nn: '02' }, tmpDir);
469
+
470
+ const cap = captureStdout();
471
+ await handlePrecompact(TOKEN, vaultDir, now, tmpDir);
472
+ const out = cap.stop();
473
+
474
+ expect(out).toBe('');
475
+ const state = readState(TOKEN, tmpDir);
476
+ expect(state.last_stop_nn).toBe('02'); // unchanged
477
+ expect(state.pending_stub).toBeUndefined();
478
+ });
479
+
480
+ it('no recent checkpoint → writes stub file and updates state to 4-field format', async () => {
481
+ const now = 1700001000;
482
+ const oldTs = now - 600; // 600s > 300s
483
+ writeState(TOKEN, { count: 3, last_ts: oldTs, last_stop_nn: '02' }, tmpDir);
484
+
485
+ const cap = captureStdout();
486
+ await handlePrecompact(TOKEN, vaultDir, now, tmpDir);
487
+ const out = cap.stop();
488
+
489
+ expect(out).toBe('');
490
+
491
+ const state = readState(TOKEN, tmpDir);
492
+ expect(state.count).toBe(0);
493
+ expect(state.last_ts).toBe(oldTs); // NOT updated by precompact
494
+ expect(state.last_stop_nn).toBe('02'); // NOT incremented
495
+ expect(state.pending_stub).toBeTruthy();
496
+ // stub NN should be 03 (last_stop_nn '02' + 1)
497
+ expect(state.pending_stub).toMatch(/checkpoint-03\.md$/);
498
+ });
499
+
500
+ it('NN derivation: last_stop_nn="02" → stub NN="03"', async () => {
501
+ const now = 1700001000;
502
+ writeState(TOKEN, { count: 2, last_ts: now - 600, last_stop_nn: '02' }, tmpDir);
503
+
504
+ await handlePrecompact(TOKEN, vaultDir, now, tmpDir);
505
+
506
+ const state = readState(TOKEN, tmpDir);
507
+ expect(state.pending_stub).toMatch(/-checkpoint-03\.md$/);
508
+ });
509
+
510
+ it('stub file is actually written to vault logs dir', async () => {
511
+ const now = 1700001000;
512
+ const date = new Date(now * 1000);
513
+ const yyyy = date.getFullYear().toString();
514
+ const mm = String(date.getMonth() + 1).padStart(2, '0');
515
+ writeState(TOKEN, { count: 2, last_ts: now - 600, last_stop_nn: '00' }, tmpDir);
516
+
517
+ await handlePrecompact(TOKEN, vaultDir, now, tmpDir);
518
+
519
+ const state = readState(TOKEN, tmpDir);
520
+ const stubFilename = state.pending_stub ?? '';
521
+ expect(stubFilename).toBeTruthy();
522
+ const stubPath = join(vaultDir, '07-logs', yyyy, mm, stubFilename);
523
+ const content = await readFile(stubPath, 'utf8');
524
+ expect(content).toContain('trigger: precompact');
525
+ expect(content).toContain('merged: false');
526
+ expect(content).toContain('## What We Worked On');
527
+ });
528
+
529
+ it('stub frontmatter checkpoint: field preserves zero-padding (e.g. "03" not 3)', async () => {
530
+ const now = 1700001000;
531
+ const date = new Date(now * 1000);
532
+ const yyyy = date.getFullYear().toString();
533
+ const mm = String(date.getMonth() + 1).padStart(2, '0');
534
+ // last_stop_nn='02' → stub NN='03'
535
+ writeState(TOKEN, { count: 2, last_ts: now - 600, last_stop_nn: '02' }, tmpDir);
536
+
537
+ await handlePrecompact(TOKEN, vaultDir, now, tmpDir);
538
+
539
+ const state = readState(TOKEN, tmpDir);
540
+ const stubFilename = state.pending_stub ?? '';
541
+ const stubPath = join(vaultDir, '07-logs', yyyy, mm, stubFilename);
542
+ const content = await readFile(stubPath, 'utf8');
543
+ // Must contain 'checkpoint: 03' (zero-padded), NOT 'checkpoint: 3'
544
+ expect(content).toContain('checkpoint: 03');
545
+ expect(content).not.toContain('checkpoint: 3\n');
546
+ });
547
+
548
+ it('last_stop_nn NOT updated (stays same in state after precompact)', async () => {
549
+ const now = 1700001000;
550
+ writeState(TOKEN, { count: 2, last_ts: now - 600, last_stop_nn: '03' }, tmpDir);
551
+
552
+ await handlePrecompact(TOKEN, vaultDir, now, tmpDir);
553
+
554
+ const state = readState(TOKEN, tmpDir);
555
+ expect(state.last_stop_nn).toBe('03');
556
+ expect(state.pending_stub).toMatch(/-checkpoint-04\.md$/);
557
+ });
558
+
559
+ it('no state file (last_ts=0) → recency check fails → writes stub for new session', async () => {
560
+ // No state file → readState returns last_ts=0 → recency guard (last_ts > 0) is false
561
+ // → precompact proceeds and writes a stub (correct: compact on brand-new session)
562
+ const now = 1700001000;
563
+ const cap = captureStdout();
564
+ await handlePrecompact(TOKEN, vaultDir, now, tmpDir);
565
+ const out = cap.stop();
566
+ expect(out).toBe('');
567
+ const state = readState(TOKEN, tmpDir);
568
+ expect(state.pending_stub).toBeTruthy();
569
+ expect(state.pending_stub).toMatch(/-checkpoint-01\.md$/);
570
+ });
571
+
572
+ it('stop-then-autocompact: precompact no-ops within 5 min of stop checkpoint', async () => {
573
+ // Step 1: arrange state where stop threshold is met → stop writes checkpoint, last_ts = now
574
+ const now = 1700001000;
575
+ writeState(TOKEN, { count: 4, last_ts: now - 10, last_stop_nn: '02' }, tmpDir);
576
+
577
+ // handleStop emits block JSON and resets state with last_ts = now
578
+ const cap1 = captureStdout();
579
+ handleStop(TOKEN, vaultDir, now, tmpDir);
580
+ cap1.stop(); // discard the block output
581
+
582
+ // Confirm stop updated last_ts to now
583
+ const stateAfterStop = readState(TOKEN, tmpDir);
584
+ expect(stateAfterStop.last_ts).toBe(now);
585
+ expect(stateAfterStop.count).toBe(0);
586
+
587
+ // Step 2: precompact fires 60s later (still within PRECOMPACT_RECENCY = 300s)
588
+ const nowPlus60 = now + 60;
589
+ const cap2 = captureStdout();
590
+ await handlePrecompact(TOKEN, vaultDir, nowPlus60, tmpDir);
591
+ const out2 = cap2.stop();
592
+
593
+ // Assert: precompact no-ops — no stdout, no stub written
594
+ expect(out2).toBe('');
595
+ const stateAfterPrecompact = readState(TOKEN, tmpDir);
596
+ expect(stateAfterPrecompact.pending_stub).toBeUndefined();
597
+ });
598
+ });
599
+
600
+ // ---------------------------------------------------------------------------
601
+ // handlePostcompact
602
+ // ---------------------------------------------------------------------------
603
+
604
+ describe('handlePostcompact', () => {
605
+ let tmpDir: string;
606
+
607
+ beforeEach(async () => {
608
+ tmpDir = await makeTmpDir();
609
+ });
610
+ afterEach(async () => {
611
+ await rm(tmpDir, { recursive: true, force: true });
612
+ });
613
+
614
+ it('no pending stub (3-field state) → preserve last_ts, write 3-field, no stdout', async () => {
615
+ const now = 1700001000;
616
+ const ts = 1699999000;
617
+ writeState(TOKEN, { count: 2, last_ts: ts, last_stop_nn: '02' }, tmpDir);
618
+
619
+ const cap = captureStdout();
620
+ handlePostcompact(TOKEN, now, tmpDir);
621
+ const out = cap.stop();
622
+
623
+ expect(out).toBe('');
624
+ const raw = await readStateRaw(tmpDir, TOKEN);
625
+ // 3-field, last_ts preserved
626
+ expect(raw).toBe(`0:${ts}:02`);
627
+ });
628
+
629
+ it('no state file → write 3-field with last_ts=0, no stdout', () => {
630
+ const now = 1700001000;
631
+ const cap = captureStdout();
632
+ handlePostcompact(TOKEN, now, tmpDir);
633
+ const out = cap.stop();
634
+ expect(out).toBe('');
635
+ const state = readState(TOKEN, tmpDir);
636
+ expect(state.count).toBe(0);
637
+ expect(state.last_ts).toBe(0);
638
+ });
639
+
640
+ it('pending stub found → emit fill-checkpoint block, clear pending_stub, last_ts=0', async () => {
641
+ const now = 1700001000;
642
+ const ts = 1699999000;
643
+ writeState(
644
+ TOKEN,
645
+ {
646
+ count: 0,
647
+ last_ts: ts,
648
+ last_stop_nn: '02',
649
+ pending_stub: '2026-04-23-41928-checkpoint-03.md',
650
+ },
651
+ tmpDir,
652
+ );
653
+
654
+ const cap = captureStdout();
655
+ handlePostcompact(TOKEN, now, tmpDir);
656
+ const out = cap.stop();
657
+
658
+ const parsed = JSON.parse(out.trim());
659
+ expect(parsed.decision).toBe('block');
660
+ expect(parsed.reason).toContain('fill-checkpoint:');
661
+ expect(parsed.reason).toContain('checkpoint-03.md');
662
+ expect(parsed.reason).toMatch(/since checkpoint-02$/);
663
+
664
+ // State cleared: last_ts=0, pending_stub gone
665
+ const raw = await readStateRaw(tmpDir, TOKEN);
666
+ expect(raw).toBe('0:0:02');
667
+ });
668
+
669
+ it('"since start" format when last_stop_nn="00"', () => {
670
+ writeState(
671
+ TOKEN,
672
+ {
673
+ count: 0,
674
+ last_ts: 1699999000,
675
+ last_stop_nn: '00',
676
+ pending_stub: '2026-04-23-41928-checkpoint-01.md',
677
+ },
678
+ tmpDir,
679
+ );
680
+
681
+ const cap = captureStdout();
682
+ handlePostcompact(TOKEN, 1700001000, tmpDir);
683
+ const out = cap.stop();
684
+
685
+ const parsed = JSON.parse(out.trim());
686
+ expect(parsed.reason).toMatch(/since start$/);
687
+ });
688
+
689
+ it('"since checkpoint-NN" format when last_stop_nn non-zero', () => {
690
+ writeState(
691
+ TOKEN,
692
+ {
693
+ count: 0,
694
+ last_ts: 1699999000,
695
+ last_stop_nn: '03',
696
+ pending_stub: '2026-04-23-41928-checkpoint-04.md',
697
+ },
698
+ tmpDir,
699
+ );
700
+
701
+ const cap = captureStdout();
702
+ handlePostcompact(TOKEN, 1700001000, tmpDir);
703
+ const out = cap.stop();
704
+
705
+ const parsed = JSON.parse(out.trim());
706
+ expect(parsed.reason).toMatch(/since checkpoint-03$/);
707
+ });
708
+
709
+ it('missing stub file on disk → still emit fill-checkpoint (Claude creates it)', () => {
710
+ // Stub filename referenced in state but no actual file on disk
711
+ writeState(
712
+ TOKEN,
713
+ {
714
+ count: 0,
715
+ last_ts: 1699999000,
716
+ last_stop_nn: '01',
717
+ pending_stub: 'nonexistent-checkpoint-02.md',
718
+ },
719
+ tmpDir,
720
+ );
721
+
722
+ const cap = captureStdout();
723
+ handlePostcompact(TOKEN, 1700001000, tmpDir);
724
+ const out = cap.stop();
725
+
726
+ const parsed = JSON.parse(out.trim());
727
+ expect(parsed.decision).toBe('block');
728
+ expect(parsed.reason).toContain('fill-checkpoint:');
729
+ });
730
+
731
+ it('4-field state with empty pending_stub → treat as no pending stub', async () => {
732
+ // Manually write edge case: 4-field with empty 4th
733
+ await writeFile(stateFile(tmpDir, TOKEN), '0:1699999000:02:', 'utf8');
734
+
735
+ const cap = captureStdout();
736
+ handlePostcompact(TOKEN, 1700001000, tmpDir);
737
+ const out = cap.stop();
738
+
739
+ expect(out).toBe('');
740
+ });
741
+ });