@druumen/sessions-db 0.1.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 (50) hide show
  1. package/CHANGELOG.md +249 -0
  2. package/LICENSE +201 -0
  3. package/NOTICE +10 -0
  4. package/README.md +250 -0
  5. package/cli/_write-helpers.mjs +99 -0
  6. package/cli/alias.mjs +115 -0
  7. package/cli/argparse.mjs +296 -0
  8. package/cli/close.mjs +116 -0
  9. package/cli/find.mjs +185 -0
  10. package/cli/format.mjs +277 -0
  11. package/cli/link-parent.mjs +133 -0
  12. package/cli/link.mjs +132 -0
  13. package/cli/rebuild.mjs +98 -0
  14. package/cli/sessions-db-session-start-main.mjs +454 -0
  15. package/cli/sessions-db-session-start.mjs +56 -0
  16. package/cli/sessions-db.mjs +119 -0
  17. package/cli/sweep.mjs +171 -0
  18. package/cli/tree.mjs +127 -0
  19. package/lib/git-context.mjs +479 -0
  20. package/lib/identity.mjs +616 -0
  21. package/lib/index.mjs +145 -0
  22. package/lib/init.mjs +185 -0
  23. package/lib/lock.mjs +86 -0
  24. package/lib/operations.mjs +490 -0
  25. package/lib/paths.mjs +199 -0
  26. package/lib/projection.mjs +496 -0
  27. package/lib/sanitize.mjs +131 -0
  28. package/lib/storage.mjs +759 -0
  29. package/lib/sweep.mjs +209 -0
  30. package/lib/transcript.mjs +230 -0
  31. package/lib/types.mjs +276 -0
  32. package/lib/uuid.mjs +116 -0
  33. package/lib/watch.mjs +217 -0
  34. package/package.json +53 -0
  35. package/types/git-context.d.mts +98 -0
  36. package/types/identity.d.mts +658 -0
  37. package/types/index.d.mts +10 -0
  38. package/types/index.d.ts +127 -0
  39. package/types/init.d.mts +53 -0
  40. package/types/lock.d.mts +18 -0
  41. package/types/operations.d.mts +204 -0
  42. package/types/paths.d.mts +54 -0
  43. package/types/projection.d.mts +79 -0
  44. package/types/sanitize.d.mts +39 -0
  45. package/types/storage.d.mts +276 -0
  46. package/types/sweep.d.mts +58 -0
  47. package/types/transcript.d.mts +59 -0
  48. package/types/types.d.mts +255 -0
  49. package/types/uuid.d.mts +17 -0
  50. package/types/watch.d.mts +33 -0
@@ -0,0 +1,616 @@
1
+ /**
2
+ * Pure identity reconciliation for sessions-db.
3
+ *
4
+ * Maps a SessionStart hook signal set
5
+ * (claude_session_id, transcript metadata, git context, fingerprints, cwd) to
6
+ * a `stable_id` using a 3-priority lookup chain. No IO, no time, no
7
+ * randomness beyond the optional `mintStableId` callback (which the caller
8
+ * must supply — typically `generateSessionId` from `uuid.mjs`).
9
+ *
10
+ * Strategy (try in order, first match wins):
11
+ *
12
+ * 1. claude_session_id_index (confidence='exact')
13
+ * Scan `projection.sessions[*].claude_session_ids[]` for an exact match.
14
+ * Empty arrays are skipped so non-session_seen-created skeleton records
15
+ * cannot false-match (regression guard from P2 storage round-1 review).
16
+ *
17
+ * 2. transcript_lineage (confidence='high')
18
+ * If `transcriptMeta.firstParentUuid` matches some
19
+ * session.transcript_files[*].lastUuid, this csid is a resume / fork of
20
+ * that session. Structurally derived ID — high confidence, no
21
+ * corroborator needed.
22
+ *
23
+ * 3. fingerprint_corroborator (confidence='low')
24
+ * A fingerprint (first_human_prompt_v1 OR lineage_prefix_v1) match
25
+ * ALONE is too weak. We classify corroborators into two strengths:
26
+ *
27
+ * STRONG (location-anchored — uniquely identify a workspace slot):
28
+ * - same_cwd
29
+ * - same_worktree_realpath
30
+ *
31
+ * WEAK (signal-anchored — frequently shared by unrelated sessions):
32
+ * - same_branch_at_start (e.g. dozens of sessions on `main`)
33
+ * - within_time_window (e.g. dozens of sessions inside any 72h)
34
+ *
35
+ * Acceptance requires BOTH:
36
+ * (a) at least 1 STRONG corroborator, AND
37
+ * (b) total (strong + weak) >= `minCorroborators` (default 2)
38
+ *
39
+ * This blocks the false-merge "same branch + same window alone is enough"
40
+ * pattern that codex round-1 review flagged: two unrelated sessions on
41
+ * `main` within 72h would have weak=2, but strong=0 — must be rejected.
42
+ *
43
+ * Ambiguity rule: if MULTIPLE candidates are above the threshold, we
44
+ * cannot pick one safely. We MINT a fresh stable_id and surface ALL
45
+ * above-threshold candidates as parent_candidates so a human / future
46
+ * manual_link can disambiguate. (Old behavior silently picked the first
47
+ * projection-iteration entry — ordering bug.)
48
+ *
49
+ * Fingerprint matches without enough corroborators are surfaced as
50
+ * `parentCandidates[]` (hub-spoke derivation hints — the caller decides
51
+ * whether to promote them later).
52
+ *
53
+ * To bound payload size (events.jsonl uses MAX_EVENT_BYTES=4096 cap),
54
+ * `parentCandidates[]` is hard-capped at MAX_PARENT_CANDIDATES (default
55
+ * 16). Sort key: (strong corroborator count desc, recency desc). When
56
+ * truncated, `parentCandidatesOmittedCount` carries the omitted count
57
+ * so callers can surface "+ N more" in CLI / audit.
58
+ *
59
+ * If all three miss, the caller mints a fresh stable_id via `mintStableId()`
60
+ * and we return `source: 'minted'`, `confidence: 'minted'`.
61
+ *
62
+ * Priority is strict — P1 hit short-circuits and never queries P2/P3, etc.
63
+ *
64
+ * @typedef {{
65
+ * stable_id: string,
66
+ * claude_session_ids: string[],
67
+ * transcript_files: Array<{ path?: string, last_uuid?: string|null }>,
68
+ * fingerprints: { first_human_prompt_v1: string|null, lineage_prefix_v1: string|null },
69
+ * cwd?: string|null,
70
+ * worktree_realpath?: string|null,
71
+ * branch_at_start?: string|null,
72
+ * last_progress_at?: string|null,
73
+ * }} ProjectionSession
74
+ *
75
+ * @typedef {{
76
+ * _meta: object,
77
+ * sessions: Record<string, ProjectionSession>,
78
+ * }} Projection
79
+ *
80
+ * @typedef {{
81
+ * firstUuid?: string|null,
82
+ * lastUuid?: string|null,
83
+ * firstParentUuid?: string|null,
84
+ * }} TranscriptMetaInput
85
+ *
86
+ * @typedef {{
87
+ * worktreeRealpath?: string|null,
88
+ * worktreePath?: string|null,
89
+ * branch?: string|null,
90
+ * }} GitContextInput
91
+ *
92
+ * @typedef {{ first_human_prompt_v1?: string|null, lineage_prefix_v1?: string|null }} FingerprintInput
93
+ *
94
+ * @typedef {{
95
+ * stableId: string,
96
+ * source: 'claude_session_id_index' | 'transcript_lineage' | 'fingerprint_corroborator' | 'minted',
97
+ * confidence: 'exact' | 'high' | 'low' | 'minted',
98
+ * matched: object,
99
+ * parentCandidates: Array<{ stable_id: string, source: string, confidence: string, reason: object }>,
100
+ * parentCandidatesOmittedCount?: number,
101
+ * }} IdentityResult
102
+ */
103
+
104
+ /** Default time window (hours) for the within_time_window corroborator. */
105
+ const DEFAULT_TIME_WINDOW_HOURS = 72;
106
+ const DEFAULT_MIN_CORROBORATORS = 2;
107
+
108
+ /**
109
+ * Hard cap on `parentCandidates[]` length. Keeps event payloads safely under
110
+ * `storage.MAX_EVENT_BYTES` (4 KiB POSIX PIPE_BUF guarantee).
111
+ *
112
+ * Empirical sizing (after collectParentCandidates payload trim — see that
113
+ * function's bytes-on-disk note): each candidate serializes to ~241 bytes
114
+ * (stable_id ≈ 50 + reason summary ≈ 130 + JSON wrapping ≈ 60). The rest
115
+ * of a typical session_seen payload (csid + transcript_file + fingerprints
116
+ * + git context + identity_resolution audit) is ~500–600 bytes. Budget:
117
+ * 4096 - 600 baseline = 3496 bytes for candidates
118
+ * 3496 / 241 ≈ 14.5 candidates worst-case
119
+ * We cap at 10 to retain safety margin — 10 × 241 + 600 ≈ 3010 bytes,
120
+ * comfortably under the cap with headroom for transcript_file edge cases.
121
+ *
122
+ * Codex round-1 review suggested 16; we reduced to 10 after measuring real
123
+ * payload bytes (their suggestion did not include the per-candidate sizing
124
+ * calculation; 16 candidates would intermittently exceed MAX_EVENT_BYTES
125
+ * and recreate the very rejection the cap exists to prevent).
126
+ *
127
+ * Exported so callers / tests can reason about it. Override is intentionally
128
+ * NOT exposed via opts — keeping it a constant prevents callers from passing
129
+ * a number large enough to re-trip the MAX_EVENT_BYTES rejection.
130
+ */
131
+ export const MAX_PARENT_CANDIDATES = 10;
132
+
133
+ /**
134
+ * Corroborator strength classification. Exported so storage.mjs / projection
135
+ * layers can reason about it consistently (e.g. CLI display, audit reports).
136
+ *
137
+ * STRONG: location-anchored — these uniquely identify a workspace slot.
138
+ * Two unrelated sessions cannot share `cwd` or `worktree_realpath`
139
+ * by accident.
140
+ * WEAK: signal-anchored — frequently shared by unrelated sessions.
141
+ * Many sessions live on `main` (same_branch_at_start) and inside
142
+ * any 72h window (within_time_window).
143
+ */
144
+ export const STRONG_CORROBORATORS = Object.freeze([
145
+ 'same_cwd',
146
+ 'same_worktree_realpath',
147
+ ]);
148
+ export const WEAK_CORROBORATORS = Object.freeze([
149
+ 'same_branch_at_start',
150
+ 'within_time_window',
151
+ ]);
152
+
153
+ /**
154
+ * Compute strong / weak / total counts from a corroborator hit map.
155
+ * @param {{ same_cwd: boolean, same_worktree_realpath: boolean,
156
+ * same_branch_at_start: boolean, within_time_window: boolean }} hits
157
+ * @returns {{ strong: number, weak: number, total: number }}
158
+ */
159
+ export function classifyCorroborators(hits) {
160
+ let strong = 0;
161
+ let weak = 0;
162
+ for (const k of STRONG_CORROBORATORS) if (hits && hits[k] === true) strong += 1;
163
+ for (const k of WEAK_CORROBORATORS) if (hits && hits[k] === true) weak += 1;
164
+ return { strong, weak, total: strong + weak };
165
+ }
166
+
167
+ /**
168
+ * Acceptance gate: meets fingerprint+corroborator threshold for accept-as-
169
+ * identity (vs surface-as-parent_candidate).
170
+ *
171
+ * Requires BOTH:
172
+ * - strong >= 1 (at least one location-anchored signal); AND
173
+ * - total >= minCorroborators (default 2).
174
+ *
175
+ * @param {{ strong: number, weak: number, total: number }} counts
176
+ * @param {{ minCorroborators?: number }} opts
177
+ */
178
+ export function meetsThreshold(counts, opts = {}) {
179
+ if (!counts || typeof counts !== 'object') return false;
180
+ const min = typeof opts.minCorroborators === 'number'
181
+ ? opts.minCorroborators
182
+ : DEFAULT_MIN_CORROBORATORS;
183
+ return counts.strong >= 1 && counts.total >= min;
184
+ }
185
+
186
+ /**
187
+ * Public entry: resolve identity from a hook signal set, OR mint a fresh one.
188
+ *
189
+ * @param {{
190
+ * projection: Projection,
191
+ * claudeSessionId: string,
192
+ * transcriptMeta?: TranscriptMetaInput | null,
193
+ * gitContext?: GitContextInput | null,
194
+ * cwd?: string | null,
195
+ * fingerprints?: FingerprintInput | null,
196
+ * now?: number,
197
+ * timeWindowHours?: number,
198
+ * minCorroborators?: number,
199
+ * mintStableId: () => string,
200
+ * }} input
201
+ * @returns {IdentityResult}
202
+ */
203
+ export function resolveIdentity(input) {
204
+ if (!input || typeof input !== 'object') {
205
+ throw new TypeError('resolveIdentity: input required');
206
+ }
207
+ const {
208
+ projection,
209
+ claudeSessionId,
210
+ transcriptMeta = null,
211
+ gitContext = null,
212
+ cwd = null,
213
+ fingerprints = null,
214
+ now = Date.now(),
215
+ timeWindowHours = DEFAULT_TIME_WINDOW_HOURS,
216
+ minCorroborators = DEFAULT_MIN_CORROBORATORS,
217
+ mintStableId,
218
+ } = input;
219
+
220
+ if (typeof mintStableId !== 'function') {
221
+ throw new TypeError('resolveIdentity: mintStableId callback required');
222
+ }
223
+ if (typeof claudeSessionId !== 'string' || claudeSessionId.length === 0) {
224
+ throw new TypeError('resolveIdentity: claudeSessionId required');
225
+ }
226
+
227
+ // Priority 1 — claude_session_id_index (exact).
228
+ const p1 = findByClaudeSessionId(projection, claudeSessionId);
229
+ if (p1 !== null) {
230
+ return {
231
+ stableId: p1,
232
+ source: 'claude_session_id_index',
233
+ confidence: 'exact',
234
+ matched: { claude_session_id: claudeSessionId },
235
+ // P1 hit — do NOT compute parentCandidates. The session is identified;
236
+ // hub-spoke parent surfacing is only meaningful when we cannot resolve
237
+ // the exact identity from a stable cross-session signal.
238
+ parentCandidates: [],
239
+ parentCandidatesOmittedCount: 0,
240
+ };
241
+ }
242
+
243
+ // Priority 2 — transcript_lineage (high).
244
+ const p2 = findByTranscriptLineage(projection, transcriptMeta);
245
+ if (p2 !== null) {
246
+ return {
247
+ stableId: p2.stableId,
248
+ source: 'transcript_lineage',
249
+ confidence: 'high',
250
+ matched: {
251
+ first_parent_uuid: transcriptMeta?.firstParentUuid ?? null,
252
+ matched_transcript_path: p2.matchedPath,
253
+ matched_last_uuid: p2.matchedLastUuid,
254
+ },
255
+ parentCandidates: [],
256
+ parentCandidatesOmittedCount: 0,
257
+ };
258
+ }
259
+
260
+ // Priority 3 — fingerprint + corroborator (low).
261
+ const corrCtx = {
262
+ cwd: typeof cwd === 'string' && cwd.length > 0 ? cwd : null,
263
+ worktreeRealpath: gitContext && typeof gitContext.worktreeRealpath === 'string' && gitContext.worktreeRealpath.length > 0
264
+ ? gitContext.worktreeRealpath
265
+ : null,
266
+ branch: gitContext && typeof gitContext.branch === 'string' && gitContext.branch.length > 0
267
+ ? gitContext.branch
268
+ : null,
269
+ now,
270
+ timeWindowHours,
271
+ };
272
+ const fpScan = scanFingerprintCandidates(projection, fingerprints, corrCtx);
273
+
274
+ // Partition by acceptance threshold. Acceptance requires >=1 STRONG
275
+ // corroborator AND total >= minCorroborators (see meetsThreshold).
276
+ const above = [];
277
+ const below = [];
278
+ for (const c of fpScan) {
279
+ if (meetsThreshold(c.strengthCounts, { minCorroborators })) above.push(c);
280
+ else below.push(c);
281
+ }
282
+
283
+ // Exactly one above-threshold candidate → safe to accept as identity.
284
+ // Below-threshold candidates still surface as parent_candidates (hub-spoke
285
+ // hints; they share a fingerprint but lack enough corroborators).
286
+ if (above.length === 1) {
287
+ const accepted = above[0];
288
+ const { list, omitted } = capParentCandidates(
289
+ // Other above-threshold (none in this branch) + all below-threshold.
290
+ below.filter((c) => c.stableId !== accepted.stableId),
291
+ );
292
+ return {
293
+ stableId: accepted.stableId,
294
+ source: 'fingerprint_corroborator',
295
+ confidence: 'low',
296
+ matched: {
297
+ fingerprints_matched: accepted.fingerprintsMatched,
298
+ corroborators: accepted.corroborators,
299
+ corroborator_count: accepted.corroboratorCount,
300
+ strong_corroborator_count: accepted.strengthCounts.strong,
301
+ },
302
+ parentCandidates: list,
303
+ parentCandidatesOmittedCount: omitted,
304
+ };
305
+ }
306
+
307
+ // Two cases reach here:
308
+ // 1. above.length === 0 → no acceptable match → mint
309
+ // 2. above.length >= 2 → AMBIGUOUS → mint + surface ALL as candidates
310
+ // (refuse to silently pick first projection-iteration entry)
311
+ const minted = mintStableId();
312
+ const matched = above.length >= 2
313
+ ? { ambiguous: true, ambiguous_count: above.length }
314
+ : {};
315
+ // Order: above-threshold candidates first (stronger evidence), then below.
316
+ // capParentCandidates sorts internally by (strong desc, recency desc), but
317
+ // we surface above before below so the strongest evidence is never trimmed.
318
+ const { list, omitted } = capParentCandidates([...above, ...below]);
319
+ return {
320
+ stableId: minted,
321
+ source: 'minted',
322
+ confidence: 'minted',
323
+ matched,
324
+ parentCandidates: list,
325
+ parentCandidatesOmittedCount: omitted,
326
+ };
327
+ }
328
+
329
+ // ---------------------------------------------------------------------------
330
+ // P1: claude_session_id_index
331
+ // ---------------------------------------------------------------------------
332
+
333
+ /**
334
+ * Scan `projection.sessions` for the first record whose `claude_session_ids[]`
335
+ * contains `csid`. Returns the matching `stable_id` or `null`.
336
+ *
337
+ * Empty `claude_session_ids[]` are skipped so a skeleton record produced by
338
+ * a non-session_seen op (e.g. `manual_link`) never false-matches a fresh
339
+ * incoming claude_session_id.
340
+ *
341
+ * Exported for direct testing.
342
+ */
343
+ export function findByClaudeSessionId(projection, csid) {
344
+ if (!projection || !projection.sessions || typeof projection.sessions !== 'object') {
345
+ return null;
346
+ }
347
+ if (typeof csid !== 'string' || csid.length === 0) return null;
348
+ for (const [stableId, session] of Object.entries(projection.sessions)) {
349
+ if (!session || !Array.isArray(session.claude_session_ids)) continue;
350
+ if (session.claude_session_ids.length === 0) continue;
351
+ if (session.claude_session_ids.includes(csid)) return stableId;
352
+ }
353
+ return null;
354
+ }
355
+
356
+ // ---------------------------------------------------------------------------
357
+ // P2: transcript_lineage
358
+ // ---------------------------------------------------------------------------
359
+
360
+ /**
361
+ * Look for a session whose `transcript_files[*].last_uuid` equals
362
+ * `transcriptMeta.firstParentUuid`. That equality means our incoming
363
+ * transcript starts off the tail of an existing session's transcript — i.e.
364
+ * fork or resume.
365
+ *
366
+ * Returns `{ stableId, matchedPath, matchedLastUuid }` on hit, `null`
367
+ * otherwise. `transcriptMeta == null` (or missing firstParentUuid) returns
368
+ * `null` cleanly.
369
+ *
370
+ * Exported for direct testing.
371
+ */
372
+ export function findByTranscriptLineage(projection, transcriptMeta) {
373
+ if (!transcriptMeta || typeof transcriptMeta !== 'object') return null;
374
+ const parent = typeof transcriptMeta.firstParentUuid === 'string'
375
+ ? transcriptMeta.firstParentUuid
376
+ : null;
377
+ if (!parent || parent.length === 0) return null;
378
+ if (!projection || !projection.sessions || typeof projection.sessions !== 'object') {
379
+ return null;
380
+ }
381
+ for (const [stableId, session] of Object.entries(projection.sessions)) {
382
+ if (!session || !Array.isArray(session.transcript_files)) continue;
383
+ for (const tf of session.transcript_files) {
384
+ if (!tf || typeof tf !== 'object') continue;
385
+ // The reducer stores the field as `last_uuid` (snake_case payload).
386
+ // Defensive read for both naming styles.
387
+ const lastUuid = typeof tf.last_uuid === 'string' && tf.last_uuid.length > 0
388
+ ? tf.last_uuid
389
+ : (typeof tf.lastUuid === 'string' && tf.lastUuid.length > 0 ? tf.lastUuid : null);
390
+ if (lastUuid && lastUuid === parent) {
391
+ return {
392
+ stableId,
393
+ matchedPath: typeof tf.path === 'string' ? tf.path : null,
394
+ matchedLastUuid: lastUuid,
395
+ };
396
+ }
397
+ }
398
+ }
399
+ return null;
400
+ }
401
+
402
+ // ---------------------------------------------------------------------------
403
+ // P3: fingerprint + corroborator
404
+ // ---------------------------------------------------------------------------
405
+
406
+ /**
407
+ * For every session whose fingerprints (any v1) match the incoming ones,
408
+ * compute corroborator hits and return one entry per match — the caller
409
+ * picks acceptance vs parent-candidate based on `minCorroborators`.
410
+ *
411
+ * Exported for direct testing.
412
+ *
413
+ * @param {Projection} projection
414
+ * @param {FingerprintInput|null} fingerprints
415
+ * @param {{
416
+ * cwd: string|null,
417
+ * worktreeRealpath: string|null,
418
+ * branch: string|null,
419
+ * now: number,
420
+ * timeWindowHours: number,
421
+ * }} corrCtx
422
+ * @returns {Array<{
423
+ * stableId: string,
424
+ * fingerprintsMatched: string[],
425
+ * corroborators: { same_cwd: boolean, same_worktree_realpath: boolean,
426
+ * same_branch_at_start: boolean, within_time_window: boolean },
427
+ * corroboratorCount: number,
428
+ * strengthCounts: { strong: number, weak: number, total: number },
429
+ * sessionLastProgressAt: string|null,
430
+ * }>}
431
+ */
432
+ export function scanFingerprintCandidates(projection, fingerprints, corrCtx) {
433
+ /** @type {ReturnType<typeof scanFingerprintCandidates>} */
434
+ const out = [];
435
+ if (!projection || !projection.sessions || typeof projection.sessions !== 'object') {
436
+ return out;
437
+ }
438
+ if (!fingerprints || typeof fingerprints !== 'object') return out;
439
+
440
+ const fpHuman = typeof fingerprints.first_human_prompt_v1 === 'string' && fingerprints.first_human_prompt_v1.length > 0
441
+ ? fingerprints.first_human_prompt_v1
442
+ : null;
443
+ const fpLineage = typeof fingerprints.lineage_prefix_v1 === 'string' && fingerprints.lineage_prefix_v1.length > 0
444
+ ? fingerprints.lineage_prefix_v1
445
+ : null;
446
+
447
+ if (fpHuman === null && fpLineage === null) return out;
448
+
449
+ const windowMs = (typeof corrCtx.timeWindowHours === 'number' && corrCtx.timeWindowHours >= 0
450
+ ? corrCtx.timeWindowHours
451
+ : DEFAULT_TIME_WINDOW_HOURS) * 3600 * 1000;
452
+
453
+ for (const [stableId, session] of Object.entries(projection.sessions)) {
454
+ if (!session || !session.fingerprints || typeof session.fingerprints !== 'object') continue;
455
+
456
+ /** @type {string[]} */
457
+ const matched = [];
458
+ if (
459
+ fpHuman !== null &&
460
+ typeof session.fingerprints.first_human_prompt_v1 === 'string' &&
461
+ session.fingerprints.first_human_prompt_v1 === fpHuman
462
+ ) {
463
+ matched.push('first_human_prompt_v1');
464
+ }
465
+ if (
466
+ fpLineage !== null &&
467
+ typeof session.fingerprints.lineage_prefix_v1 === 'string' &&
468
+ session.fingerprints.lineage_prefix_v1 === fpLineage
469
+ ) {
470
+ matched.push('lineage_prefix_v1');
471
+ }
472
+ if (matched.length === 0) continue;
473
+
474
+ // Compute corroborators. Each corroborator reads a single comparable
475
+ // field; missing fields on either side count as "not corroborated".
476
+ const corroborators = {
477
+ same_cwd: corrCtx.cwd !== null
478
+ && typeof session.cwd === 'string'
479
+ && session.cwd.length > 0
480
+ && session.cwd === corrCtx.cwd,
481
+ same_worktree_realpath: corrCtx.worktreeRealpath !== null
482
+ && typeof session.worktree_realpath === 'string'
483
+ && session.worktree_realpath.length > 0
484
+ && session.worktree_realpath === corrCtx.worktreeRealpath,
485
+ same_branch_at_start: corrCtx.branch !== null
486
+ && typeof session.branch_at_start === 'string'
487
+ && session.branch_at_start.length > 0
488
+ && session.branch_at_start === corrCtx.branch,
489
+ within_time_window: false,
490
+ };
491
+ if (typeof session.last_progress_at === 'string' && session.last_progress_at.length > 0) {
492
+ const lastMs = Date.parse(session.last_progress_at);
493
+ if (Number.isFinite(lastMs)) {
494
+ const diffMs = corrCtx.now - lastMs;
495
+ // Within window when delta is non-negative (last_progress not in
496
+ // future) and <= windowMs. Negative delta (clock skew / pre-dated
497
+ // events) is treated as outside the window — defensive.
498
+ corroborators.within_time_window = diffMs >= 0 && diffMs <= windowMs;
499
+ }
500
+ }
501
+
502
+ const corroboratorCount = Object.values(corroborators).filter(Boolean).length;
503
+ const strengthCounts = classifyCorroborators(corroborators);
504
+ out.push({
505
+ stableId,
506
+ fingerprintsMatched: matched,
507
+ corroborators,
508
+ corroboratorCount,
509
+ strengthCounts,
510
+ sessionLastProgressAt: typeof session.last_progress_at === 'string'
511
+ ? session.last_progress_at
512
+ : null,
513
+ });
514
+ }
515
+ return out;
516
+ }
517
+
518
+ /**
519
+ * Convert raw fingerprint scan rows into stable parent candidate records,
520
+ * deduped by `stable_id`. The reason payload preserves which fingerprints
521
+ * matched + count summaries (strong + weak + total) so the caller can audit
522
+ * the surface later (e.g. CLI listing parent candidates with their evidence).
523
+ *
524
+ * Bytes-on-disk note: we deliberately do NOT include the per-corroborator
525
+ * boolean map (`{same_cwd, same_worktree_realpath, ...}`) here. That map
526
+ * costs ~120 bytes per candidate and pushes the cumulative payload past
527
+ * MAX_EVENT_BYTES (4 KiB POSIX PIPE_BUF) once a session accumulates ~10
528
+ * candidates. The summary counts are sufficient for "is this candidate
529
+ * strong evidence?" decisions; the exact corroborator vector is recoverable
530
+ * by re-scanning against the seed session when needed (CLI drill-down).
531
+ *
532
+ * @param {ReturnType<typeof scanFingerprintCandidates>} rows
533
+ * @returns {Array<{
534
+ * stable_id: string, source: 'fingerprint', confidence: 'low',
535
+ * reason: { fingerprints_matched: string[],
536
+ * corroborator_count: number,
537
+ * strong_corroborator_count: number,
538
+ * weak_corroborator_count: number },
539
+ * }>}
540
+ */
541
+ export function collectParentCandidates(rows) {
542
+ if (!Array.isArray(rows) || rows.length === 0) return [];
543
+ /** @type {Map<string, ReturnType<typeof collectParentCandidates>[number]>} */
544
+ const seen = new Map();
545
+ for (const r of rows) {
546
+ if (!r || typeof r.stableId !== 'string') continue;
547
+ if (seen.has(r.stableId)) continue;
548
+ // strengthCounts may be absent if the row was hand-built (tests). Fall
549
+ // back to recomputing from the corroborator hit map so the reason is
550
+ // always self-describing.
551
+ const strength = r.strengthCounts ?? classifyCorroborators(r.corroborators);
552
+ seen.set(r.stableId, {
553
+ stable_id: r.stableId,
554
+ source: 'fingerprint',
555
+ confidence: 'low',
556
+ reason: {
557
+ fingerprints_matched: [...r.fingerprintsMatched],
558
+ corroborator_count: r.corroboratorCount,
559
+ strong_corroborator_count: strength.strong,
560
+ weak_corroborator_count: strength.weak,
561
+ },
562
+ });
563
+ }
564
+ return Array.from(seen.values());
565
+ }
566
+
567
+ /**
568
+ * Cap fingerprint scan rows to MAX_PARENT_CANDIDATES, sorted by
569
+ * (strong corroborator count desc, last_progress recency desc, stable_id
570
+ * asc-tie-break). Returns the surface-able candidate list plus the count of
571
+ * candidates omitted due to the cap so the caller can inject
572
+ * `parent_candidates_omitted_count` into the event payload.
573
+ *
574
+ * The cap exists because the SSoT events.jsonl uses MAX_EVENT_BYTES=4096
575
+ * (POSIX PIPE_BUF guarantee for atomic O_APPEND); an unbounded
576
+ * parent_candidates list can blow that budget and force appendEvent to
577
+ * reject the entire session_seen, losing the audit trail.
578
+ *
579
+ * @param {ReturnType<typeof scanFingerprintCandidates>} rows
580
+ * @param {{ cap?: number }} [opts]
581
+ * @returns {{ list: ReturnType<typeof collectParentCandidates>, omitted: number }}
582
+ */
583
+ export function capParentCandidates(rows, opts = {}) {
584
+ if (!Array.isArray(rows) || rows.length === 0) {
585
+ return { list: [], omitted: 0 };
586
+ }
587
+ const cap = typeof opts.cap === 'number' && opts.cap > 0
588
+ ? opts.cap
589
+ : MAX_PARENT_CANDIDATES;
590
+
591
+ // Dedup BEFORE sorting + capping — the same stable_id can appear once per
592
+ // matching fingerprint, but we only count it once toward the cap.
593
+ /** @type {Map<string, ReturnType<typeof scanFingerprintCandidates>[number]>} */
594
+ const dedup = new Map();
595
+ for (const r of rows) {
596
+ if (!r || typeof r.stableId !== 'string') continue;
597
+ if (!dedup.has(r.stableId)) dedup.set(r.stableId, r);
598
+ }
599
+
600
+ // Sort by strong corroborator count desc, then by recency desc.
601
+ // Recency is parsed lexically when both ISO strings are present; rows
602
+ // without a parseable last_progress sort to the end.
603
+ const sorted = Array.from(dedup.values()).sort((a, b) => {
604
+ const aStrong = (a.strengthCounts ?? classifyCorroborators(a.corroborators)).strong;
605
+ const bStrong = (b.strengthCounts ?? classifyCorroborators(b.corroborators)).strong;
606
+ if (bStrong !== aStrong) return bStrong - aStrong;
607
+ const aTs = a.sessionLastProgressAt ?? '';
608
+ const bTs = b.sessionLastProgressAt ?? '';
609
+ if (aTs !== bTs) return bTs.localeCompare(aTs);
610
+ return a.stableId.localeCompare(b.stableId);
611
+ });
612
+
613
+ const kept = sorted.slice(0, cap);
614
+ const omitted = Math.max(0, sorted.length - kept.length);
615
+ return { list: collectParentCandidates(kept), omitted };
616
+ }