@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.
- package/CHANGELOG.md +249 -0
- package/LICENSE +201 -0
- package/NOTICE +10 -0
- package/README.md +250 -0
- package/cli/_write-helpers.mjs +99 -0
- package/cli/alias.mjs +115 -0
- package/cli/argparse.mjs +296 -0
- package/cli/close.mjs +116 -0
- package/cli/find.mjs +185 -0
- package/cli/format.mjs +277 -0
- package/cli/link-parent.mjs +133 -0
- package/cli/link.mjs +132 -0
- package/cli/rebuild.mjs +98 -0
- package/cli/sessions-db-session-start-main.mjs +454 -0
- package/cli/sessions-db-session-start.mjs +56 -0
- package/cli/sessions-db.mjs +119 -0
- package/cli/sweep.mjs +171 -0
- package/cli/tree.mjs +127 -0
- package/lib/git-context.mjs +479 -0
- package/lib/identity.mjs +616 -0
- package/lib/index.mjs +145 -0
- package/lib/init.mjs +185 -0
- package/lib/lock.mjs +86 -0
- package/lib/operations.mjs +490 -0
- package/lib/paths.mjs +199 -0
- package/lib/projection.mjs +496 -0
- package/lib/sanitize.mjs +131 -0
- package/lib/storage.mjs +759 -0
- package/lib/sweep.mjs +209 -0
- package/lib/transcript.mjs +230 -0
- package/lib/types.mjs +276 -0
- package/lib/uuid.mjs +116 -0
- package/lib/watch.mjs +217 -0
- package/package.json +53 -0
- package/types/git-context.d.mts +98 -0
- package/types/identity.d.mts +658 -0
- package/types/index.d.mts +10 -0
- package/types/index.d.ts +127 -0
- package/types/init.d.mts +53 -0
- package/types/lock.d.mts +18 -0
- package/types/operations.d.mts +204 -0
- package/types/paths.d.mts +54 -0
- package/types/projection.d.mts +79 -0
- package/types/sanitize.d.mts +39 -0
- package/types/storage.d.mts +276 -0
- package/types/sweep.d.mts +58 -0
- package/types/transcript.d.mts +59 -0
- package/types/types.d.mts +255 -0
- package/types/uuid.d.mts +17 -0
- 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();
|