@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.
- package/dist/onebrain +12656 -0
- package/package.json +23 -0
- package/src/commands/doctor.test.ts +416 -0
- package/src/commands/doctor.ts +203 -0
- package/src/commands/init.test.ts +318 -0
- package/src/commands/init.ts +477 -0
- package/src/commands/update.test.ts +413 -0
- package/src/commands/update.ts +353 -0
- package/src/index.ts +144 -0
- package/src/internal/__snapshots__/checkpoint.test.ts.snap +12 -0
- package/src/internal/__snapshots__/orphan-scan.test.ts.snap +13 -0
- package/src/internal/__snapshots__/session-init.test.ts.snap +15 -0
- package/src/internal/checkpoint.test.ts +741 -0
- package/src/internal/checkpoint.ts +427 -0
- package/src/internal/migrate.test.ts +301 -0
- package/src/internal/migrate.ts +186 -0
- package/src/internal/orphan-scan.test.ts +271 -0
- package/src/internal/orphan-scan.ts +213 -0
- package/src/internal/qmd-reindex.test.ts +117 -0
- package/src/internal/qmd-reindex.ts +44 -0
- package/src/internal/register-hooks.test.ts +343 -0
- package/src/internal/register-hooks.ts +418 -0
- package/src/internal/session-init.test.ts +318 -0
- package/src/internal/session-init.ts +264 -0
- package/src/internal/vault-sync.test.ts +419 -0
- package/src/internal/vault-sync.ts +764 -0
- package/tests/integration/init.integration.test.ts +304 -0
- package/tests/integration/update.integration.test.ts +306 -0
- package/tsconfig.json +12 -0
|
@@ -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
|
+
});
|