@contextgit/store 0.1.10 → 0.2.1

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 (38) hide show
  1. package/README.md +16 -0
  2. package/dist/interface.d.ts +26 -1
  3. package/dist/interface.d.ts.map +1 -1
  4. package/dist/local/index.d.ts +26 -1
  5. package/dist/local/index.d.ts.map +1 -1
  6. package/dist/local/index.js +216 -6
  7. package/dist/local/index.js.map +1 -1
  8. package/dist/local/local-store.test.js +642 -2
  9. package/dist/local/local-store.test.js.map +1 -1
  10. package/dist/local/migrations.d.ts +11 -0
  11. package/dist/local/migrations.d.ts.map +1 -1
  12. package/dist/local/migrations.js +118 -1
  13. package/dist/local/migrations.js.map +1 -1
  14. package/dist/local/plan-nodes.test.d.ts +2 -0
  15. package/dist/local/plan-nodes.test.d.ts.map +1 -0
  16. package/dist/local/plan-nodes.test.js +291 -0
  17. package/dist/local/plan-nodes.test.js.map +1 -0
  18. package/dist/local/queries.d.ts +103 -2
  19. package/dist/local/queries.d.ts.map +1 -1
  20. package/dist/local/queries.js +506 -16
  21. package/dist/local/queries.js.map +1 -1
  22. package/dist/local/schema.d.ts +8 -0
  23. package/dist/local/schema.d.ts.map +1 -1
  24. package/dist/local/schema.js +88 -0
  25. package/dist/local/schema.js.map +1 -1
  26. package/dist/local/thread-archive.test.d.ts +2 -0
  27. package/dist/local/thread-archive.test.d.ts.map +1 -0
  28. package/dist/local/thread-archive.test.js +203 -0
  29. package/dist/local/thread-archive.test.js.map +1 -0
  30. package/dist/remote/index.d.ts +15 -0
  31. package/dist/remote/index.d.ts.map +1 -1
  32. package/dist/remote/index.js +44 -0
  33. package/dist/remote/index.js.map +1 -1
  34. package/dist/supabase/index.d.ts +15 -0
  35. package/dist/supabase/index.d.ts.map +1 -1
  36. package/dist/supabase/index.js +45 -2
  37. package/dist/supabase/index.js.map +1 -1
  38. package/package.json +2 -2
@@ -1,6 +1,8 @@
1
1
  // All SQL queries for LocalStore.
2
2
  // Dates are stored as INTEGER (Unix ms) and converted to/from Date at this layer.
3
3
  // IDs are generated by the caller (nanoid).
4
+ import { nanoid } from 'nanoid';
5
+ import { classifyThread, normalizeThreadSubject } from '@contextgit/core';
4
6
  // ─── Row → domain type converters ───────────────────────────────────────────
5
7
  function toProject(row) {
6
8
  return {
@@ -53,8 +55,10 @@ function toThread(row) {
53
55
  branchId: row.branch_id,
54
56
  description: row.description,
55
57
  status: row.status,
58
+ kind: row.kind,
56
59
  workflowType: row.workflow_type ?? undefined,
57
60
  openedInCommit: row.opened_in_commit,
61
+ lastTouchedCommit: row.last_touched_commit ?? undefined,
58
62
  closedInCommit: row.closed_in_commit ?? undefined,
59
63
  closedNote: row.closed_note ?? undefined,
60
64
  createdAt: new Date(row.created_at),
@@ -76,6 +80,37 @@ function toClaim(row) {
76
80
  threadId: row.thread_id ?? undefined,
77
81
  };
78
82
  }
83
+ function toTraceEntry(row) {
84
+ return {
85
+ id: row.id,
86
+ projectId: row.project_id,
87
+ branchId: row.branch_id,
88
+ note: row.note,
89
+ gitCommitSha: row.git_commit_sha ?? undefined,
90
+ createdAt: new Date(row.created_at),
91
+ };
92
+ }
93
+ function toArchivedThread(row) {
94
+ return {
95
+ ...toThread(row),
96
+ archivedAt: new Date(row.archived_at),
97
+ archivedReason: row.archived_reason,
98
+ };
99
+ }
100
+ function toPlanNode(row) {
101
+ return {
102
+ id: row.id,
103
+ projectId: row.project_id,
104
+ parentId: row.parent_id ?? undefined,
105
+ level: row.level,
106
+ title: row.title,
107
+ status: row.status,
108
+ position: row.position,
109
+ gitCommitSha: row.git_commit_sha ?? undefined,
110
+ createdAt: new Date(row.created_at),
111
+ completedAt: row.completed_at ? new Date(row.completed_at) : undefined,
112
+ };
113
+ }
79
114
  function toAgent(row) {
80
115
  return {
81
116
  id: row.id,
@@ -136,28 +171,100 @@ export class Queries {
136
171
  // Threads
137
172
  insertThread: db.prepare(`
138
173
  INSERT INTO threads
139
- (id, project_id, branch_id, description, status, workflow_type,
140
- opened_in_commit, created_at)
174
+ (id, project_id, branch_id, description, status, kind, workflow_type,
175
+ opened_in_commit, last_touched_commit, created_at)
141
176
  VALUES
142
- (@id, @project_id, @branch_id, @description, 'open', @workflow_type,
143
- @opened_in_commit, @created_at)
177
+ (@id, @project_id, @branch_id, @description, 'open', @kind, @workflow_type,
178
+ @opened_in_commit, @opened_in_commit, @created_at)
144
179
  `),
145
180
  syncThread: db.prepare(`
146
181
  INSERT OR IGNORE INTO threads
147
- (id, project_id, branch_id, description, status, workflow_type,
148
- opened_in_commit, created_at)
182
+ (id, project_id, branch_id, description, status, kind, workflow_type,
183
+ opened_in_commit, last_touched_commit, created_at)
149
184
  VALUES
150
- (@id, @project_id, @branch_id, @description, @status, @workflow_type,
151
- @opened_in_commit, @created_at)
185
+ (@id, @project_id, @branch_id, @description, @status, @kind, @workflow_type,
186
+ @opened_in_commit, @last_touched_commit, @created_at)
152
187
  `),
153
188
  closeThread: db.prepare(`
154
189
  UPDATE threads
155
190
  SET status = 'closed', closed_in_commit = ?, closed_note = ?
156
191
  WHERE id = ?
192
+ `),
193
+ updateThreadLastTouched: db.prepare(`
194
+ UPDATE threads SET last_touched_commit = ? WHERE id = ?
157
195
  `),
158
196
  selectOpenThreads: db.prepare(`SELECT * FROM threads WHERE project_id = ? AND status = 'open' ORDER BY created_at ASC`),
159
197
  selectOpenThreadsByBranch: db.prepare(`SELECT * FROM threads WHERE branch_id = ? AND status = 'open' ORDER BY created_at ASC`),
160
198
  reassignThreads: db.prepare(`UPDATE threads SET branch_id = ? WHERE branch_id = ? AND status = 'open'`),
199
+ // thread_archive (03 DELTA)
200
+ insertThreadArchive: db.prepare(`
201
+ INSERT INTO thread_archive
202
+ (id, project_id, branch_id, description, status, kind, workflow_type,
203
+ opened_in_commit, last_touched_commit, closed_in_commit, closed_note,
204
+ created_at, updated_at, archived_at, archived_reason)
205
+ SELECT
206
+ id, project_id, branch_id, description, status, kind, workflow_type,
207
+ opened_in_commit, last_touched_commit, ?, ?,
208
+ created_at, updated_at, ?, ?
209
+ FROM threads WHERE id = ?
210
+ `),
211
+ deleteThread: db.prepare(`DELETE FROM threads WHERE id = ?`),
212
+ selectArchivedThread: db.prepare(`SELECT * FROM thread_archive WHERE id = ?`),
213
+ restoreFromArchive: db.prepare(`
214
+ INSERT INTO threads
215
+ (id, project_id, branch_id, description, status, kind, workflow_type,
216
+ opened_in_commit, last_touched_commit, closed_in_commit, closed_note,
217
+ created_at, updated_at)
218
+ SELECT
219
+ id, project_id, branch_id, description, 'open', kind, workflow_type,
220
+ opened_in_commit, last_touched_commit, NULL, NULL,
221
+ created_at, ?
222
+ FROM thread_archive WHERE id = ?
223
+ `),
224
+ deleteFromArchive: db.prepare(`DELETE FROM thread_archive WHERE id = ?`),
225
+ selectThread: db.prepare(`SELECT * FROM threads WHERE id = ?`),
226
+ listArchivedByProject: db.prepare(`
227
+ SELECT * FROM thread_archive WHERE project_id = ? ORDER BY archived_at DESC
228
+ `),
229
+ findOpenByHandle: db.prepare(`
230
+ SELECT * FROM threads
231
+ WHERE project_id = ? AND status = 'open' AND id LIKE ? || '%'
232
+ LIMIT 2
233
+ `),
234
+ findArchivedByHandle: db.prepare(`
235
+ SELECT * FROM thread_archive
236
+ WHERE project_id = ? AND id LIKE ? || '%'
237
+ LIMIT 2
238
+ `),
239
+ // plan_nodes (04 DELTA)
240
+ insertPlanNode: db.prepare(`
241
+ INSERT INTO plan_nodes
242
+ (id, project_id, parent_id, level, title, status, position, git_commit_sha, created_at, completed_at)
243
+ VALUES
244
+ (@id, @project_id, @parent_id, @level, @title, @status, @position, @git_commit_sha, @created_at, @completed_at)
245
+ `),
246
+ selectPlanNode: db.prepare(`SELECT * FROM plan_nodes WHERE id = ?`),
247
+ listPlanNodesByProject: db.prepare(`
248
+ SELECT * FROM plan_nodes WHERE project_id = ? ORDER BY level, position
249
+ `),
250
+ updatePlanNodeStatus: db.prepare(`
251
+ UPDATE plan_nodes
252
+ SET status = @status,
253
+ completed_at = @completed_at,
254
+ git_commit_sha = COALESCE(@git_commit_sha, git_commit_sha)
255
+ WHERE id = @id
256
+ `),
257
+ updatePlanNodeTitle: db.prepare(`UPDATE plan_nodes SET title = ? WHERE id = ?`),
258
+ findPlanByHandle: db.prepare(`
259
+ SELECT * FROM plan_nodes
260
+ WHERE project_id = ? AND id LIKE ? || '%'
261
+ LIMIT 2
262
+ `),
263
+ findPlanByTitle: db.prepare(`
264
+ SELECT * FROM plan_nodes
265
+ WHERE project_id = ? AND title = ?
266
+ LIMIT 2
267
+ `),
161
268
  // Agents
162
269
  insertAgent: db.prepare(`
163
270
  INSERT INTO agents
@@ -214,6 +321,26 @@ export class Queries {
214
321
  `),
215
322
  selectThreadChangesSince: db.prepare(`
216
323
  SELECT * FROM threads WHERE project_id = ? AND (created_at > ? OR updated_at > ?) ORDER BY created_at ASC
324
+ `),
325
+ selectCommitCreatedAt: db.prepare(`
326
+ SELECT created_at FROM commits WHERE id = ?
327
+ `),
328
+ countProjectCommitsSince: db.prepare(`
329
+ SELECT COUNT(*) AS n FROM commits c
330
+ JOIN branches b ON c.branch_id = b.id
331
+ WHERE b.project_id = ? AND c.created_at > ?
332
+ `),
333
+ countBranchCommitsSince: db.prepare(`
334
+ SELECT COUNT(*) AS n FROM commits WHERE branch_id = ? AND created_at > ?
335
+ `),
336
+ insertTraceEntry: db.prepare(`
337
+ INSERT INTO trace (id, project_id, branch_id, note, git_commit_sha, created_at)
338
+ VALUES (@id, @project_id, @branch_id, @note, @git_commit_sha, @created_at)
339
+ `),
340
+ selectTraceEntries: db.prepare(`
341
+ SELECT * FROM trace WHERE project_id = ?
342
+ ORDER BY created_at DESC, rowid DESC
343
+ LIMIT ? OFFSET ?
217
344
  `),
218
345
  };
219
346
  }
@@ -339,13 +466,14 @@ export class Queries {
339
466
  return row ? toCommit(row) : null;
340
467
  }
341
468
  // ─── Threads ──────────────────────────────────────────────────────────────
342
- insertThread(id, description, projectId, branchId, openedInCommit, workflowType) {
469
+ insertThread(id, description, projectId, branchId, openedInCommit, workflowType, kind = 'open') {
343
470
  const now = Date.now();
344
471
  this.stmts.insertThread.run({
345
472
  id,
346
473
  project_id: projectId,
347
474
  branch_id: branchId,
348
475
  description,
476
+ kind,
349
477
  workflow_type: workflowType,
350
478
  opened_in_commit: openedInCommit,
351
479
  created_at: now,
@@ -356,8 +484,10 @@ export class Queries {
356
484
  branchId,
357
485
  description,
358
486
  status: 'open',
487
+ kind,
359
488
  workflowType: workflowType ?? undefined,
360
489
  openedInCommit,
490
+ lastTouchedCommit: openedInCommit,
361
491
  createdAt: new Date(now),
362
492
  };
363
493
  }
@@ -368,8 +498,10 @@ export class Queries {
368
498
  branch_id: thread.branchId,
369
499
  description: thread.description,
370
500
  status: thread.status,
501
+ kind: thread.kind ?? 'open',
371
502
  workflow_type: thread.workflowType ?? null,
372
503
  opened_in_commit: thread.openedInCommit,
504
+ last_touched_commit: thread.lastTouchedCommit ?? thread.openedInCommit,
373
505
  created_at: thread.createdAt.getTime(),
374
506
  });
375
507
  return thread;
@@ -377,18 +509,196 @@ export class Queries {
377
509
  closeThread(threadId, closedInCommit, note) {
378
510
  this.stmts.closeThread.run(closedInCommit, note, threadId);
379
511
  }
512
+ /**
513
+ * Move an open thread from `threads` to `thread_archive`. Single transaction.
514
+ * `closedInCommit` is the commit triggering the archive (the save's new commit
515
+ * for manual closes; the last-touched commit for sweep-based archival).
516
+ */
517
+ archiveThread(threadId, reason, closedInCommit) {
518
+ const now = Date.now();
519
+ this.db.transaction(() => {
520
+ this.stmts.insertThreadArchive.run(closedInCommit, null, now, reason, threadId);
521
+ this.stmts.deleteThread.run(threadId);
522
+ })();
523
+ const row = this.stmts.selectArchivedThread.get(threadId);
524
+ if (!row)
525
+ throw new Error(`archiveThread: thread ${threadId} not found after move`);
526
+ return toArchivedThread(row);
527
+ }
528
+ /**
529
+ * Move a row from `thread_archive` back to `threads` with `status='open'`,
530
+ * clearing closed_in_commit and closed_note (the restore semantically reopens it).
531
+ */
532
+ restoreThread(threadId) {
533
+ const now = Date.now();
534
+ this.db.transaction(() => {
535
+ this.stmts.restoreFromArchive.run(now, threadId);
536
+ this.stmts.deleteFromArchive.run(threadId);
537
+ })();
538
+ const row = this.stmts.selectThread.get(threadId);
539
+ if (!row)
540
+ throw new Error(`restoreThread: thread ${threadId} not found after move`);
541
+ return toThread(row);
542
+ }
543
+ listArchivedThreads(projectId) {
544
+ const rows = this.stmts.listArchivedByProject.all(projectId);
545
+ return rows.map(toArchivedThread);
546
+ }
547
+ /**
548
+ * Bulk restore: move every archived row matching one of the given reasons back
549
+ * to `threads`. Returns the number of rows moved. Used to recover from a wrong
550
+ * one-time-sweep (e.g. the 0.2.1 calibration bug that archived live threads on
551
+ * long-lived branches via the old OR-of-distance rule).
552
+ */
553
+ restoreAllArchivedByReason(projectId, reasons) {
554
+ if (reasons.length === 0)
555
+ return 0;
556
+ const rows = this.stmts.listArchivedByProject.all(projectId);
557
+ const targets = rows.filter((r) => reasons.includes(r.archived_reason));
558
+ if (targets.length === 0)
559
+ return 0;
560
+ const now = Date.now();
561
+ this.db.transaction(() => {
562
+ for (const r of targets) {
563
+ this.stmts.restoreFromArchive.run(now, r.id);
564
+ this.stmts.deleteFromArchive.run(r.id);
565
+ }
566
+ })();
567
+ return targets.length;
568
+ }
569
+ /**
570
+ * Find an open thread on this project whose normalized description matches.
571
+ * Used by dedupe-on-save (02 DELTA spec §A) — open-thread count per project is
572
+ * bounded by the decay system being built, so a JS-side scan is acceptable.
573
+ */
574
+ findOpenThreadByNormalizedDescription(projectId, normalized) {
575
+ const rows = this.stmts.selectOpenThreads.all(projectId);
576
+ for (const row of rows) {
577
+ if (normalizeThreadSubject(row.description) === normalized)
578
+ return toThread(row);
579
+ }
580
+ return undefined;
581
+ }
582
+ /**
583
+ * Resolve a 6-char handle to an open thread on the project.
584
+ * Returns undefined if no match; throws if multiple match (collision).
585
+ */
586
+ findOpenThreadByHandle(projectId, handle) {
587
+ const rows = this.stmts.findOpenByHandle.all(projectId, handle);
588
+ if (rows.length === 0)
589
+ return undefined;
590
+ if (rows.length > 1) {
591
+ throw new Error(`findOpenThreadByHandle: handle '${handle}' matches ${rows.length} threads`);
592
+ }
593
+ const now = Date.now();
594
+ return this.attachDecayFlags(toThread(rows[0]), now);
595
+ }
596
+ findArchivedThreadByHandle(projectId, handle) {
597
+ const rows = this.stmts.findArchivedByHandle.all(projectId, handle);
598
+ if (rows.length === 0)
599
+ return undefined;
600
+ if (rows.length > 1) {
601
+ throw new Error(`findArchivedThreadByHandle: handle '${handle}' matches ${rows.length} threads`);
602
+ }
603
+ return toArchivedThread(rows[0]);
604
+ }
605
+ /**
606
+ * Project-scoped sweep run by createCommit at the end of every save: any open
607
+ * thread on this project that classifies as `stale` or `expired` is moved to
608
+ * `thread_archive`. Returns counts by reason for the save response.
609
+ */
610
+ sweepStaleThreads(projectId, now) {
611
+ const threads = this.listOpenThreads(projectId);
612
+ const byReason = { 'stale-age': 0, 'stale-distance': 0, 'watch-expired': 0 };
613
+ let archived = 0;
614
+ for (const t of threads) {
615
+ if (!t.stale && !t.expired)
616
+ continue;
617
+ let reason;
618
+ if (t.expired) {
619
+ reason = 'watch-expired';
620
+ }
621
+ else {
622
+ const touchId = t.lastTouchedCommit ?? t.openedInCommit;
623
+ const tsRow = this.stmts.selectCommitCreatedAt.get(touchId);
624
+ const touchTs = tsRow?.created_at ?? t.createdAt.getTime();
625
+ const branchN = this.stmts.countBranchCommitsSince.get(t.branchId, touchTs).n;
626
+ reason = branchN >= 30 ? 'stale-distance' : 'stale-age';
627
+ }
628
+ this.stmts.insertThreadArchive.run(null, null, now, reason, t.id);
629
+ this.stmts.deleteThread.run(t.id);
630
+ byReason[reason]++;
631
+ archived++;
632
+ }
633
+ return { archived, byReason };
634
+ }
635
+ updateThreadLastTouched(threadId, commitId) {
636
+ this.stmts.updateThreadLastTouched.run(commitId, threadId);
637
+ }
380
638
  listOpenThreads(projectId) {
381
639
  const rows = this.stmts.selectOpenThreads.all(projectId);
382
- return rows.map(toThread);
640
+ const now = Date.now();
641
+ return rows.map(toThread).map((t) => this.attachDecayFlags(t, now));
383
642
  }
384
643
  listOpenThreadsByBranch(branchId) {
385
644
  const rows = this.stmts.selectOpenThreadsByBranch.all(branchId);
386
- return rows.map(toThread);
645
+ const now = Date.now();
646
+ return rows.map(toThread).map((t) => this.attachDecayFlags(t, now));
647
+ }
648
+ /**
649
+ * Set the derived `stale` (open) or `expired` (watch) flag on a thread by
650
+ * looking up the touched commit's timestamp and counting project/branch
651
+ * commits since. Returns a new Thread — never mutates the input.
652
+ */
653
+ attachDecayFlags(thread, now) {
654
+ const touchCommitId = thread.lastTouchedCommit ?? thread.openedInCommit;
655
+ const touchRow = this.stmts.selectCommitCreatedAt.get(touchCommitId);
656
+ const touchTs = touchRow?.created_at ?? thread.createdAt.getTime();
657
+ const projectRow = this.stmts.countProjectCommitsSince.get(thread.projectId, touchTs);
658
+ const branchRow = this.stmts.countBranchCommitsSince.get(thread.branchId, touchTs);
659
+ const flag = classifyThread(thread, {
660
+ touchTs,
661
+ projectCommitsSince: projectRow.n,
662
+ branchCommitsSince: branchRow.n,
663
+ now,
664
+ });
665
+ if (flag === 'stale')
666
+ return { ...thread, stale: true };
667
+ if (flag === 'expired')
668
+ return { ...thread, expired: true };
669
+ return thread;
387
670
  }
388
671
  /** Move open threads from source branch to target (called during merge). */
389
672
  reassignOpenThreads(fromBranchId, toBranchId) {
390
673
  this.stmts.reassignThreads.run(toBranchId, fromBranchId);
391
674
  }
675
+ // ─── Trace (fine tier, 02 DELTA) ─────────────────────────────────────────
676
+ //
677
+ // Append-only, pull-only. NEVER included in getSessionSnapshot / default load.
678
+ // Retrieved via project_memory_retrieve(tier: 'trace', window, offset).
679
+ insertTraceEntry(input) {
680
+ const now = Date.now();
681
+ this.stmts.insertTraceEntry.run({
682
+ id: input.id,
683
+ project_id: input.projectId,
684
+ branch_id: input.branchId,
685
+ note: input.note,
686
+ git_commit_sha: input.gitCommitSha ?? null,
687
+ created_at: now,
688
+ });
689
+ return {
690
+ id: input.id,
691
+ projectId: input.projectId,
692
+ branchId: input.branchId,
693
+ note: input.note,
694
+ gitCommitSha: input.gitCommitSha,
695
+ createdAt: new Date(now),
696
+ };
697
+ }
698
+ listTraceEntries(projectId, pagination) {
699
+ const rows = this.stmts.selectTraceEntries.all(projectId, pagination.limit, pagination.offset);
700
+ return rows.map(toTraceEntry);
701
+ }
392
702
  // ─── Agents ───────────────────────────────────────────────────────────────
393
703
  upsertAgent(input) {
394
704
  const now = Date.now();
@@ -463,17 +773,37 @@ export class Queries {
463
773
  const branchSummary = branch?.headCommitId
464
774
  ? (this.getCommit(branch.headCommitId)?.summary ?? '')
465
775
  : '';
466
- // Last 3 commits on current branch (optionally filtered by agent role)
776
+ // Recent commits on current branch windowed via 02 DELTA commitWindow (default 5).
777
+ const commitWindow = options?.commitWindow ?? 5;
467
778
  const recentCommits = options?.agentRole
468
- ? this.stmts.selectCommitsByRole.all(branchId, options.agentRole, 3).map(toCommit)
469
- : this.listCommits(branchId, { limit: 3, offset: 0 });
470
- // All open threads for the project
471
- const openThreads = this.listOpenThreads(projectId);
779
+ ? this.stmts.selectCommitsByRole.all(branchId, options.agentRole, commitWindow).map(toCommit)
780
+ : this.listCommits(branchId, { limit: commitWindow, offset: 0 });
781
+ // All open threads (decorated with stale/expired flags by listOpenThreads).
782
+ // The default snapshot returns only live threads — the curated load (02 DELTA §B/§C):
783
+ // • kind='open' threads filtered out when stale=true → counted in staleThreadCount
784
+ // • kind='watch' threads filtered out when expired=true → counted in expiredWatchCount
785
+ // Stale/expired threads stay retrievable via project_memory_threads tool (Step 5).
786
+ const allOpenThreads = this.listOpenThreads(projectId);
787
+ let staleThreadCount = 0;
788
+ let expiredWatchCount = 0;
789
+ const openThreads = [];
790
+ for (const t of allOpenThreads) {
791
+ if (t.stale) {
792
+ staleThreadCount++;
793
+ continue;
794
+ }
795
+ if (t.expired) {
796
+ expiredWatchCount++;
797
+ continue;
798
+ }
799
+ openThreads.push(t);
800
+ }
472
801
  const activeClaims = this.listActiveClaims(projectId);
473
802
  // Check if the project has been initiated: true if main/master branch has a head commit
474
803
  // OR if the current branch has any commits (covers projects without a named main branch)
475
804
  const allCommits = this.listCommits(branchId, { limit: 1, offset: 0 });
476
805
  const isInitiated = mainBranch?.headCommitId != null || allCommits.length > 0;
806
+ const planTree = this.getPlanTree(projectId);
477
807
  return {
478
808
  projectSummary,
479
809
  branchName: branch?.name ?? '',
@@ -482,6 +812,9 @@ export class Queries {
482
812
  openThreads,
483
813
  activeClaims,
484
814
  isInitiated,
815
+ staleThreadCount,
816
+ expiredWatchCount,
817
+ planTree,
485
818
  };
486
819
  }
487
820
  // ─── Semantic search (sqlite-vec) ─────────────────────────────────────────
@@ -545,5 +878,162 @@ export class Queries {
545
878
  // sqlite-vec not available
546
879
  }
547
880
  }
881
+ // ─── plan_nodes (04 DELTA) ────────────────────────────────────────────────
882
+ /**
883
+ * Insert a plan tree of arbitrary depth atomically. `level` is semantic:
884
+ * - root (parent_id IS NULL) → 'plan'
885
+ * - has children at insert time → 'step' (container)
886
+ * - no children at insert time → 'task' (leaf)
887
+ *
888
+ * Hierarchy is determined by parent_id, not by depth. The depth-based
889
+ * mapping from earlier 04 DELTA versions was wrong on trees deeper than 3
890
+ * (Slice at depth 2 would get labeled 'task' but actually had children).
891
+ * Content-based labeling is correct at any depth.
892
+ */
893
+ insertPlanTree(projectId, input) {
894
+ const now = Date.now();
895
+ const levelFor = (isRoot, hasChildren) => {
896
+ if (isRoot)
897
+ return 'plan';
898
+ return hasChildren ? 'step' : 'task';
899
+ };
900
+ const insertRecursive = (node, parentId, position) => {
901
+ const id = nanoid();
902
+ const isRoot = parentId === null;
903
+ const hasChildren = (node.children?.length ?? 0) > 0;
904
+ const level = levelFor(isRoot, hasChildren);
905
+ this.stmts.insertPlanNode.run({
906
+ id,
907
+ project_id: projectId,
908
+ parent_id: parentId,
909
+ level,
910
+ title: node.title,
911
+ status: node.status ?? 'pending',
912
+ position,
913
+ git_commit_sha: null,
914
+ created_at: now,
915
+ completed_at: null,
916
+ });
917
+ const children = node.children?.map((c, i) => insertRecursive(c, id, i)) ?? [];
918
+ const inserted = toPlanNode(this.stmts.selectPlanNode.get(id));
919
+ if (children.length)
920
+ inserted.children = children;
921
+ return inserted;
922
+ };
923
+ return this.db.transaction(() => insertRecursive(input, null, 0))();
924
+ }
925
+ /**
926
+ * Read the active plan tree for a project. Top-level plans with full child
927
+ * trees and derived `progress = { done, total }` per node — DIRECT-CHILD
928
+ * semantics: progress.total = number of direct children; progress.done =
929
+ * number of direct children that are effectively-done. Effectively-done
930
+ * propagates recursively: a node is effectively-done if (it has no children
931
+ * AND status='done') OR (it has children AND every child is effectively-done).
932
+ *
933
+ * Plans that are effectively-done are excluded from the active view — they
934
+ * belong in listCompletedPlans.
935
+ */
936
+ getPlanTree(projectId) {
937
+ return this.buildPlanTrees(projectId, (_n, isDone) => !isDone);
938
+ }
939
+ /**
940
+ * List plans (parent_id IS NULL) that are effectively-done. The completed
941
+ * delivery record — kept indefinitely, never archived.
942
+ */
943
+ listCompletedPlans(projectId) {
944
+ return this.buildPlanTrees(projectId, (_n, isDone) => isDone);
945
+ }
946
+ /** Shared tree builder for getPlanTree / listCompletedPlans. */
947
+ buildPlanTrees(projectId, includeTop) {
948
+ const rows = this.stmts.listPlanNodesByProject.all(projectId);
949
+ const byId = new Map();
950
+ const childrenByParent = new Map();
951
+ for (const row of rows) {
952
+ const node = toPlanNode(row);
953
+ byId.set(node.id, node);
954
+ if (node.parentId) {
955
+ if (!childrenByParent.has(node.parentId))
956
+ childrenByParent.set(node.parentId, []);
957
+ childrenByParent.get(node.parentId).push(node);
958
+ }
959
+ }
960
+ for (const node of byId.values()) {
961
+ const kids = childrenByParent.get(node.id);
962
+ if (kids) {
963
+ kids.sort((a, b) => a.position - b.position);
964
+ node.children = kids;
965
+ }
966
+ }
967
+ // Walk the subtree. For each container (level='plan'|'step'), set
968
+ // progress = {done: direct children that are complete, total: direct
969
+ // children count}. Returns whether this node itself is complete.
970
+ //
971
+ // Completeness rule (level-aware, depth-agnostic):
972
+ // - level='task' (leaf-by-design): complete iff status='done'.
973
+ // - level='plan'|'step' (container): complete iff has at least one
974
+ // child AND every child is complete.
975
+ //
976
+ // Containers with no children are NEVER complete — status='done' on an
977
+ // empty container is meaningless for the rollup. The level enum is what
978
+ // distinguishes "this is a leaf, status drives" from "this is a
979
+ // container, children drive."
980
+ const walkAndSetProgress = (node) => {
981
+ if (node.level === 'task') {
982
+ return node.status === 'done';
983
+ }
984
+ // 'plan' or 'step' — container semantics.
985
+ if (!node.children || node.children.length === 0) {
986
+ return false;
987
+ }
988
+ let doneCount = 0;
989
+ for (const c of node.children) {
990
+ if (walkAndSetProgress(c))
991
+ doneCount++;
992
+ }
993
+ node.progress = { done: doneCount, total: node.children.length };
994
+ return doneCount === node.children.length;
995
+ };
996
+ const tops = [];
997
+ for (const node of byId.values()) {
998
+ if (!node.parentId) {
999
+ const isDone = walkAndSetProgress(node);
1000
+ if (includeTop(node, isDone))
1001
+ tops.push(node);
1002
+ }
1003
+ }
1004
+ tops.sort((a, b) => a.position - b.position);
1005
+ return tops;
1006
+ }
1007
+ /**
1008
+ * Set a plan node's status. When transitioning to 'done', stamps completed_at
1009
+ * and optionally git_commit_sha (the commit that completed it).
1010
+ */
1011
+ updatePlanNodeStatus(id, status, gitCommitSha) {
1012
+ const completedAt = status === 'done' ? Date.now() : null;
1013
+ this.stmts.updatePlanNodeStatus.run({ id, status, completed_at: completedAt, git_commit_sha: gitCommitSha });
1014
+ }
1015
+ updatePlanNodeTitle(id, title) {
1016
+ this.stmts.updatePlanNodeTitle.run(title, id);
1017
+ }
1018
+ /** Resolve a 6-char handle to a plan node. Throws on prefix collision. */
1019
+ findPlanNodeByHandle(projectId, handle) {
1020
+ const rows = this.stmts.findPlanByHandle.all(projectId, handle);
1021
+ if (rows.length === 0)
1022
+ return undefined;
1023
+ if (rows.length > 1) {
1024
+ throw new Error(`findPlanNodeByHandle: handle '${handle}' matches ${rows.length} plan nodes`);
1025
+ }
1026
+ return toPlanNode(rows[0]);
1027
+ }
1028
+ /** Resolve an exact title match. Throws when the title is ambiguous across nodes. */
1029
+ findPlanNodeByTitle(projectId, title) {
1030
+ const rows = this.stmts.findPlanByTitle.all(projectId, title);
1031
+ if (rows.length === 0)
1032
+ return undefined;
1033
+ if (rows.length > 1) {
1034
+ throw new Error(`findPlanNodeByTitle: title '${title}' matches ${rows.length} plan nodes`);
1035
+ }
1036
+ return toPlanNode(rows[0]);
1037
+ }
548
1038
  }
549
1039
  //# sourceMappingURL=queries.js.map