@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,454 @@
1
+ /**
2
+ * sessions-db SessionStart hook — real main.
3
+ *
4
+ * The companion bootstrap (`sessions-db-session-start.mjs`) installs three
5
+ * safety nets (uncaught handlers, kill switch, hard timeout) BEFORE
6
+ * dynamically importing this module. That ordering guarantees that any
7
+ * import-time failure here is caught by the bootstrap's uncaughtException
8
+ * handler and exits 0 silently — Claude Code never sees a non-zero exit
9
+ * from a hook that is purely observational.
10
+ *
11
+ * Six-item safety contract (every test below cross-references one item):
12
+ * 1. cwd-gate: bail on any cwd whose nearest CLAUDE.md does not declare a
13
+ * "Druumen Workspace". No event written.
14
+ * 2. < 2 second budget: bootstrap's setTimeout(2000ms).unref() always wins.
15
+ * Each sub-probe respects a single global deadline derived from
16
+ * `gitContext({ totalBudgetMs })` — six probes can never sum past the
17
+ * budget.
18
+ * 3. silent stderr: nothing is ever written to stderr by us. Any
19
+ * console.error from a transitive dep would be a test failure.
20
+ * 4. exit 0 always: every error path — gate fail, bad input, transcript
21
+ * missing, lock contention, projection corrupted — exits 0 so Claude
22
+ * Code never sees a non-zero from a hook that is purely observational.
23
+ * 5. kill-switch: `DRUUMEN_SESSIONS_DB_DISABLED=1` exits immediately,
24
+ * before any IO at all (handled in the bootstrap shim).
25
+ * 6. shared lib reuse: git/worktree probing goes through
26
+ * `hooks/_lib/git-context.mjs` so hive-watcher (and future hooks) can
27
+ * migrate to the same probe.
28
+ *
29
+ * Identity reconciliation in P2: the lookup → mint → build → append → apply
30
+ * → save sequence is now an atomic transaction inside `recordSessionSeen`,
31
+ * which holds the projection lock across the entire critical section. Two
32
+ * concurrent hooks for the same `claude_session_id` will serialize on the
33
+ * lock and observe each other's mint, so identity does not split.
34
+ *
35
+ * cwd discipline: every storage call passes `{ root: storageRoot }` so the
36
+ * events.jsonl + projection cache + lock file all anchor on the project
37
+ * cwd (resolved via CLAUDE.md walk + git common-dir), NOT on the random
38
+ * `process.cwd()` Claude Code happened to spawn the hook from.
39
+ */
40
+
41
+ import { createHash } from 'node:crypto';
42
+ import { existsSync, readFileSync } from 'node:fs';
43
+ import { dirname, join } from 'node:path';
44
+
45
+ import {
46
+ listTranscriptFiles,
47
+ parseTranscriptFile,
48
+ workspaceHashFromCwd,
49
+ } from '../lib/transcript.mjs';
50
+ import { sanitizeFirstPrompt } from '../lib/sanitize.mjs';
51
+ import {
52
+ recordSessionSeen,
53
+ } from '../lib/storage.mjs';
54
+ import { gitContext } from '../lib/git-context.mjs';
55
+
56
+ // ---------------------------------------------------------------------------
57
+ // Top-level safety wrapper. Any unhandled rejection in main() exits 0 silently
58
+ // — never throws, never console.errors. The bootstrap shim has its own
59
+ // uncaughtException handler as a final backstop.
60
+ // ---------------------------------------------------------------------------
61
+ main().catch(() => process.exit(0));
62
+
63
+ async function main() {
64
+ // (1) Read hook payload from stdin (best-effort, hard 100ms cap). Claude
65
+ // Code emits a JSON line on stdin for hook scripts; we tolerate empty stdin
66
+ // and fall through to env / cwd defaults if needed.
67
+ const input = await readStdinJson({ timeoutMs: 100 });
68
+
69
+ // (2) Resolve cwd — explicit input wins, then CLAUDE_PROJECT_DIR, then our
70
+ // own process.cwd(). All three are normal Claude Code invocation surfaces.
71
+ // CRITICAL: this `cwd` becomes the anchor for ALL downstream IO (CLAUDE.md
72
+ // gate walk, git probe, transcript locate, storage). Once we commit to it
73
+ // here, `process.cwd()` is never read again — protects against the case
74
+ // where Claude Code spawns the hook from a different cwd than the project.
75
+ const cwd = pickString(input?.cwd) ||
76
+ process.env.CLAUDE_PROJECT_DIR ||
77
+ process.cwd();
78
+
79
+ // (3) cwd-gate. We walk up from cwd looking for a CLAUDE.md that contains
80
+ // the "Druumen Workspace" sentinel. Any other repo (admin, blog, a random
81
+ // scratch dir) bails silently.
82
+ if (!isDruumenWorkspace(cwd)) {
83
+ process.exit(0);
84
+ }
85
+
86
+ // (4) git context — bounded probes, soft-fail. We tolerate `partial` but
87
+ // bail on `not_a_repo` (no point recording a session against a non-git dir).
88
+ // totalBudgetMs is the SHARED budget across all probes — async runGit
89
+ // races against a single global deadline so 6 probes cannot exceed it.
90
+ let gitCtx;
91
+ try {
92
+ gitCtx = await gitContext({ cwd, totalBudgetMs: 1500 });
93
+ } catch {
94
+ process.exit(0);
95
+ }
96
+ if (gitCtx.status === 'not_a_repo') {
97
+ process.exit(0);
98
+ }
99
+
100
+ // (5) Resolve the storage root. Prefer the worktree root (so different
101
+ // worktrees of the same repo each accumulate their own events.jsonl) and
102
+ // fall back to the gated cwd. NEVER fall back to process.cwd() — see (2).
103
+ const storageRoot = gitCtx.worktreePath || cwd;
104
+
105
+ // (6) claude_session_id — required input. Without it we cannot reconcile
106
+ // identity at all, so we bail rather than minting a stable_id we can never
107
+ // re-correlate against the transcript file.
108
+ const claudeSessionId = pickString(input?.session_id) ||
109
+ process.env.CLAUDE_SESSION_ID ||
110
+ null;
111
+ if (!claudeSessionId || !looksLikeUuid(claudeSessionId)) {
112
+ process.exit(0);
113
+ }
114
+
115
+ // (7) Locate transcript jsonl. Prefer the path the hook payload supplied;
116
+ // otherwise compute the canonical `~/.claude/projects/<hash>/<id>.jsonl`
117
+ // and fall back to "newest jsonl in workspace dir" if that exact file is
118
+ // missing (Claude Code occasionally writes the file with a slight rename).
119
+ const transcriptPath = locateTranscript({
120
+ explicit: pickString(input?.transcript_path),
121
+ cwd,
122
+ claudeSessionId,
123
+ });
124
+
125
+ // (8) Parse transcript — best-effort. Missing / corrupted / oversized
126
+ // transcripts leave transcriptMeta null; downstream falls through cleanly.
127
+ let transcriptMeta = null;
128
+ if (transcriptPath && existsSync(transcriptPath)) {
129
+ try {
130
+ transcriptMeta = await parseTranscriptFile(transcriptPath);
131
+ } catch {
132
+ transcriptMeta = null;
133
+ }
134
+ }
135
+
136
+ // (9) Compute fingerprints + first-prompt preview before the transaction
137
+ // so the payloadBuilder closure is pure (no surprise IO inside the lock).
138
+ const fingerprints = computeFingerprints(transcriptMeta);
139
+ const firstPromptPreview = transcriptMeta?.firstHumanPromptRaw
140
+ ? sanitizeFirstPrompt(transcriptMeta.firstHumanPromptRaw)
141
+ : null;
142
+
143
+ // (9b) Privacy opt-out gate. The env var DRUUMEN_SESSIONS_DB_STORE_PREVIEW
144
+ // mirrors the cockpit Setup Wizard's "Store first prompt preview" checkbox.
145
+ // Only literal '0' or 'false' (case-insensitive) disables preview storage;
146
+ // anything else (including unset) keeps the default behavior. Same
147
+ // semantics as the kill switch (`DRUUMEN_SESSIONS_DB_DISABLED=1`) — a
148
+ // single ENV-driven knob ops can flip without touching settings.json or
149
+ // the hook source.
150
+ //
151
+ // We translate the env to a boolean here and forward it as
152
+ // `opts.storeFirstPrompt` so the storage layer enforces the policy
153
+ // atomically inside the lock. The boolean shape matches the public
154
+ // library API exactly so cockpit can pass the same flag programmatically
155
+ // when it calls recordSessionSeen directly (no env-var scaffolding).
156
+ const storeFirstPrompt = isPreviewDisabled(
157
+ process.env.DRUUMEN_SESSIONS_DB_STORE_PREVIEW,
158
+ ) ? false : true;
159
+
160
+ // (10) Hand off to the atomic recordSessionSeen transaction. It owns the
161
+ // projection lock for the full resolve → build → append → apply → save
162
+ // cycle, so concurrent hooks for the same claude_session_id cannot split
163
+ // identity (each one observes the other's mint and reuses it).
164
+ //
165
+ // P3: storage now runs the full 3-priority identity chain (P1 csid index
166
+ // → P2 transcript lineage → P3 fingerprint+corroborator → mint). We pass
167
+ // ALL the signals we have so the resolver can walk the chain and surface
168
+ // both the matched stable_id and any parent candidates (hub-spoke hints).
169
+ //
170
+ // Every storage path is anchored on `storageRoot` — events.jsonl,
171
+ // projection cache, and lock file all land in <storageRoot>/tickets/_logs/.
172
+ // This is the cwd-plumb-through fix: process.cwd() is NEVER read by
173
+ // storage when called this way.
174
+ try {
175
+ await recordSessionSeen({
176
+ claudeSessionId,
177
+ root: storageRoot,
178
+ lockTimeoutMs: 1500,
179
+ transcriptMeta,
180
+ gitContext: gitCtx,
181
+ cwd,
182
+ fingerprints,
183
+ storeFirstPrompt,
184
+ payloadBuilder: (_stableId, _identityResolution) => buildSessionSeenPayload({
185
+ claudeSessionId,
186
+ gitCtx,
187
+ cwd,
188
+ transcriptPath,
189
+ transcriptMeta,
190
+ fingerprints,
191
+ firstPromptPreview,
192
+ }),
193
+ });
194
+ } catch {
195
+ // already exit-0 path — drop. Either the lock failed, the projection
196
+ // is corrupt beyond repair, or events.jsonl rejected the line. The
197
+ // SSoT is the durable record; rebuild reconciles everything later.
198
+ }
199
+
200
+ process.exit(0);
201
+ }
202
+
203
+ // ---------------------------------------------------------------------------
204
+ // Helpers (unit-tested via the hook script's own integration tests rather than
205
+ // individually so we keep them private to this entry point).
206
+ // ---------------------------------------------------------------------------
207
+
208
+ /**
209
+ * Read a single JSON object from stdin within `timeoutMs`. Returns null on
210
+ * timeout, empty stdin, or invalid JSON. Never throws.
211
+ */
212
+ function readStdinJson({ timeoutMs }) {
213
+ return new Promise((resolve) => {
214
+ // Detached / non-piped stdin (e.g. terminal): isTTY is true. Don't even
215
+ // bother waiting.
216
+ if (process.stdin.isTTY) {
217
+ resolve(null);
218
+ return;
219
+ }
220
+
221
+ let settled = false;
222
+ const chunks = [];
223
+ const finish = (value) => {
224
+ if (settled) return;
225
+ settled = true;
226
+ try {
227
+ process.stdin.removeAllListeners('data');
228
+ process.stdin.removeAllListeners('end');
229
+ process.stdin.removeAllListeners('error');
230
+ } catch {
231
+ // ignore
232
+ }
233
+ resolve(value);
234
+ };
235
+
236
+ const timer = setTimeout(() => finish(null), timeoutMs);
237
+ timer.unref();
238
+
239
+ process.stdin.on('data', (c) => chunks.push(c));
240
+ process.stdin.on('end', () => {
241
+ clearTimeout(timer);
242
+ if (chunks.length === 0) {
243
+ finish(null);
244
+ return;
245
+ }
246
+ try {
247
+ const text = Buffer.concat(chunks).toString('utf8').trim();
248
+ if (text.length === 0) {
249
+ finish(null);
250
+ return;
251
+ }
252
+ finish(JSON.parse(text));
253
+ } catch {
254
+ finish(null);
255
+ }
256
+ });
257
+ process.stdin.on('error', () => {
258
+ clearTimeout(timer);
259
+ finish(null);
260
+ });
261
+ });
262
+ }
263
+
264
+ /**
265
+ * Walk up from `cwd` looking for a CLAUDE.md whose body contains the
266
+ * "Druumen Workspace" sentinel. Bounded to 12 ancestors so a runaway loop
267
+ * (e.g. weird filesystem mount) cannot stall us.
268
+ *
269
+ * Returns true when sentinel found, false otherwise (incl. read errors).
270
+ */
271
+ function isDruumenWorkspace(cwd) {
272
+ if (typeof cwd !== 'string' || cwd.length === 0) return false;
273
+ let dir = cwd;
274
+ for (let i = 0; i < 12; i++) {
275
+ const candidate = join(dir, 'CLAUDE.md');
276
+ if (existsSync(candidate)) {
277
+ try {
278
+ // We only need the first ~8KB to find the sentinel; CLAUDE.md is
279
+ // typically short, so reading the whole file is fine.
280
+ const body = readFileSync(candidate, 'utf8');
281
+ if (body.includes('Druumen Workspace')) return true;
282
+ } catch {
283
+ // unreadable — keep walking up just in case there's a higher one.
284
+ }
285
+ }
286
+ const parent = dirname(dir);
287
+ if (parent === dir) return false;
288
+ dir = parent;
289
+ }
290
+ return false;
291
+ }
292
+
293
+ /**
294
+ * Resolve the claude transcript jsonl path. Layered:
295
+ * 1. explicit hook payload — trust it as long as the file actually exists.
296
+ * 2. canonical path: ~/.claude/projects/<hash>/<claudeSessionId>.jsonl.
297
+ * 3. fallback: newest jsonl in the workspace's claude projects dir (Claude
298
+ * Code occasionally lands transcripts under a slightly different filename
299
+ * during fork/resume — newest-by-mtime is the right tie-breaker).
300
+ *
301
+ * Returns null when nothing usable is found.
302
+ */
303
+ function locateTranscript({ explicit, cwd, claudeSessionId }) {
304
+ if (explicit && existsSync(explicit)) return explicit;
305
+
306
+ // Canonical path computation requires an absolute cwd; we only walk this
307
+ // path when it is.
308
+ if (typeof cwd !== 'string' || !cwd.startsWith('/')) return null;
309
+ let hash;
310
+ try {
311
+ hash = workspaceHashFromCwd(cwd);
312
+ } catch {
313
+ return null;
314
+ }
315
+
316
+ const canonical = join(
317
+ process.env.HOME || '',
318
+ '.claude',
319
+ 'projects',
320
+ hash,
321
+ `${claudeSessionId}.jsonl`,
322
+ );
323
+ if (existsSync(canonical)) return canonical;
324
+
325
+ // Fallback: newest jsonl in the workspace dir. listTranscriptFiles already
326
+ // sorts by mtime descending so the head of the list is the most-recent.
327
+ let files;
328
+ try {
329
+ files = listTranscriptFiles(hash);
330
+ } catch {
331
+ return null;
332
+ }
333
+ return files.length > 0 ? files[0] : null;
334
+ }
335
+
336
+ /**
337
+ * Build the canonical session_seen payload from the gathered context. Pure
338
+ * function — no IO, no time, no randomness. Called inside `recordSessionSeen`
339
+ * with the already-resolved stable_id so the payload can include any
340
+ * stable_id-aware fields (none in P2, but the closure shape matches the
341
+ * recordSessionSeen contract for forward compatibility).
342
+ */
343
+ function buildSessionSeenPayload({
344
+ claudeSessionId,
345
+ gitCtx,
346
+ cwd,
347
+ transcriptPath,
348
+ transcriptMeta,
349
+ fingerprints,
350
+ firstPromptPreview,
351
+ }) {
352
+ return {
353
+ claude_session_id: claudeSessionId,
354
+ branch_at_start: gitCtx.branch,
355
+ branch_current: gitCtx.branch,
356
+ head_at_start: gitCtx.head,
357
+ head_last_seen: gitCtx.head,
358
+ worktree_path_observed: gitCtx.worktreePath || cwd,
359
+ worktree_realpath: gitCtx.worktreeRealpath,
360
+ worktree_registry_name: gitCtx.registryName,
361
+ git_common_dir: gitCtx.gitCommonDir,
362
+ transcript_file: transcriptMeta && transcriptPath ? {
363
+ path: transcriptPath,
364
+ first_uuid: transcriptMeta.firstUuid,
365
+ last_uuid: transcriptMeta.lastUuid,
366
+ size: transcriptMeta.size,
367
+ // statSync()'s mtime is a Date — serialize to ISO so events.jsonl
368
+ // round-trips cleanly through JSON.parse.
369
+ mtime: transcriptMeta.mtime instanceof Date
370
+ ? transcriptMeta.mtime.toISOString()
371
+ : transcriptMeta.mtime,
372
+ status: transcriptMeta.status,
373
+ } : null,
374
+ fingerprints,
375
+ first_prompt_preview: firstPromptPreview,
376
+ cwd,
377
+ };
378
+ }
379
+
380
+ /**
381
+ * Compute v1 fingerprints from transcript meta. Both algorithms hash to a
382
+ * 16-char hex prefix of SHA-256 — short enough to dedupe in the projection
383
+ * map without bloating event payloads.
384
+ *
385
+ * - first_human_prompt_v1: hash(sanitized first prompt). Stable across
386
+ * fork/resume because the user's first prompt doesn't change.
387
+ * - lineage_prefix_v1: hash(firstUuid + ":" + firstParentUuid). Stable
388
+ * across the same logical session even when Claude renames the jsonl.
389
+ *
390
+ * Returns `{ first_human_prompt_v1: null, lineage_prefix_v1: null }` when
391
+ * the transcript is unavailable or insufficient.
392
+ */
393
+ function computeFingerprints(transcriptMeta) {
394
+ const out = { first_human_prompt_v1: null, lineage_prefix_v1: null };
395
+ if (!transcriptMeta) return out;
396
+
397
+ if (typeof transcriptMeta.firstHumanPromptRaw === 'string' &&
398
+ transcriptMeta.firstHumanPromptRaw.length > 0) {
399
+ const sanitized = sanitizeFirstPrompt(transcriptMeta.firstHumanPromptRaw);
400
+ if (sanitized.length > 0) {
401
+ out.first_human_prompt_v1 = sha256Prefix(sanitized);
402
+ }
403
+ }
404
+
405
+ if (typeof transcriptMeta.firstUuid === 'string' && transcriptMeta.firstUuid.length > 0) {
406
+ // Resume sessions have a non-null firstParentUuid; fresh sessions have
407
+ // null. The combined hash makes both shapes uniquely identifiable.
408
+ const parent = typeof transcriptMeta.firstParentUuid === 'string'
409
+ ? transcriptMeta.firstParentUuid
410
+ : '';
411
+ out.lineage_prefix_v1 = sha256Prefix(`${transcriptMeta.firstUuid}:${parent}`);
412
+ }
413
+ return out;
414
+ }
415
+
416
+ function sha256Prefix(text) {
417
+ return createHash('sha256').update(text).digest('hex').slice(0, 16);
418
+ }
419
+
420
+ function pickString(v) {
421
+ return typeof v === 'string' && v.length > 0 ? v : null;
422
+ }
423
+
424
+ /**
425
+ * Cheap UUID-shape validator. We don't want to lock ourselves to v4-only or
426
+ * v7-only since Claude Code's session_id format may evolve, but we DO want to
427
+ * reject obvious junk (empty / control chars / whitespace) that would corrupt
428
+ * the events.jsonl line.
429
+ */
430
+ function looksLikeUuid(s) {
431
+ return typeof s === 'string' &&
432
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(s);
433
+ }
434
+
435
+ /**
436
+ * Privacy opt-out predicate for `DRUUMEN_SESSIONS_DB_STORE_PREVIEW`.
437
+ *
438
+ * Returns true ONLY for the literal opt-out values `'0'` and `'false'`
439
+ * (case-insensitive, after trim). Everything else — unset, empty string,
440
+ * `'1'`, `'true'`, `'yes'`, garbage — keeps the default-on behavior.
441
+ *
442
+ * Why this asymmetric shape? The default is preview-stored (backward compat
443
+ * with 0.1.0-dev) and we want a typo in the env var to fail SAFE: an
444
+ * operator who intends to opt out but mistypes (e.g. sets `=False` and
445
+ * trusts case-insensitivity) gets opt-out, but a typo like `=fals` or
446
+ * `=disabled` keeps the default. Treating only the two canonical strings
447
+ * as off-signals makes the gate predictable; cockpit's Setup Wizard always
448
+ * writes one of the two canonical values when the user unticks the box.
449
+ */
450
+ function isPreviewDisabled(envValue) {
451
+ if (typeof envValue !== 'string') return false;
452
+ const v = envValue.trim().toLowerCase();
453
+ return v === '0' || v === 'false';
454
+ }
@@ -0,0 +1,56 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * sessions-db SessionStart hook — bootstrap shim.
4
+ *
5
+ * This file is INTENTIONALLY tiny. Its only job is to install three safety
6
+ * nets BEFORE any project code is imported, then forward to the real main
7
+ * module via dynamic `import()`. The split exists because ESM static imports
8
+ * run before any top-level statements — meaning a failed import in the real
9
+ * main module would leak a stack trace to stderr and exit the process with
10
+ * code 1, completely bypassing the kill switch and exit-0 contract.
11
+ *
12
+ * Safety nets (in install order):
13
+ * 1. process.on('uncaughtException' | 'unhandledRejection') → exit 0.
14
+ * Catches anything the dynamic import + main flow throws after this
15
+ * point, including project-side bugs we can't predict.
16
+ * 2. DRUUMEN_SESSIONS_DB_DISABLED=1 kill switch. Exits before we even try
17
+ * to import the main module — useful for CI / docker / dev-offload
18
+ * sweeps that need to disable the hook without touching settings.json.
19
+ * 3. setTimeout(2000, exit 0).unref(). The hard timeout. Now that no probe
20
+ * runs synchronously (git-context uses async spawn + global deadline,
21
+ * not spawnSync), this timer ACTUALLY fires when the event loop is
22
+ * otherwise busy. .unref() so a fast happy-path exits at natural
23
+ * completion without the timer keeping us alive.
24
+ *
25
+ * Only AFTER all three are armed do we `import()` the real main. Any import
26
+ * error (corrupt main module, missing file, ESM resolution failure) is
27
+ * swallowed by the uncaughtException handler — Claude Code never sees a
28
+ * non-zero exit from us regardless.
29
+ *
30
+ * Wired by `.claude/settings.json` (in P5 — NOT in this phase) to fire on
31
+ * every Claude Code SessionStart event.
32
+ */
33
+
34
+ // (1) Silence error path. Install BEFORE any import so even a syntax error
35
+ // in the main module exits 0 silently. Both 'uncaughtException' and
36
+ // 'unhandledRejection' get the same treatment — promise rejections from
37
+ // inside the imported main are funneled here when not handled by main itself.
38
+ process.on('uncaughtException', () => process.exit(0));
39
+ process.on('unhandledRejection', () => process.exit(0));
40
+
41
+ // (2) Kill switch. Env vars are available without imports — cheapest possible
42
+ // short-circuit. Lets ops teams disable the hook without modifying any code.
43
+ if (process.env.DRUUMEN_SESSIONS_DB_DISABLED === '1') {
44
+ process.exit(0);
45
+ }
46
+
47
+ // (3) Hard timeout. Node built-in setTimeout, no import needed. .unref() so
48
+ // the timer never keeps the event loop alive past the hook's natural
49
+ // completion. With async git probes (no more spawnSync) this WILL fire when
50
+ // some probe is truly stuck — see hook safety contract item 2.
51
+ setTimeout(() => process.exit(0), 2000).unref();
52
+
53
+ // (4) NOW it is safe to import the real main. Any import-time failure
54
+ // (corrupted file, ESM resolution error, missing dependency) bubbles to the
55
+ // uncaughtException handler installed at step 1 → exits 0 silently.
56
+ import('./sessions-db-session-start-main.mjs').catch(() => process.exit(0));
@@ -0,0 +1,119 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * sessions-db CLI — entry dispatcher.
4
+ *
5
+ * Subcommands:
6
+ * find filter sessions
7
+ * tree render hub-spoke subtree
8
+ * alias set / clear human alias
9
+ * link link to task / project (or --remove)
10
+ * link-parent set / clear parent_session_id
11
+ * close set outcome + closed_at + reason
12
+ * rebuild rebuild projection from events.jsonl
13
+ * sweep apply activity_state transitions (active → idle → archived)
14
+ *
15
+ * Global flags supported by every handler:
16
+ * --json machine-readable JSON output
17
+ * --root <p> override storage root (default cwd)
18
+ * --dry-run write commands only — print event, don't write
19
+ * --quiet silent stdout (exit code only)
20
+ * --no-color disable ANSI color (read-only commands)
21
+ * -h | --help subcommand help
22
+ *
23
+ * Exit codes:
24
+ * 0 success
25
+ * 1 business error (invalid stable_id, lock timeout, rebuild failure, ...)
26
+ * 2 argparse error (unknown flag, missing required, invalid enum value)
27
+ * 3 unknown command
28
+ *
29
+ * Top-level wrappers:
30
+ * - uncaughtException / unhandledRejection both exit 1 (verbose stderr only
31
+ * when DRUUMEN_SESSIONS_DB_VERBOSE is set, to keep noise out of CI logs).
32
+ * - Subcommand handlers call process.exit() themselves on business errors;
33
+ * the dispatcher only sets the exit code on uncaught throws.
34
+ */
35
+
36
+ const COMMANDS = {
37
+ find: () => import('./find.mjs'),
38
+ tree: () => import('./tree.mjs'),
39
+ alias: () => import('./alias.mjs'),
40
+ link: () => import('./link.mjs'),
41
+ 'link-parent': () => import('./link-parent.mjs'),
42
+ close: () => import('./close.mjs'),
43
+ rebuild: () => import('./rebuild.mjs'),
44
+ sweep: () => import('./sweep.mjs'),
45
+ };
46
+
47
+ function printRootHelp() {
48
+ const lines = [
49
+ 'Usage: sessions-db <command> [args]',
50
+ '',
51
+ 'Commands:',
52
+ ' find Filter sessions by task / project / alias / branch / cwd / state / outcome',
53
+ ' tree Render hub-spoke parent → children subtree',
54
+ ' alias Set / change / clear a session alias',
55
+ ' link Link a session to a task or project (or --remove via session_unlink)',
56
+ ' link-parent Set / clear parent_session_id',
57
+ ' close Set outcome + closed_at + reason (or reopen)',
58
+ ' rebuild Rebuild projection cache from events.jsonl',
59
+ ' sweep Apply activity_state transitions (active → idle → archived)',
60
+ '',
61
+ 'Run `sessions-db <command> --help` for subcommand-specific flags.',
62
+ '',
63
+ 'Global flags supported by all commands:',
64
+ ' --json JSON output',
65
+ ' --root <p> override storage root (default cwd)',
66
+ ' --dry-run write-only — print planned event, do not persist',
67
+ ' --quiet silent stdout',
68
+ ' --no-color disable ANSI color',
69
+ ' -h, --help subcommand help',
70
+ '',
71
+ 'Exit codes: 0 success / 1 business error / 2 argparse error / 3 unknown command',
72
+ '',
73
+ ];
74
+ process.stdout.write(lines.join('\n'));
75
+ }
76
+
77
+ function printVerboseError(e) {
78
+ if (process.env.DRUUMEN_SESSIONS_DB_VERBOSE) {
79
+ process.stderr.write((e && e.stack) ? e.stack + '\n' : String(e) + '\n');
80
+ } else {
81
+ const msg = e && e.message ? e.message : String(e);
82
+ process.stderr.write(`error: ${msg}\n`);
83
+ }
84
+ }
85
+
86
+ process.on('uncaughtException', (e) => {
87
+ printVerboseError(e);
88
+ process.exit(1);
89
+ });
90
+ process.on('unhandledRejection', (e) => {
91
+ printVerboseError(e);
92
+ process.exit(1);
93
+ });
94
+
95
+ async function main() {
96
+ const argv = process.argv.slice(2);
97
+ const [cmd, ...rest] = argv;
98
+
99
+ if (!cmd || cmd === '-h' || cmd === '--help') {
100
+ printRootHelp();
101
+ process.exit(0);
102
+ }
103
+
104
+ const loader = COMMANDS[cmd];
105
+ if (!loader) {
106
+ process.stderr.write(`error: unknown command: ${cmd}\n\n`);
107
+ printRootHelp();
108
+ process.exit(3);
109
+ }
110
+
111
+ const mod = await loader();
112
+ if (typeof mod.run !== 'function') {
113
+ process.stderr.write(`error: subcommand ${cmd} did not export run()\n`);
114
+ process.exit(1);
115
+ }
116
+ await mod.run(rest);
117
+ }
118
+
119
+ main();