@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,479 @@
1
+ /**
2
+ * Shared git/worktree probe for Claude Code hook scripts.
3
+ *
4
+ * Why a separate library: every SessionStart-class hook (sessions-db,
5
+ * hive-watcher, future ones) needs the same triplet: (worktree path, branch,
6
+ * HEAD) and the same survival posture — sub-second timeouts, soft-fail on
7
+ * every probe, never throw to the caller, never blow past the hook's overall
8
+ * 2-second budget. Centralising the probe here keeps each hook's main file
9
+ * tiny and lets us evolve the survival rules in one place.
10
+ *
11
+ * Design rules (enforced by the test suite):
12
+ * - `runGit` uses non-blocking `child_process.spawn` + Promise.race against
13
+ * a single global deadline. setTimeout(...).unref() in the hook bootstrap
14
+ * can ACTUALLY fire because we never block the event loop with spawnSync.
15
+ * - Every git invocation respects the SAME absolute deadline (computed once
16
+ * in `gitContext` from `totalBudgetMs`), so 6 sequential probes can never
17
+ * add up to > totalBudgetMs even if each individual probe runs slowly.
18
+ * When the deadline lapses mid-sequence we skip remaining probes and
19
+ * surface `status: 'partial'`.
20
+ * - We surface a `status` of `ok` | `partial` | `not_a_repo` | `error` plus a
21
+ * plain `errors[]` array of one-line diagnostics. Callers can branch on
22
+ * `status` without touching `errors`.
23
+ * - The dev-offload registry probe (`~/.claude-dev/druumen-dev/
24
+ * worktree-registry.json`) is best-effort only — file missing / unreadable
25
+ * leaves `registryName === null` without raising the overall status.
26
+ * - Default `totalBudgetMs` is 1500 ms total; the hook script's outer 2000 ms
27
+ * hard timeout is the ultimate guard (and now actually fires because we're
28
+ * not blocking the event loop).
29
+ *
30
+ * Zero new npm deps: only `node:child_process`, `node:fs`, `node:os`,
31
+ * `node:path`.
32
+ */
33
+
34
+ import { spawn } from 'node:child_process';
35
+ import { existsSync, readFileSync, realpathSync } from 'node:fs';
36
+ import { homedir } from 'node:os';
37
+ import { join, resolve } from 'node:path';
38
+
39
+ const DEFAULT_TOTAL_BUDGET_MS = 1500;
40
+ // Floor for per-probe budget — if remaining < this, we treat the deadline as
41
+ // already lapsed (avoids spawning a process that has effectively no time).
42
+ const MIN_PROBE_BUDGET_MS = 25;
43
+ const REGISTRY_PATH_DEFAULT = join(homedir(), '.claude-dev', 'druumen-dev', 'worktree-registry.json');
44
+
45
+ /**
46
+ * @typedef {Object} GitContext
47
+ * @property {string} cwd The cwd we ran probes against (always set).
48
+ * @property {string|null} worktreePath Output of `git rev-parse --show-toplevel` (worktree root).
49
+ * @property {string|null} worktreeRealpath realpath() of worktreePath, with symlinks resolved.
50
+ * @property {string|null} gitCommonDir Output of `git rev-parse --git-common-dir` (resolved to absolute).
51
+ * @property {boolean} isInWorktree True when worktree's `.git` is a file (linked worktree),
52
+ * i.e. gitCommonDir's parent != worktreePath.
53
+ * @property {boolean} isInsideRepo True when cwd is inside any git repo (linked worktree counts).
54
+ * @property {string|null} branch `git branch --show-current` (empty string => detached HEAD => null).
55
+ * @property {string|null} head `git rev-parse HEAD` (full SHA).
56
+ * @property {string|null} registryName Key in the dev-offload registry whose `worktree_path` matches us.
57
+ * @property {'ok'|'partial'|'not_a_repo'|'error'} status
58
+ * @property {string[]} errors One-liner error summaries, suitable for jsonl logging.
59
+ */
60
+
61
+ /**
62
+ * Probe git context for `cwd`. Never throws; returns a `GitContext` whose
63
+ * `status` field tells the caller what to trust.
64
+ *
65
+ * Budget model: `totalBudgetMs` is the wall-clock budget for ALL probes
66
+ * combined. Each individual probe gets `min(remaining, MIN_PROBE_BUDGET_MS)`
67
+ * — once the budget is exhausted, we stop probing and return whatever we
68
+ * have so far with `status: 'partial'`.
69
+ *
70
+ * @param {{ cwd?: string, totalBudgetMs?: number, registryPath?: string }} [opts]
71
+ * @returns {Promise<GitContext>}
72
+ */
73
+ export async function gitContext(opts = {}) {
74
+ const cwd = typeof opts.cwd === 'string' && opts.cwd.length > 0 ? opts.cwd : process.cwd();
75
+ const totalBudgetMs = Number.isFinite(opts.totalBudgetMs) && opts.totalBudgetMs > 0
76
+ ? opts.totalBudgetMs
77
+ : Number.isFinite(opts.timeoutMs) && opts.timeoutMs > 0
78
+ // Backward compat: old callers passed `timeoutMs` for per-call budget.
79
+ // Treat it as the total budget so behavior is at-most-as-slow as before.
80
+ ? opts.timeoutMs
81
+ : DEFAULT_TOTAL_BUDGET_MS;
82
+ const registryPath = typeof opts.registryPath === 'string' && opts.registryPath.length > 0
83
+ ? opts.registryPath
84
+ : REGISTRY_PATH_DEFAULT;
85
+
86
+ const deadlineAt = Date.now() + totalBudgetMs;
87
+
88
+ /** @type {GitContext} */
89
+ const ctx = {
90
+ cwd,
91
+ worktreePath: null,
92
+ worktreeRealpath: null,
93
+ gitCommonDir: null,
94
+ isInWorktree: false,
95
+ isInsideRepo: false,
96
+ branch: null,
97
+ head: null,
98
+ registryName: null,
99
+ status: 'ok',
100
+ errors: [],
101
+ };
102
+
103
+ // Probe 1: are we inside a git repo at all?
104
+ // `git rev-parse --is-inside-work-tree` returns "true" when in a working
105
+ // tree. Outside a repo git exits 128 with "fatal: not a git repository" on
106
+ // stderr; that is the canonical "not_a_repo" signal and is NOT a runtime
107
+ // error — we suppress the diagnostic that runGit recorded for it.
108
+ const insideProbe = await runGit(['rev-parse', '--is-inside-work-tree'], { cwd, deadlineAt }, ctx);
109
+ if ((insideProbe.stdout || '').trim() === 'true') {
110
+ ctx.isInsideRepo = true;
111
+ } else if (insideProbe.spawnFailed || insideProbe.timedOut) {
112
+ // git binary missing / spawn error / timed out — distinct from "outside
113
+ // a repo". Keep the recorded error as-is.
114
+ ctx.status = 'error';
115
+ return ctx;
116
+ } else {
117
+ // Ran fine but exit != 0: "not_a_repo". The diagnostic runGit added is
118
+ // noise for this expected case — pop it so callers see a clean errors[].
119
+ if (ctx.errors.length > 0) ctx.errors.pop();
120
+ ctx.status = 'not_a_repo';
121
+ return ctx;
122
+ }
123
+
124
+ // From here on, every probe is conditional on remaining budget. If the
125
+ // deadline lapses we stop probing and finalize with status='partial'.
126
+ const finalize = () => {
127
+ if (ctx.errors.length > 0 && ctx.status === 'ok') {
128
+ ctx.status = 'partial';
129
+ }
130
+ return ctx;
131
+ };
132
+
133
+ if (deadlineLapsed(deadlineAt)) {
134
+ ctx.errors.push('git probes: total budget exhausted after probe 1');
135
+ return finalize();
136
+ }
137
+
138
+ // Probe 2: worktree root.
139
+ const topProbe = await runGit(['rev-parse', '--show-toplevel'], { cwd, deadlineAt }, ctx);
140
+ if (topProbe.ok) {
141
+ const top = (topProbe.stdout || '').trim();
142
+ if (top.length > 0) {
143
+ ctx.worktreePath = top;
144
+ try {
145
+ ctx.worktreeRealpath = realpathSync(top);
146
+ } catch (err) {
147
+ // realpath failure is non-fatal — keep worktreePath, log the issue.
148
+ ctx.errors.push(`realpath(${truncate(top, 80)}): ${shortMessage(err)}`);
149
+ }
150
+ }
151
+ }
152
+
153
+ if (deadlineLapsed(deadlineAt)) return finalize();
154
+
155
+ // Probe 3: git common dir (the canonical .git directory; for linked
156
+ // worktrees this differs from worktreePath/.git). We resolve relative paths
157
+ // against worktreePath || cwd so callers always see an absolute path.
158
+ const commonProbe = await runGit(['rev-parse', '--git-common-dir'], { cwd, deadlineAt }, ctx);
159
+ if (commonProbe.ok) {
160
+ const raw = (commonProbe.stdout || '').trim();
161
+ if (raw.length > 0) {
162
+ const absBase = ctx.worktreePath || cwd;
163
+ const abs = raw.startsWith('/') ? raw : resolve(absBase, raw);
164
+ try {
165
+ ctx.gitCommonDir = realpathSync(abs);
166
+ } catch {
167
+ // Couldn't realpath — keep the resolved-but-unverified absolute path
168
+ // so callers still get a usable hint without crashing the probe.
169
+ ctx.gitCommonDir = abs;
170
+ }
171
+ }
172
+ }
173
+
174
+ if (deadlineLapsed(deadlineAt)) return finalize();
175
+
176
+ // Linked worktree detection: when `git rev-parse --git-dir` returns a path
177
+ // whose realpath differs from `<worktreePath>/.git`, we know we're in a
178
+ // linked worktree. We use git-dir (per-worktree) for this — git-common-dir
179
+ // would always point to the main repo's .git regardless.
180
+ if (ctx.worktreePath) {
181
+ const gitDirProbe = await runGit(['rev-parse', '--git-dir'], { cwd, deadlineAt }, ctx);
182
+ if (gitDirProbe.ok) {
183
+ const raw = (gitDirProbe.stdout || '').trim();
184
+ if (raw.length > 0) {
185
+ const absBase = ctx.worktreePath;
186
+ const abs = raw.startsWith('/') ? raw : resolve(absBase, raw);
187
+ let resolvedGitDir = abs;
188
+ try {
189
+ resolvedGitDir = realpathSync(abs);
190
+ } catch {
191
+ // keep unresolved abs
192
+ }
193
+ // Linked worktree's git-dir lives at <commonDir>/worktrees/<name>,
194
+ // i.e. it is NOT == <worktreePath>/.git. Compare on resolved paths.
195
+ let mainGitDir = join(ctx.worktreePath, '.git');
196
+ try {
197
+ mainGitDir = realpathSync(mainGitDir);
198
+ } catch {
199
+ // .git might be a file in a linked worktree — keep the joined path
200
+ // for the comparison; the inequality is still meaningful.
201
+ }
202
+ ctx.isInWorktree = resolvedGitDir !== mainGitDir;
203
+ }
204
+ }
205
+ }
206
+
207
+ if (deadlineLapsed(deadlineAt)) return finalize();
208
+
209
+ // Probe 4: branch (empty stdout => detached HEAD, leave null).
210
+ const branchProbe = await runGit(['branch', '--show-current'], { cwd, deadlineAt }, ctx);
211
+ if (branchProbe.ok) {
212
+ const b = (branchProbe.stdout || '').trim();
213
+ ctx.branch = b.length > 0 ? b : null;
214
+ }
215
+
216
+ if (deadlineLapsed(deadlineAt)) return finalize();
217
+
218
+ // Probe 5: HEAD SHA.
219
+ const headProbe = await runGit(['rev-parse', 'HEAD'], { cwd, deadlineAt }, ctx);
220
+ if (headProbe.ok) {
221
+ const h = (headProbe.stdout || '').trim();
222
+ if (/^[0-9a-f]{40}$/i.test(h)) {
223
+ ctx.head = h.toLowerCase();
224
+ }
225
+ }
226
+
227
+ // Probe 6 (optional): dev-offload registry reverse lookup. Only runs when
228
+ // the file exists; missing-or-unreadable leaves registryName === null
229
+ // without escalating ctx.status. This is a sync fs probe — no budget cost.
230
+ ctx.registryName = lookupRegistryName(ctx.worktreeRealpath || ctx.worktreePath, registryPath);
231
+
232
+ return finalize();
233
+ }
234
+
235
+ // ---------------------------------------------------------------------------
236
+ // Internal helpers
237
+ // ---------------------------------------------------------------------------
238
+
239
+ function deadlineLapsed(deadlineAt) {
240
+ return Date.now() + MIN_PROBE_BUDGET_MS > deadlineAt;
241
+ }
242
+
243
+ /**
244
+ * Run a single `git <args>` command with a per-call budget derived from the
245
+ * shared deadline. Uses non-blocking spawn + Promise.race so the hook's outer
246
+ * setTimeout can actually fire (was: spawnSync blocked the event loop).
247
+ *
248
+ * @returns {Promise<{ ok: boolean, stdout: string, stderr: string,
249
+ * code: number|null, signal: NodeJS.Signals|null,
250
+ * spawnFailed: boolean, timedOut: boolean }>}
251
+ */
252
+ export async function runGit(args, { cwd, deadlineAt, encoding = 'utf8' }, ctx) {
253
+ const remaining = deadlineAt - Date.now();
254
+ if (remaining <= MIN_PROBE_BUDGET_MS) {
255
+ const msg = `git ${args.join(' ')}: deadline lapsed before spawn`;
256
+ if (ctx) ctx.errors.push(msg);
257
+ return {
258
+ ok: false,
259
+ stdout: '',
260
+ stderr: '',
261
+ code: null,
262
+ signal: null,
263
+ spawnFailed: false,
264
+ timedOut: true,
265
+ };
266
+ }
267
+
268
+ let child;
269
+ try {
270
+ child = spawn('git', args, {
271
+ cwd,
272
+ // Inherit env so credential helpers / GIT_DIR overrides behave as the
273
+ // user expects. We pass GIT_OPTIONAL_LOCKS=0 to prevent git from
274
+ // touching index.lock during read-only probes (cheap protection
275
+ // against blocking on a contended worktree).
276
+ env: { ...process.env, GIT_OPTIONAL_LOCKS: '0' },
277
+ // Detach stdin so git never tries to read from our hook's stdin (which
278
+ // is reserved for the JSON event payload).
279
+ stdio: ['ignore', 'pipe', 'pipe'],
280
+ });
281
+ } catch (err) {
282
+ const msg = `git ${args.join(' ')}: ${shortMessage(err)}`;
283
+ if (ctx) ctx.errors.push(msg);
284
+ return {
285
+ ok: false,
286
+ stdout: '',
287
+ stderr: '',
288
+ code: null,
289
+ signal: null,
290
+ spawnFailed: true,
291
+ timedOut: false,
292
+ };
293
+ }
294
+
295
+ const stdoutChunks = [];
296
+ const stderrChunks = [];
297
+ child.stdout.on('data', (c) => stdoutChunks.push(c));
298
+ child.stderr.on('data', (c) => stderrChunks.push(c));
299
+
300
+ const result = await new Promise((resolvePromise) => {
301
+ let settled = false;
302
+ const settle = (value) => {
303
+ if (settled) return;
304
+ settled = true;
305
+ resolvePromise(value);
306
+ };
307
+
308
+ // Per-call timer races against the global deadline. We use `remaining`
309
+ // (already computed above) as the per-call budget — every probe is
310
+ // bounded by the shared deadline, never accumulating beyond it.
311
+ const timer = setTimeout(() => {
312
+ // Try graceful kill first, then SIGKILL to guarantee descent doesn't
313
+ // outlive our budget. .unref() so the timer itself never keeps the
314
+ // event loop alive past natural completion.
315
+ try { child.kill('SIGTERM'); } catch { /* already gone */ }
316
+ // Hard-kill follow-up after a 50 ms grace window.
317
+ setTimeout(() => {
318
+ try { child.kill('SIGKILL'); } catch { /* ignore */ }
319
+ }, 50).unref();
320
+ settle({ kind: 'timeout' });
321
+ }, remaining);
322
+ timer.unref();
323
+
324
+ child.on('error', (err) => {
325
+ clearTimeout(timer);
326
+ settle({ kind: 'error', err });
327
+ });
328
+ // Use 'close' rather than 'exit': 'exit' fires when the child has exited
329
+ // but stdio pipes may still have buffered data not yet delivered to our
330
+ // 'data' listeners. On a contended Linux CI runner that race lets us read
331
+ // an empty stdout from a successful `git rev-parse --is-inside-work-tree`,
332
+ // mis-classifying a real repo as 'not_a_repo'. 'close' fires only after
333
+ // both the child exited AND stdio streams drained.
334
+ child.on('close', (code, signal) => {
335
+ clearTimeout(timer);
336
+ settle({ kind: 'exit', code, signal });
337
+ });
338
+ });
339
+
340
+ const stdout = Buffer.concat(stdoutChunks).toString(encoding);
341
+ const stderr = Buffer.concat(stderrChunks).toString(encoding);
342
+
343
+ if (result.kind === 'timeout') {
344
+ const msg = `git ${args.join(' ')}: timed out after ${remaining}ms`;
345
+ if (ctx) ctx.errors.push(msg);
346
+ return {
347
+ ok: false,
348
+ stdout,
349
+ stderr,
350
+ code: null,
351
+ signal: 'SIGTERM',
352
+ spawnFailed: false,
353
+ timedOut: true,
354
+ };
355
+ }
356
+
357
+ if (result.kind === 'error') {
358
+ const msg = `git ${args.join(' ')}: ${shortMessage(result.err)}`;
359
+ if (ctx) ctx.errors.push(msg);
360
+ return {
361
+ ok: false,
362
+ stdout,
363
+ stderr,
364
+ code: null,
365
+ signal: null,
366
+ spawnFailed: true,
367
+ timedOut: false,
368
+ };
369
+ }
370
+
371
+ // result.kind === 'exit'
372
+ if (result.signal) {
373
+ const msg = `git ${args.join(' ')}: killed by ${result.signal}`;
374
+ if (ctx) ctx.errors.push(msg);
375
+ return {
376
+ ok: false,
377
+ stdout,
378
+ stderr,
379
+ code: result.code,
380
+ signal: result.signal,
381
+ spawnFailed: false,
382
+ timedOut: true,
383
+ };
384
+ }
385
+
386
+ if (result.code !== 0) {
387
+ // Non-zero exit. Some commands (rev-parse --is-inside-work-tree outside a
388
+ // repo) intentionally exit non-zero; the caller decides whether to log it
389
+ // by inspecting `ok`. We still record a one-line diagnostic for observers
390
+ // — callers that consider the non-zero exit "expected" can pop the last
391
+ // entry from ctx.errors.
392
+ const tail = (stderr || '').trim().split('\n').pop() || `exit ${result.code}`;
393
+ if (ctx) ctx.errors.push(`git ${args.join(' ')}: ${truncate(tail, 120)}`);
394
+ return {
395
+ ok: false,
396
+ stdout,
397
+ stderr,
398
+ code: result.code,
399
+ signal: null,
400
+ spawnFailed: false,
401
+ timedOut: false,
402
+ };
403
+ }
404
+
405
+ return {
406
+ ok: true,
407
+ stdout,
408
+ stderr,
409
+ code: 0,
410
+ signal: null,
411
+ spawnFailed: false,
412
+ timedOut: false,
413
+ };
414
+ }
415
+
416
+ /**
417
+ * Reverse-look-up the registry to find the worktree key whose `worktree_path`
418
+ * (resolved via realpath when possible) matches our worktreeRealpath. Matches
419
+ * are realpath-aware: the registry file may store the symlinked path while we
420
+ * receive the canonical one (or vice versa), so we resolve both before
421
+ * comparing.
422
+ *
423
+ * Returns `null` for any miss (file not found, not JSON, no match) — never
424
+ * throws.
425
+ */
426
+ function lookupRegistryName(worktreeRealOrPath, registryPath) {
427
+ if (!worktreeRealOrPath) return null;
428
+ if (!existsSync(registryPath)) return null;
429
+
430
+ let parsed;
431
+ try {
432
+ const raw = readFileSync(registryPath, 'utf8');
433
+ parsed = JSON.parse(raw);
434
+ } catch {
435
+ return null;
436
+ }
437
+ if (!parsed || typeof parsed !== 'object' || !parsed.worktrees ||
438
+ typeof parsed.worktrees !== 'object') {
439
+ return null;
440
+ }
441
+
442
+ // Pre-resolve our side once so we don't realpath() inside the loop.
443
+ let mySide = worktreeRealOrPath;
444
+ try {
445
+ mySide = realpathSync(worktreeRealOrPath);
446
+ } catch {
447
+ // Keep mySide as-is — best-effort.
448
+ }
449
+
450
+ for (const [name, entry] of Object.entries(parsed.worktrees)) {
451
+ if (!entry || typeof entry !== 'object') continue;
452
+ const candidate = typeof entry.worktree_path === 'string' ? entry.worktree_path : null;
453
+ if (!candidate) continue;
454
+ if (candidate === mySide || candidate === worktreeRealOrPath) {
455
+ return name;
456
+ }
457
+ let candidateReal = candidate;
458
+ try {
459
+ candidateReal = realpathSync(candidate);
460
+ } catch {
461
+ // Skip — non-existent registry entry.
462
+ continue;
463
+ }
464
+ if (candidateReal === mySide) return name;
465
+ }
466
+ return null;
467
+ }
468
+
469
+ function shortMessage(err) {
470
+ if (!err) return 'unknown error';
471
+ if (err.code) return `${err.code}${err.message ? `: ${truncate(err.message, 100)}` : ''}`;
472
+ return truncate(String(err.message || err), 120);
473
+ }
474
+
475
+ function truncate(s, max) {
476
+ if (typeof s !== 'string') return '';
477
+ if (s.length <= max) return s;
478
+ return s.slice(0, max - 1) + '…';
479
+ }