@contextgit/store 0.1.10 → 0.2.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.
Files changed (37) hide show
  1. package/dist/interface.d.ts +26 -1
  2. package/dist/interface.d.ts.map +1 -1
  3. package/dist/local/index.d.ts +26 -1
  4. package/dist/local/index.d.ts.map +1 -1
  5. package/dist/local/index.js +216 -6
  6. package/dist/local/index.js.map +1 -1
  7. package/dist/local/local-store.test.js +642 -2
  8. package/dist/local/local-store.test.js.map +1 -1
  9. package/dist/local/migrations.d.ts +11 -0
  10. package/dist/local/migrations.d.ts.map +1 -1
  11. package/dist/local/migrations.js +118 -1
  12. package/dist/local/migrations.js.map +1 -1
  13. package/dist/local/plan-nodes.test.d.ts +2 -0
  14. package/dist/local/plan-nodes.test.d.ts.map +1 -0
  15. package/dist/local/plan-nodes.test.js +291 -0
  16. package/dist/local/plan-nodes.test.js.map +1 -0
  17. package/dist/local/queries.d.ts +103 -2
  18. package/dist/local/queries.d.ts.map +1 -1
  19. package/dist/local/queries.js +506 -16
  20. package/dist/local/queries.js.map +1 -1
  21. package/dist/local/schema.d.ts +8 -0
  22. package/dist/local/schema.d.ts.map +1 -1
  23. package/dist/local/schema.js +88 -0
  24. package/dist/local/schema.js.map +1 -1
  25. package/dist/local/thread-archive.test.d.ts +2 -0
  26. package/dist/local/thread-archive.test.d.ts.map +1 -0
  27. package/dist/local/thread-archive.test.js +203 -0
  28. package/dist/local/thread-archive.test.js.map +1 -0
  29. package/dist/remote/index.d.ts +15 -0
  30. package/dist/remote/index.d.ts.map +1 -1
  31. package/dist/remote/index.js +44 -0
  32. package/dist/remote/index.js.map +1 -1
  33. package/dist/supabase/index.d.ts +15 -0
  34. package/dist/supabase/index.d.ts.map +1 -1
  35. package/dist/supabase/index.js +45 -2
  36. package/dist/supabase/index.js.map +1 -1
  37. package/package.json +2 -2
@@ -1,5 +1,10 @@
1
- import { describe, it, expect, beforeEach, afterEach } from 'vitest';
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
2
  import { LocalStore } from './index.js';
3
+ import { SnapshotFormatter } from '@contextgit/core';
4
+ /** Advance the mocked clock by 1 minute. Use between commits in decay tests so each gets a distinct created_at. */
5
+ function tick() {
6
+ vi.setSystemTime(new Date(Date.now() + 60_000));
7
+ }
3
8
  describe('LocalStore (in-memory)', () => {
4
9
  let store;
5
10
  beforeEach(() => {
@@ -182,7 +187,8 @@ describe('LocalStore (in-memory)', () => {
182
187
  const project = await store.createProject({ name: 'fmt-test' });
183
188
  const branch = await store.createBranch({ projectId: project.id, name: 'main', gitBranch: 'main' });
184
189
  const text = await store.getFormattedSnapshot(project.id, branch.id, 'text');
185
- expect(text).toContain('=== PROJECT STATE ===');
190
+ // Note: === PROJECT STATE === was removed in 02 DELTA gate 1 (commit b11e6e4).
191
+ // The text format now emits live ## Git facts + branch + threads + claims, no stored prose.
186
192
  expect(text).toContain('=== CURRENT BRANCH: main ===');
187
193
  expect(text).toContain('=== OPEN THREADS ===');
188
194
  });
@@ -211,6 +217,85 @@ describe('LocalStore (in-memory)', () => {
211
217
  expect(mainThreads).toHaveLength(1);
212
218
  expect(mainThreads[0].description).toBe('Thread from feat');
213
219
  });
220
+ it('getSessionSnapshot respects commitWindow option (default 5, custom honored)', async () => {
221
+ vi.useFakeTimers({ now: new Date('2026-01-01T00:00:00Z') });
222
+ try {
223
+ const project = await store.createProject({ name: 'p' });
224
+ const branch = await store.createBranch({ projectId: project.id, name: 'main', gitBranch: 'main' });
225
+ await store.upsertAgent({
226
+ id: 'agent-1',
227
+ projectId: project.id,
228
+ role: 'dev',
229
+ tool: 'claude-code',
230
+ workflowType: 'interactive',
231
+ });
232
+ for (let i = 0; i < 10; i++) {
233
+ tick();
234
+ await store.createCommit({
235
+ branchId: branch.id,
236
+ agentId: 'agent-1',
237
+ agentRole: 'dev',
238
+ tool: 'claude-code',
239
+ workflowType: 'interactive',
240
+ message: `commit ${i}`,
241
+ content: 'c',
242
+ summary: 's',
243
+ commitType: 'manual',
244
+ });
245
+ }
246
+ const defaultSnap = await store.getSessionSnapshot(project.id, branch.id);
247
+ expect(defaultSnap.recentCommits).toHaveLength(5);
248
+ const windowed = await store.getSessionSnapshot(project.id, branch.id, { commitWindow: 8 });
249
+ expect(windowed.recentCommits).toHaveLength(8);
250
+ const tiny = await store.getSessionSnapshot(project.id, branch.id, { commitWindow: 2 });
251
+ expect(tiny.recentCommits).toHaveLength(2);
252
+ }
253
+ finally {
254
+ vi.useRealTimers();
255
+ }
256
+ });
257
+ it('appends and lists trace entries in reverse-chronological order with paging', async () => {
258
+ const project = await store.createProject({ name: 'p' });
259
+ const branch = await store.createBranch({ projectId: project.id, name: 'main', gitBranch: 'main' });
260
+ const first = await store.appendTraceEntry({
261
+ projectId: project.id,
262
+ branchId: branch.id,
263
+ note: 'tried X, abandoned because Y',
264
+ });
265
+ const second = await store.appendTraceEntry({
266
+ projectId: project.id,
267
+ branchId: branch.id,
268
+ note: 'considered Z, picked W',
269
+ gitCommitSha: 'abc1234',
270
+ });
271
+ expect(first.id).toBeTruthy();
272
+ expect(first.id).not.toBe(second.id);
273
+ const page = await store.listTraceEntries(project.id, { limit: 10, offset: 0 });
274
+ expect(page).toHaveLength(2);
275
+ // Most-recent-first
276
+ expect(page[0].note).toBe('considered Z, picked W');
277
+ expect(page[0].gitCommitSha).toBe('abc1234');
278
+ expect(page[1].note).toBe('tried X, abandoned because Y');
279
+ const offset1 = await store.listTraceEntries(project.id, { limit: 10, offset: 1 });
280
+ expect(offset1).toHaveLength(1);
281
+ expect(offset1[0].note).toBe('tried X, abandoned because Y');
282
+ const empty = await store.listTraceEntries(project.id, { limit: 10, offset: 5 });
283
+ expect(empty).toHaveLength(0);
284
+ });
285
+ it('does not include trace entries in getSessionSnapshot', async () => {
286
+ const project = await store.createProject({ name: 'p' });
287
+ const branch = await store.createBranch({ projectId: project.id, name: 'main', gitBranch: 'main' });
288
+ await store.appendTraceEntry({
289
+ projectId: project.id,
290
+ branchId: branch.id,
291
+ note: 'this must NEVER auto-load',
292
+ });
293
+ const snapshot = await store.getSessionSnapshot(project.id, branch.id);
294
+ const formatter = new SnapshotFormatter();
295
+ const out = formatter.format(snapshot, 'agents-md');
296
+ expect(out).not.toContain('this must NEVER auto-load');
297
+ expect(out).not.toContain('trace');
298
+ });
214
299
  it('uses explicit dbPath when provided, ignoring projectId for path computation', async () => {
215
300
  // Passing ':memory:' as dbPath bypasses projectId-based path computation entirely.
216
301
  // If projectId were used, it would try to open ~/.contextgit/projects/ignored-id.db.
@@ -218,5 +303,560 @@ describe('LocalStore (in-memory)', () => {
218
303
  await expect(store2.createProject({ id: 'p1', name: 'Test' })).resolves.toMatchObject({ id: 'p1' });
219
304
  store2.close();
220
305
  });
306
+ it('dedupes thread opens on normalized subject and updates lastTouchedCommit', async () => {
307
+ const project = await store.createProject({ name: 'p' });
308
+ const branch = await store.createBranch({ projectId: project.id, name: 'main', gitBranch: 'main' });
309
+ await store.upsertAgent({
310
+ id: 'agent-1',
311
+ projectId: project.id,
312
+ role: 'dev',
313
+ tool: 'claude-code',
314
+ workflowType: 'interactive',
315
+ });
316
+ const first = await store.createCommit({
317
+ branchId: branch.id,
318
+ agentId: 'agent-1',
319
+ agentRole: 'dev',
320
+ tool: 'claude-code',
321
+ workflowType: 'interactive',
322
+ message: 'open thread',
323
+ content: 'c',
324
+ summary: 's',
325
+ commitType: 'manual',
326
+ threads: { open: ['Write Plan B Extension'] },
327
+ });
328
+ const afterFirst = await store.listOpenThreads(project.id);
329
+ expect(afterFirst).toHaveLength(1);
330
+ expect(afterFirst[0].description).toBe('Write Plan B Extension');
331
+ expect(afterFirst[0].lastTouchedCommit).toBe(first.id);
332
+ const second = await store.createCommit({
333
+ branchId: branch.id,
334
+ agentId: 'agent-1',
335
+ agentRole: 'dev',
336
+ tool: 'claude-code',
337
+ workflowType: 'interactive',
338
+ message: 'duplicate subject, varied casing + spacing',
339
+ content: 'c',
340
+ summary: 's',
341
+ commitType: 'manual',
342
+ threads: { open: [' write PLAN b extension '] },
343
+ });
344
+ const afterSecond = await store.listOpenThreads(project.id);
345
+ expect(afterSecond).toHaveLength(1);
346
+ expect(afterSecond[0].id).toBe(afterFirst[0].id);
347
+ expect(afterSecond[0].description).toBe('Write Plan B Extension');
348
+ expect(afterSecond[0].lastTouchedCommit).toBe(second.id);
349
+ });
350
+ it('opens a thread as kind="watch" when input is {subject, kind: "watch"}', async () => {
351
+ const project = await store.createProject({ name: 'p' });
352
+ const branch = await store.createBranch({ projectId: project.id, name: 'main', gitBranch: 'main' });
353
+ await store.upsertAgent({
354
+ id: 'agent-1',
355
+ projectId: project.id,
356
+ role: 'dev',
357
+ tool: 'claude-code',
358
+ workflowType: 'interactive',
359
+ });
360
+ await store.createCommit({
361
+ branchId: branch.id,
362
+ agentId: 'agent-1',
363
+ agentRole: 'dev',
364
+ tool: 'claude-code',
365
+ workflowType: 'interactive',
366
+ message: 'open one open and one watch',
367
+ content: 'c',
368
+ summary: 's',
369
+ commitType: 'manual',
370
+ threads: {
371
+ open: [
372
+ 'committed thread',
373
+ { subject: 'speculative reminder', kind: 'watch' },
374
+ ],
375
+ },
376
+ });
377
+ const threads = await store.listOpenThreads(project.id);
378
+ expect(threads).toHaveLength(2);
379
+ const byDesc = Object.fromEntries(threads.map((t) => [t.description, t]));
380
+ expect(byDesc['committed thread'].kind).toBe('open');
381
+ expect(byDesc['speculative reminder'].kind).toBe('watch');
382
+ });
383
+ it('flags an open thread as stale only when BOTH age AND distance signals fire (03 DELTA recalibration)', async () => {
384
+ vi.useFakeTimers({ now: new Date('2026-01-01T00:00:00Z') });
385
+ try {
386
+ const project = await store.createProject({ name: 'p' });
387
+ const branch = await store.createBranch({ projectId: project.id, name: 'main', gitBranch: 'main' });
388
+ await store.upsertAgent({
389
+ id: 'agent-1',
390
+ projectId: project.id,
391
+ role: 'dev',
392
+ tool: 'claude-code',
393
+ workflowType: 'interactive',
394
+ });
395
+ // Open the thread at T0.
396
+ await store.createCommit({
397
+ branchId: branch.id,
398
+ agentId: 'agent-1',
399
+ agentRole: 'dev',
400
+ tool: 'claude-code',
401
+ workflowType: 'interactive',
402
+ message: 'open',
403
+ content: 'c',
404
+ summary: 's',
405
+ commitType: 'manual',
406
+ threads: { open: ['the stale-soon thread'] },
407
+ });
408
+ const before = await store.listOpenThreads(project.id);
409
+ expect(before).toHaveLength(1);
410
+ expect(before[0].stale).toBeUndefined();
411
+ // Distance signal alone: 31 noise commits, all within ~31 minutes. AND-rule
412
+ // says the thread must STAY LIVE — recency has not aged out yet.
413
+ for (let i = 0; i < 31; i++) {
414
+ tick();
415
+ await store.createCommit({
416
+ branchId: branch.id,
417
+ agentId: 'agent-1',
418
+ agentRole: 'dev',
419
+ tool: 'claude-code',
420
+ workflowType: 'interactive',
421
+ message: `noise ${i}`,
422
+ content: 'c',
423
+ summary: 's',
424
+ commitType: 'manual',
425
+ });
426
+ }
427
+ const stillLive = await store.listOpenThreads(project.id);
428
+ expect(stillLive).toHaveLength(1);
429
+ expect(stillLive[0].description).toBe('the stale-soon thread');
430
+ // Now jump the clock past the 14-day age threshold and trigger one more
431
+ // commit. Sweep-on-save now sees BOTH age (>14d since touch) AND distance
432
+ // (≥30 commits since touch) → thread is archived.
433
+ vi.setSystemTime(new Date(Date.now() + 15 * 24 * 60 * 60 * 1000));
434
+ await store.createCommit({
435
+ branchId: branch.id,
436
+ agentId: 'agent-1',
437
+ agentRole: 'dev',
438
+ tool: 'claude-code',
439
+ workflowType: 'interactive',
440
+ message: 'final noise',
441
+ content: 'c',
442
+ summary: 's',
443
+ commitType: 'manual',
444
+ });
445
+ const after = await store.listOpenThreads(project.id);
446
+ expect(after).toHaveLength(0);
447
+ const archived = await store.listArchivedThreads(project.id);
448
+ expect(archived).toHaveLength(1);
449
+ expect(archived[0].archivedReason).toMatch(/stale/);
450
+ }
451
+ finally {
452
+ vi.useRealTimers();
453
+ }
454
+ });
455
+ it('flags a watch thread as expired once branch commits past its touch hit the watch threshold', async () => {
456
+ vi.useFakeTimers({ now: new Date('2026-01-01T00:00:00Z') });
457
+ try {
458
+ const project = await store.createProject({ name: 'p' });
459
+ const branch = await store.createBranch({ projectId: project.id, name: 'main', gitBranch: 'main' });
460
+ await store.upsertAgent({
461
+ id: 'agent-1',
462
+ projectId: project.id,
463
+ role: 'dev',
464
+ tool: 'claude-code',
465
+ workflowType: 'interactive',
466
+ });
467
+ await store.createCommit({
468
+ branchId: branch.id,
469
+ agentId: 'agent-1',
470
+ agentRole: 'dev',
471
+ tool: 'claude-code',
472
+ workflowType: 'interactive',
473
+ message: 'open watch',
474
+ content: 'c',
475
+ summary: 's',
476
+ commitType: 'manual',
477
+ threads: { open: [{ subject: 'watch this', kind: 'watch' }] },
478
+ });
479
+ for (let i = 0; i < 16; i++) {
480
+ tick();
481
+ await store.createCommit({
482
+ branchId: branch.id,
483
+ agentId: 'agent-1',
484
+ agentRole: 'dev',
485
+ tool: 'claude-code',
486
+ workflowType: 'interactive',
487
+ message: `noise ${i}`,
488
+ content: 'c',
489
+ summary: 's',
490
+ commitType: 'manual',
491
+ });
492
+ }
493
+ // 03 DELTA: sweep runs on every createCommit. After 15+ noise commits, the
494
+ // watch thread is watch-expired and gets archived — no longer in listOpenThreads.
495
+ const after = await store.listOpenThreads(project.id);
496
+ expect(after).toHaveLength(0);
497
+ const archived = await store.listArchivedThreads(project.id);
498
+ expect(archived).toHaveLength(1);
499
+ expect(archived[0].archivedReason).toBe('watch-expired');
500
+ }
501
+ finally {
502
+ vi.useRealTimers();
503
+ }
504
+ });
505
+ it('curates getSessionSnapshot: filters stale + expired threads and sets counts', async () => {
506
+ vi.useFakeTimers({ now: new Date('2026-01-01T00:00:00Z') });
507
+ try {
508
+ const project = await store.createProject({ name: 'p' });
509
+ const branch = await store.createBranch({ projectId: project.id, name: 'main', gitBranch: 'main' });
510
+ await store.upsertAgent({
511
+ id: 'agent-1',
512
+ projectId: project.id,
513
+ role: 'dev',
514
+ tool: 'claude-code',
515
+ workflowType: 'interactive',
516
+ });
517
+ // Open one open thread that will go stale, one watch that will expire, and one live open
518
+ await store.createCommit({
519
+ branchId: branch.id,
520
+ agentId: 'agent-1',
521
+ agentRole: 'dev',
522
+ tool: 'claude-code',
523
+ workflowType: 'interactive',
524
+ message: 'open three',
525
+ content: 'c',
526
+ summary: 's',
527
+ commitType: 'manual',
528
+ threads: {
529
+ open: [
530
+ 'will go stale',
531
+ { subject: 'will expire', kind: 'watch' },
532
+ 'will stay live',
533
+ ],
534
+ },
535
+ });
536
+ // Push enough noise to drive up branch-distance, then jump the clock past
537
+ // the 14-day age threshold so AND-rule's age + distance signals BOTH fire
538
+ // on the stale-target threads. (Distance alone no longer ages anything out
539
+ // — 03 DELTA decay recalibration.)
540
+ for (let i = 0; i < 31; i++) {
541
+ tick();
542
+ await store.createCommit({
543
+ branchId: branch.id,
544
+ agentId: 'agent-1',
545
+ agentRole: 'dev',
546
+ tool: 'claude-code',
547
+ workflowType: 'interactive',
548
+ message: `noise ${i}`,
549
+ content: 'c',
550
+ summary: 's',
551
+ commitType: 'manual',
552
+ });
553
+ }
554
+ // Advance past the age threshold so the AND-rule fires on the targeted threads.
555
+ vi.setSystemTime(new Date(Date.now() + 15 * 24 * 60 * 60 * 1000));
556
+ // Touch the live one on the most recent commit so it doesn't go stale
557
+ tick();
558
+ await store.createCommit({
559
+ branchId: branch.id,
560
+ agentId: 'agent-1',
561
+ agentRole: 'dev',
562
+ tool: 'claude-code',
563
+ workflowType: 'interactive',
564
+ message: 'touch the live thread',
565
+ content: 'c',
566
+ summary: 's',
567
+ commitType: 'manual',
568
+ threads: { open: ['will stay live'] },
569
+ });
570
+ const snapshot = await store.getSessionSnapshot(project.id, branch.id);
571
+ // 03 DELTA: sweep runs on every createCommit. Stale + expired threads are
572
+ // archived before the snapshot is taken, so counts are 0 and only the live
573
+ // thread remains in openThreads.
574
+ expect(snapshot.staleThreadCount).toBe(0);
575
+ expect(snapshot.expiredWatchCount).toBe(0);
576
+ expect(snapshot.openThreads).toHaveLength(1);
577
+ expect(snapshot.openThreads[0].description).toBe('will stay live');
578
+ }
579
+ finally {
580
+ vi.useRealTimers();
581
+ }
582
+ });
583
+ it('archiveThread + restoreThread round-trip via LocalStore', async () => {
584
+ const project = await store.createProject({ name: 'p' });
585
+ const branch = await store.createBranch({ projectId: project.id, name: 'main', gitBranch: 'main' });
586
+ const commit = await store.createCommit({
587
+ branchId: branch.id, agentId: 'a', agentRole: 'solo', tool: 't',
588
+ workflowType: 'interactive', message: 'm', content: 'c', summary: 's',
589
+ commitType: 'manual',
590
+ threads: { open: ['subj-arch'] },
591
+ });
592
+ const open = await store.listOpenThreads(project.id);
593
+ const thread = open[0];
594
+ const archived = await store.archiveThread(thread.id, 'manual', commit.id);
595
+ expect(archived.archivedReason).toBe('manual');
596
+ const list = await store.listArchivedThreads(project.id);
597
+ expect(list.map((t) => t.id)).toContain(thread.id);
598
+ await store.restoreThread(thread.id);
599
+ const reopened = await store.listOpenThreads(project.id);
600
+ expect(reopened.map((t) => t.id)).toContain(thread.id);
601
+ });
602
+ it('createCommit closesThreads — handle match archives the thread', async () => {
603
+ const project = await store.createProject({ name: 'p' });
604
+ const branch = await store.createBranch({ projectId: project.id, name: 'main', gitBranch: 'main' });
605
+ await store.createCommit({
606
+ branchId: branch.id, agentId: 'a', agentRole: 'solo', tool: 't',
607
+ workflowType: 'interactive', message: 'open', content: 'x', summary: 'x',
608
+ commitType: 'manual',
609
+ threads: { open: ['close-me'] },
610
+ });
611
+ const open = await store.listOpenThreads(project.id);
612
+ const handle = open[0].id.slice(0, 6);
613
+ await store.createCommit({
614
+ branchId: branch.id, agentId: 'a', agentRole: 'solo', tool: 't',
615
+ workflowType: 'interactive', message: 'close', content: 'y', summary: 'y',
616
+ commitType: 'manual',
617
+ threads: { closes: [handle] },
618
+ });
619
+ const stillOpen = await store.listOpenThreads(project.id);
620
+ expect(stillOpen.length).toBe(0);
621
+ const archived = await store.listArchivedThreads(project.id);
622
+ expect(archived.length).toBe(1);
623
+ expect(archived[0].archivedReason).toBe('manual');
624
+ });
625
+ it('createCommit closesThreads — subject match archives the thread', async () => {
626
+ const project = await store.createProject({ name: 'p' });
627
+ const branch = await store.createBranch({ projectId: project.id, name: 'main', gitBranch: 'main' });
628
+ await store.createCommit({
629
+ branchId: branch.id, agentId: 'a', agentRole: 'solo', tool: 't',
630
+ workflowType: 'interactive', message: 'o', content: 'x', summary: 'x',
631
+ commitType: 'manual',
632
+ threads: { open: ['need to do the thing'] },
633
+ });
634
+ await store.createCommit({
635
+ branchId: branch.id, agentId: 'a', agentRole: 'solo', tool: 't',
636
+ workflowType: 'interactive', message: 'c', content: 'y', summary: 'y',
637
+ commitType: 'manual',
638
+ threads: { closes: ['Need to do the thing'] },
639
+ });
640
+ const archived = await store.listArchivedThreads(project.id);
641
+ expect(archived.length).toBe(1);
642
+ });
643
+ it('createCommit closesThreads — already-archived handle is a no-op success', async () => {
644
+ const project = await store.createProject({ name: 'p' });
645
+ const branch = await store.createBranch({ projectId: project.id, name: 'main', gitBranch: 'main' });
646
+ const c1 = await store.createCommit({
647
+ branchId: branch.id, agentId: 'a', agentRole: 'solo', tool: 't',
648
+ workflowType: 'interactive', message: 'o', content: 'x', summary: 'x',
649
+ commitType: 'manual',
650
+ threads: { open: ['will-archive-twice'] },
651
+ });
652
+ const open = await store.listOpenThreads(project.id);
653
+ const handle = open[0].id.slice(0, 6);
654
+ await store.archiveThread(open[0].id, 'manual', c1.id);
655
+ // Save uses the same handle; thread is already archived; save must NOT throw.
656
+ await store.createCommit({
657
+ branchId: branch.id, agentId: 'a', agentRole: 'solo', tool: 't',
658
+ workflowType: 'interactive', message: 'c', content: 'y', summary: 'y',
659
+ commitType: 'manual',
660
+ threads: { closes: [handle] },
661
+ });
662
+ // No second archive insert — list count remains 1.
663
+ const archived = await store.listArchivedThreads(project.id);
664
+ expect(archived.length).toBe(1);
665
+ });
666
+ it('createCommit closesThreads — no match aborts the entire save atomically', async () => {
667
+ const project = await store.createProject({ name: 'p' });
668
+ const branch = await store.createBranch({ projectId: project.id, name: 'main', gitBranch: 'main' });
669
+ const before = (await store.listCommits(branch.id, { limit: 100, offset: 0 })).length;
670
+ await expect(store.createCommit({
671
+ branchId: branch.id, agentId: 'a', agentRole: 'solo', tool: 't',
672
+ workflowType: 'interactive', message: 'c', content: 'y', summary: 'y',
673
+ commitType: 'manual',
674
+ threads: { closes: ['xxxxxx'] },
675
+ })).rejects.toThrow(/closesThreads/);
676
+ const after = (await store.listCommits(branch.id, { limit: 100, offset: 0 })).length;
677
+ expect(after).toBe(before);
678
+ });
679
+ it('createCommit closesThreads — singular close (N=1) writes archived_reason="manual"', async () => {
680
+ const project = await store.createProject({ name: 'p' });
681
+ const branch = await store.createBranch({ projectId: project.id, name: 'main', gitBranch: 'main' });
682
+ await store.createCommit({
683
+ branchId: branch.id, agentId: 'a', agentRole: 'solo', tool: 't',
684
+ workflowType: 'interactive', message: 'open', content: 'x', summary: 'x',
685
+ commitType: 'manual',
686
+ threads: { open: ['only-one'] },
687
+ });
688
+ const open = await store.listOpenThreads(project.id);
689
+ const handle = open[0].id.slice(0, 6);
690
+ await store.createCommit({
691
+ branchId: branch.id, agentId: 'a', agentRole: 'solo', tool: 't',
692
+ workflowType: 'interactive', message: 'close', content: 'y', summary: 'y',
693
+ commitType: 'manual',
694
+ threads: { closes: [handle] },
695
+ });
696
+ const archived = await store.listArchivedThreads(project.id);
697
+ expect(archived).toHaveLength(1);
698
+ expect(archived[0].archivedReason).toBe('manual');
699
+ });
700
+ it('createCommit closesThreads — batch close (N>1) writes archived_reason="bulk-cleanup"', async () => {
701
+ const project = await store.createProject({ name: 'p' });
702
+ const branch = await store.createBranch({ projectId: project.id, name: 'main', gitBranch: 'main' });
703
+ await store.createCommit({
704
+ branchId: branch.id, agentId: 'a', agentRole: 'solo', tool: 't',
705
+ workflowType: 'interactive', message: 'open three', content: 'x', summary: 'x',
706
+ commitType: 'manual',
707
+ threads: { open: ['t-1', 't-2', 't-3'] },
708
+ });
709
+ const open = await store.listOpenThreads(project.id);
710
+ expect(open).toHaveLength(3);
711
+ const handles = open.map((t) => t.id.slice(0, 6));
712
+ await store.createCommit({
713
+ branchId: branch.id, agentId: 'a', agentRole: 'solo', tool: 't',
714
+ workflowType: 'interactive', message: 'close three', content: 'y', summary: 'y',
715
+ commitType: 'manual',
716
+ threads: { closes: handles },
717
+ });
718
+ const archived = await store.listArchivedThreads(project.id);
719
+ expect(archived).toHaveLength(3);
720
+ for (const a of archived) {
721
+ expect(a.archivedReason).toBe('bulk-cleanup');
722
+ }
723
+ });
724
+ it('createCommit legacy threads.close — singular vs batch follows the same rule', async () => {
725
+ const project = await store.createProject({ name: 'p' });
726
+ const branch = await store.createBranch({ projectId: project.id, name: 'main', gitBranch: 'main' });
727
+ await store.createCommit({
728
+ branchId: branch.id, agentId: 'a', agentRole: 'solo', tool: 't',
729
+ workflowType: 'interactive', message: 'o', content: 'x', summary: 'x',
730
+ commitType: 'manual',
731
+ threads: { open: ['L1', 'L2'] },
732
+ });
733
+ const open = await store.listOpenThreads(project.id);
734
+ // Batch (N=2) via legacy close path
735
+ await store.createCommit({
736
+ branchId: branch.id, agentId: 'a', agentRole: 'solo', tool: 't',
737
+ workflowType: 'interactive', message: 'close batch', content: 'y', summary: 'y',
738
+ commitType: 'manual',
739
+ threads: { close: open.map((t) => ({ id: t.id, note: 'closed' })) },
740
+ });
741
+ const archived = await store.listArchivedThreads(project.id);
742
+ expect(archived).toHaveLength(2);
743
+ for (const a of archived)
744
+ expect(a.archivedReason).toBe('bulk-cleanup');
745
+ });
746
+ it('opens distinct threads when normalized subjects differ', async () => {
747
+ const project = await store.createProject({ name: 'p' });
748
+ const branch = await store.createBranch({ projectId: project.id, name: 'main', gitBranch: 'main' });
749
+ await store.upsertAgent({
750
+ id: 'agent-1',
751
+ projectId: project.id,
752
+ role: 'dev',
753
+ tool: 'claude-code',
754
+ workflowType: 'interactive',
755
+ });
756
+ await store.createCommit({
757
+ branchId: branch.id,
758
+ agentId: 'agent-1',
759
+ agentRole: 'dev',
760
+ tool: 'claude-code',
761
+ workflowType: 'interactive',
762
+ message: 'open two threads',
763
+ content: 'c',
764
+ summary: 's',
765
+ commitType: 'manual',
766
+ threads: { open: ['Subject A', 'Subject B'] },
767
+ });
768
+ const threads = await store.listOpenThreads(project.id);
769
+ expect(threads).toHaveLength(2);
770
+ expect(threads.map((t) => t.description).sort()).toEqual(['Subject A', 'Subject B']);
771
+ });
772
+ // ─── 04 DELTA planning hierarchy ─────────────────────────────────────────
773
+ it('LocalStore plan-tree round-trip: insertPlanTree → getPlanTree → updatePlanNodeStatus → completed', async () => {
774
+ const project = await store.createProject({ name: 'p' });
775
+ const plan = await store.insertPlanTree(project.id, {
776
+ title: 'Plan Z',
777
+ children: [{ title: 'Step 1', children: [{ title: 'Task A' }] }],
778
+ });
779
+ expect(plan.level).toBe('plan');
780
+ expect(plan.children?.[0].children?.[0].title).toBe('Task A');
781
+ const active = await store.getPlanTree(project.id);
782
+ expect(active).toHaveLength(1);
783
+ const taskId = plan.children[0].children[0].id;
784
+ await store.updatePlanNodeStatus(taskId, 'done', null);
785
+ expect(await store.getPlanTree(project.id)).toHaveLength(0);
786
+ expect((await store.listCompletedPlans(project.id))[0].title).toBe('Plan Z');
787
+ });
788
+ it('SessionSnapshot.planTree is populated from getPlanTree', async () => {
789
+ const project = await store.createProject({ name: 'p' });
790
+ const branch = await store.createBranch({ projectId: project.id, name: 'main', gitBranch: 'main' });
791
+ await store.insertPlanTree(project.id, {
792
+ title: 'In-progress plan',
793
+ children: [{ title: 'S', children: [{ title: 'T' }] }],
794
+ });
795
+ const snap = await store.getSessionSnapshot(project.id, branch.id);
796
+ expect(snap.planTree).toBeDefined();
797
+ expect(snap.planTree.length).toBe(1);
798
+ expect(snap.planTree[0].title).toBe('In-progress plan');
799
+ });
800
+ it('createCommit completesTasks — handle match marks the task done', async () => {
801
+ const project = await store.createProject({ name: 'p' });
802
+ const branch = await store.createBranch({ projectId: project.id, name: 'main', gitBranch: 'main' });
803
+ const plan = await store.insertPlanTree(project.id, {
804
+ title: 'P', children: [{ title: 'S', children: [{ title: 'T1' }] }],
805
+ });
806
+ const taskId = plan.children[0].children[0].id;
807
+ const handle = taskId.slice(0, 6);
808
+ await store.createCommit({
809
+ branchId: branch.id, agentId: 'a', agentRole: 'solo', tool: 't',
810
+ workflowType: 'interactive', message: 'done T1', content: 'x', summary: 'x',
811
+ commitType: 'manual',
812
+ completesTasks: [handle],
813
+ });
814
+ expect(await store.listCompletedPlans(project.id)).toHaveLength(1);
815
+ });
816
+ it('createCommit completesTasks — title match marks the task done', async () => {
817
+ const project = await store.createProject({ name: 'p' });
818
+ const branch = await store.createBranch({ projectId: project.id, name: 'main', gitBranch: 'main' });
819
+ await store.insertPlanTree(project.id, {
820
+ title: 'P', children: [{ title: 'S', children: [{ title: 'Specific Task Title' }] }],
821
+ });
822
+ await store.createCommit({
823
+ branchId: branch.id, agentId: 'a', agentRole: 'solo', tool: 't',
824
+ workflowType: 'interactive', message: 'done', content: 'x', summary: 'x',
825
+ commitType: 'manual',
826
+ completesTasks: ['Specific Task Title'],
827
+ });
828
+ expect(await store.listCompletedPlans(project.id)).toHaveLength(1);
829
+ });
830
+ it('createCommit completesTasks — already-done node is a no-op success', async () => {
831
+ const project = await store.createProject({ name: 'p' });
832
+ const branch = await store.createBranch({ projectId: project.id, name: 'main', gitBranch: 'main' });
833
+ const plan = await store.insertPlanTree(project.id, {
834
+ title: 'P', children: [{ title: 'S', children: [{ title: 'T' }] }],
835
+ });
836
+ const taskId = plan.children[0].children[0].id;
837
+ const handle = taskId.slice(0, 6);
838
+ await store.updatePlanNodeStatus(taskId, 'done', 'prior-commit');
839
+ // Same handle on a new save — must NOT throw.
840
+ await store.createCommit({
841
+ branchId: branch.id, agentId: 'a', agentRole: 'solo', tool: 't',
842
+ workflowType: 'interactive', message: 'c', content: 'y', summary: 'y',
843
+ commitType: 'manual',
844
+ completesTasks: [handle],
845
+ });
846
+ expect(await store.listCompletedPlans(project.id)).toHaveLength(1);
847
+ });
848
+ it('createCommit completesTasks — no match aborts the entire save atomically', async () => {
849
+ const project = await store.createProject({ name: 'p' });
850
+ const branch = await store.createBranch({ projectId: project.id, name: 'main', gitBranch: 'main' });
851
+ const before = (await store.listCommits(branch.id, { limit: 100, offset: 0 })).length;
852
+ await expect(store.createCommit({
853
+ branchId: branch.id, agentId: 'a', agentRole: 'solo', tool: 't',
854
+ workflowType: 'interactive', message: 'c', content: 'y', summary: 'y',
855
+ commitType: 'manual',
856
+ completesTasks: ['xxxxxx-bogus'],
857
+ })).rejects.toThrow(/completesTasks/);
858
+ const after = (await store.listCommits(branch.id, { limit: 100, offset: 0 })).length;
859
+ expect(after).toBe(before);
860
+ });
221
861
  });
222
862
  //# sourceMappingURL=local-store.test.js.map