claude-bulkhead 0.0.0-stage → 0.2.2

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 (48) hide show
  1. package/CHANGELOG.md +90 -0
  2. package/LICENSE +21 -0
  3. package/README.es.md +498 -0
  4. package/README.ja.md +498 -0
  5. package/README.ko.md +498 -0
  6. package/README.md +497 -2
  7. package/README.pt-BR.md +498 -0
  8. package/README.zh-CN.md +498 -0
  9. package/bin/bulkhead +3 -0
  10. package/bin/bulkhead-slot +3 -0
  11. package/bin/bulkhead-statusline +3 -0
  12. package/hooks/hooks.json +39 -0
  13. package/package.json +52 -4
  14. package/src/basics.js +236 -0
  15. package/src/checkout-guard.js +296 -0
  16. package/src/cli-util.js +79 -0
  17. package/src/cli.js +41 -0
  18. package/src/config-check.js +29 -0
  19. package/src/config-cli.js +51 -0
  20. package/src/config.js +236 -0
  21. package/src/dev-servers.js +223 -0
  22. package/src/disk-guard.js +200 -0
  23. package/src/doctor.js +255 -0
  24. package/src/events.js +108 -0
  25. package/src/hook.js +94 -0
  26. package/src/kill-guard.js +435 -0
  27. package/src/log-cli.js +146 -0
  28. package/src/memory-guard.js +145 -0
  29. package/src/memory.js +76 -0
  30. package/src/orphan-reaper.js +527 -0
  31. package/src/paths.js +31 -0
  32. package/src/port-cli.js +59 -0
  33. package/src/port-guard.js +142 -0
  34. package/src/processes.js +181 -0
  35. package/src/reap-cli.js +56 -0
  36. package/src/remote-sh.js +478 -0
  37. package/src/remote.js +1034 -0
  38. package/src/remotes.js +126 -0
  39. package/src/scan.js +604 -0
  40. package/src/sessions.js +147 -0
  41. package/src/shell.js +597 -0
  42. package/src/slot-cli.js +150 -0
  43. package/src/slots.js +168 -0
  44. package/src/status.js +111 -0
  45. package/src/statusline.js +69 -0
  46. package/src/top-worker.js +14 -0
  47. package/src/top.js +305 -0
  48. package/src/wait-guard.js +263 -0
package/src/scan.js ADDED
@@ -0,0 +1,604 @@
1
+ 'use strict'
2
+ // bulkhead scan: replays the shell commands in your Claude Code transcripts against the guards,
3
+ // to show what they would have stopped before you install them, or what they stopped since.
4
+ // Read-only: it opens transcripts for reading and writes nothing.
5
+
6
+ const fs = require('fs')
7
+ const os = require('os')
8
+ const path = require('path')
9
+ const { claudeDir } = require('./sessions')
10
+ const { setting } = require('./hook')
11
+ const { useColor, paint, shortPath, option, fit, table, bytes } = require('./cli-util')
12
+ const { parseSince } = require('./log-cli')
13
+
14
+ const USAGE = `usage: bulkhead scan [30d|DATE] [--since 30d|DATE] [--path DIR|FILE] [--project TEXT] [-n N] [--json]
15
+
16
+ Replays the shell commands in your Claude Code transcripts against bulkhead's guards and shows
17
+ what they would have stopped. Read-only. Nothing leaves this machine.
18
+ 30d, --since SPAN only commands newer than SPAN: 24h, 7d, 2w, or a date such as 2026-10-01
19
+ --path P transcripts to read (default: ~/.claude/projects)
20
+ --project TEXT only sessions whose folder contains TEXT
21
+ -n N examples to show per rule (default 3, 0 for none)
22
+ --json machine-readable output`
23
+
24
+ // Minutes on either side of a command in which another session counts as active.
25
+ const ACTIVE_WINDOW_MIN = 10
26
+
27
+ // ---------------------------------------------------------------------------------------------
28
+ // Reading transcripts
29
+
30
+ // Every .jsonl under dir (subagent transcripts included), or the file itself.
31
+ function findTranscripts(p, { since = null } = {}) {
32
+ const out = []
33
+ let st
34
+ try { st = fs.statSync(p) } catch { return out }
35
+ if (st.isFile()) return /\.jsonl$/.test(p) ? [{ file: p, size: st.size }] : []
36
+ const walk = (dir, depth) => {
37
+ let entries
38
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }) } catch { return }
39
+ for (const e of entries) {
40
+ const f = path.join(dir, e.name)
41
+ if (e.isDirectory() && depth < 4) walk(f, depth + 1)
42
+ else if (e.isFile() && e.name.endsWith('.jsonl')) {
43
+ try {
44
+ const s = fs.statSync(f)
45
+ if (since == null || s.mtimeMs >= since) out.push({ file: f, size: s.size })
46
+ } catch {}
47
+ }
48
+ }
49
+ }
50
+ walk(p, 0)
51
+ return out
52
+ }
53
+
54
+ // A subagent writes <session>/subagents/agent-*.jsonl; it belongs to the parent session.
55
+ function sessionKey(file) {
56
+ const parts = file.split(/[\\/]/)
57
+ const i = parts.lastIndexOf('subagents')
58
+ if (i > 0) return parts[i - 1]
59
+ return path.basename(file, '.jsonl')
60
+ }
61
+
62
+ const TS = Buffer.from('"timestamp":"')
63
+ const Q = 0x22
64
+ // Timestamp of a transcript line held as bytes; the last "timestamp" key is the line's own.
65
+ function timestampOf(buf) {
66
+ const i = buf.lastIndexOf(TS)
67
+ if (i < 0) return NaN
68
+ const start = i + TS.length
69
+ const end = buf.indexOf(Q, start)
70
+ if (end < 0) return NaN
71
+ return Date.parse(buf.toString('latin1', start, end))
72
+ }
73
+ const B_TOOL_USE = Buffer.from('"tool_use"')
74
+ const B_BASH = Buffer.from('"Bash"')
75
+ const B_PS = Buffer.from('"PowerShell"')
76
+ const B_TOOL_RESULT = Buffer.from('"tool_result"')
77
+ const B_IS_ERROR = Buffer.from('"is_error":true')
78
+ const B_CWD = Buffer.from('"cwd":"')
79
+ const CHUNK = 4 * 1024 * 1024
80
+
81
+ // Calls fn(lineBuffer) for each line of a file, reading big chunks and never decoding to text.
82
+ function eachLine(file, fn, onBytes) {
83
+ const fd = fs.openSync(file, 'r')
84
+ try {
85
+ const chunk = Buffer.allocUnsafe(CHUNK)
86
+ let carry = null
87
+ for (;;) {
88
+ const n = fs.readSync(fd, chunk, 0, CHUNK, null)
89
+ if (n <= 0) break
90
+ let pos = 0
91
+ let nl
92
+ while ((nl = chunk.indexOf(10, pos)) >= 0 && nl < n) {
93
+ let line = chunk.subarray(pos, nl)
94
+ if (carry) { line = Buffer.concat([carry, line]); carry = null }
95
+ fn(line)
96
+ pos = nl + 1
97
+ }
98
+ if (pos < n) carry = carry ? Buffer.concat([carry, chunk.subarray(pos, n)]) : Buffer.from(chunk.subarray(pos, n))
99
+ if (onBytes) onBytes(n)
100
+ }
101
+ if (carry && carry.length) fn(carry)
102
+ } finally { fs.closeSync(fd) }
103
+ }
104
+
105
+ // What became of a tool call that came back as an error: a PreToolUse hook stopped it, or the
106
+ // person declined it in the permission prompt.
107
+ function outcomeOf(text) {
108
+ const s = typeof text === 'string' ? text : Array.isArray(text) ? text.map(t => (t && t.text) || '').join('\n') : ''
109
+ if (/PreToolUse:\S* hook error|hook (?:blocked|denied)|\[bulkhead\]/i.test(s)) return 'hook'
110
+ if (/doesn't want to proceed|user (?:rejected|declined|denied)|permission (?:for this tool use )?(?:was )?denied/i.test(s)) return 'user'
111
+ return null
112
+ }
113
+
114
+ // Reads one transcript into `state`: commands, outcomes, and the minutes each session was active.
115
+ async function readTranscript(file, state, { since = null, onBytes = null } = {}) {
116
+ const session = sessionKey(file)
117
+ let lastCwd = null
118
+ eachLine(file, buf => {
119
+ state.lines++
120
+ const ts = timestampOf(buf)
121
+ if (Number.isFinite(ts) && (since == null || ts >= since)) {
122
+ const minutes = state.activity.get(session) || new Map()
123
+ const m = Math.floor(ts / 60000)
124
+ if (!minutes.has(m)) minutes.set(m, lastCwd)
125
+ state.activity.set(session, minutes)
126
+ }
127
+ const call = buf.includes(B_TOOL_USE) && (buf.includes(B_BASH) || buf.includes(B_PS))
128
+ const result = buf.includes(B_TOOL_RESULT) && buf.includes(B_IS_ERROR)
129
+ const hasCwd = buf.includes(B_CWD)
130
+ if (!call && !result && !hasCwd) return
131
+ if (!call && !result) {
132
+ // A line that only carries the folder: read it without parsing the whole line.
133
+ const at = buf.indexOf(B_CWD) + B_CWD.length
134
+ const end = buf.indexOf(Q, at)
135
+ // only when the key appears once: a nested "cwd" inside a tool result needs the parser
136
+ if (end > 0 && buf.indexOf(B_CWD, end) < 0) {
137
+ const raw = buf.toString('utf8', at, end)
138
+ if (!raw.includes('\\')) {
139
+ if (raw) {
140
+ lastCwd = raw
141
+ const minutes = state.activity.get(session)
142
+ if (minutes && Number.isFinite(ts)) minutes.set(Math.floor(ts / 60000), raw)
143
+ }
144
+ return
145
+ }
146
+ }
147
+ }
148
+ handleLine(buf.toString('utf8'), ts, call, result)
149
+ }, onBytes)
150
+
151
+ function handleLine(line, ts, call, result) {
152
+ let obj
153
+ try { obj = JSON.parse(line) } catch { return }
154
+ if (obj.cwd) {
155
+ lastCwd = obj.cwd
156
+ const minutes = state.activity.get(session)
157
+ if (minutes && Number.isFinite(ts)) minutes.set(Math.floor(ts / 60000), obj.cwd)
158
+ }
159
+ const items = obj.message && Array.isArray(obj.message.content) ? obj.message.content : []
160
+ if (call && obj.type === 'assistant') {
161
+ if (since != null && !(ts >= since)) return
162
+ for (const it of items) {
163
+ if (!it || it.type !== 'tool_use' || (it.name !== 'Bash' && it.name !== 'PowerShell')) continue
164
+ const input = it.input || {}
165
+ if (typeof input.command !== 'string' || !input.command) continue
166
+ state.commands.push({
167
+ id: it.id || null,
168
+ session,
169
+ file,
170
+ tool: it.name,
171
+ command: input.command,
172
+ background: input.run_in_background === true || input.run_in_background === 'true',
173
+ timeout: input.timeout,
174
+ cwd: obj.cwd || lastCwd,
175
+ ts,
176
+ })
177
+ }
178
+ }
179
+ if (result && obj.type === 'user') {
180
+ for (const it of items) {
181
+ if (!it || it.type !== 'tool_result' || !it.is_error || !it.tool_use_id) continue
182
+ const o = outcomeOf(it.content)
183
+ if (o) state.outcomes.set(it.tool_use_id, o)
184
+ }
185
+ }
186
+ }
187
+ }
188
+
189
+ // ---------------------------------------------------------------------------------------------
190
+ // Replaying
191
+
192
+ // The folder git would call the top of the tree, found by looking for .git on disk. A folder
193
+ // that no longer exists is its own tree.
194
+ const treeCache = new Map()
195
+ function treeRoot(cwd) {
196
+ if (!cwd) return null
197
+ // a transcript copied from another OS: C:\Users\... on macOS is not a path here
198
+ if (process.platform !== 'win32' && /^[a-z]:[\\/]/i.test(cwd)) return cwd.replace(/\\/g, '/')
199
+ if (treeCache.has(cwd)) return treeCache.get(cwd)
200
+ let dir = path.resolve(cwd)
201
+ let found = null
202
+ for (let i = 0; i < 40; i++) {
203
+ try {
204
+ if (fs.existsSync(path.join(dir, '.git'))) { found = dir; break }
205
+ } catch {}
206
+ const up = path.dirname(dir)
207
+ if (up === dir) break
208
+ dir = up
209
+ }
210
+ const root = found || path.resolve(cwd)
211
+ treeCache.set(cwd, root)
212
+ return root
213
+ }
214
+
215
+ // git as the guards would have seen it: the tree is known, the branch and status are not, so a
216
+ // question about them throws and the guard lets the command through. One exception: for
217
+ // git add -A and git commit -a (status --porcelain with no other option), checkout-guard asks
218
+ // only after another session was active in the same tree, and that session was most likely
219
+ // editing files there. A dirty pull is not assumed.
220
+ function replayGit({ assumeEdits = false } = {}) {
221
+ return (args, cwd) => {
222
+ if (args[0] === 'rev-parse' && args[1] === '--show-toplevel') return treeRoot(cwd)
223
+ if (assumeEdits && args[0] === 'status' && args.length === 2) return ' M (another session was active here)'
224
+ throw new Error('not replayed')
225
+ }
226
+ }
227
+
228
+ // Other sessions active within the window around `ts`, as the session files checkout-guard reads.
229
+ function activeOthers(state, session, ts) {
230
+ const files = new Map()
231
+ if (!Number.isFinite(ts)) return files
232
+ if (!state.byMinute) {
233
+ // minute -> [[session, cwd]], so a command looks at 21 minutes instead of every session
234
+ state.byMinute = new Map()
235
+ for (const [other, minutes] of state.activity) {
236
+ for (const [k, cwd] of minutes) {
237
+ if (!cwd) continue
238
+ const list = state.byMinute.get(k)
239
+ if (list) list.push([other, cwd])
240
+ else state.byMinute.set(k, [[other, cwd]])
241
+ }
242
+ }
243
+ }
244
+ const m = Math.floor(ts / 60000)
245
+ const seen = new Set([session])
246
+ let pid = 1
247
+ for (let k = m - ACTIVE_WINDOW_MIN; k <= m + ACTIVE_WINDOW_MIN; k++) {
248
+ const list = state.byMinute.get(k)
249
+ if (!list) continue
250
+ for (const [other, cwd] of list) {
251
+ if (seen.has(other)) continue
252
+ seen.add(other)
253
+ files.set(pid++, { cwd, sessionId: other, startedAt: null })
254
+ }
255
+ }
256
+ return files
257
+ }
258
+
259
+ // Every guard rule that would have stopped this command, as [{ guard, rule }].
260
+ function replay(cmd, state, { home = os.homedir(), platform = process.platform, settings = null } = {}) {
261
+ const cfg = settings || { allowNames: setting('killGuard.allowNames', []), extraSharedNames: setting('killGuard.extraSharedNames', []), waitLimit: setting('waitGuard.limitSeconds', 180) }
262
+ const hits = []
263
+ const mode = cmd.tool === 'PowerShell' ? 'ps' : 'sh'
264
+ const text = cmd.command
265
+ const cwd = cmd.cwd || home
266
+ const add = (guard, rule) => { if (!hits.some(h => h.guard === guard && h.rule === rule)) hits.push({ guard, rule }) }
267
+ const tryRun = fn => { try { fn() } catch {} }
268
+
269
+ tryRun(() => {
270
+ const hit = require('./kill-guard').classify(text, { allowNames: cfg.allowNames, extraSharedNames: cfg.extraSharedNames }, mode)
271
+ if (hit) add('kill-guard', hit.rule)
272
+ })
273
+ tryRun(() => {
274
+ if (cmd.background) return
275
+ const v = require('./wait-guard').judge(text, cmd.timeout, cfg.waitLimit, mode)
276
+ if (v) add('wait-guard', v.rule)
277
+ })
278
+ tryRun(() => {
279
+ if (require('./memory-guard').touchesSlotFiles(text)) add('memory-guard', 'slot-files')
280
+ })
281
+ tryRun(() => {
282
+ const hit = require('./disk-guard').check(text, { home, mode })
283
+ if (hit) add('disk-guard', hit.rule)
284
+ })
285
+ tryRun(() => {
286
+ const basics = require('./basics')
287
+ const why = basics.check(text, { cwd, home, platform, run: replayGit(), mode })
288
+ if (why) add('basics', basics.ruleOf(why))
289
+ })
290
+ tryRun(() => {
291
+ const others = activeOthers(state, cmd.session, cmd.ts)
292
+ if (!others.size) return
293
+ const hit = require('./checkout-guard').classify(text, {
294
+ cwd, sessionId: cmd.session, files: () => others, isAlive: () => true, run: replayGit({ assumeEdits: true }), platform, mode,
295
+ home, links: () => [], removeRule: () => true, stageRule: () => true,
296
+ })
297
+ if (hit && hit.rule !== 'worktree-force-links') add('checkout-guard', hit.rule)
298
+ })
299
+ return hits
300
+ }
301
+
302
+ // The most sessions running at once, and on how many days two or more overlapped. A session
303
+ // counts as running between two of its events when they are at most ACTIVE_WINDOW_MIN apart.
304
+ function concurrency(state) {
305
+ const perMinute = new Map()
306
+ for (const minutes of state.activity.values()) {
307
+ const sorted = [...minutes.keys()].sort((a, b) => a - b)
308
+ for (let i = 0; i < sorted.length; i++) {
309
+ const next = sorted[i + 1]
310
+ const end = next != null && next - sorted[i] <= ACTIVE_WINDOW_MIN ? next - 1 : sorted[i]
311
+ for (let m = sorted[i]; m <= end; m++) perMinute.set(m, (perMinute.get(m) || 0) + 1)
312
+ }
313
+ }
314
+ let max = 0
315
+ let at = null
316
+ const days = new Set()
317
+ for (const [m, n] of perMinute) {
318
+ if (n > max || (n === max && at != null && m > at)) { max = n; at = m }
319
+ if (n >= 2) days.add(new Date(m * 60000).toDateString())
320
+ }
321
+ return { max, at: at == null ? null : new Date(at * 60000).toISOString(), overlapDays: days.size }
322
+ }
323
+
324
+ // ---------------------------------------------------------------------------------------------
325
+ // Masking and summary
326
+
327
+ // Secret-looking values are cut out of the examples before anything is printed. Every pattern
328
+ // is linear in the length of the command: a transcript can hold a command of megabytes.
329
+ const SECRET_NAME = /PASSWORD|PASSWD|SECRET|TOKEN|API_?KEY|ACCESS_?KEY|PRIVATE_?KEY/i
330
+ const SECRETS = [
331
+ [/(^|\s)(-u|--user|--password|--token|--api-key|--secret)(\s+|=)(['"]?)[^\s'"]+\4/gi, '$1$2$3***'],
332
+ [/\b(Bearer|Basic|token)\s+[A-Za-z0-9._~+/=-]{8,}/gi, '$1 ***'],
333
+ // NAME=value where the name says it is a secret; the name is matched once, then tested
334
+ [/\b([A-Za-z_][A-Za-z0-9_]*)=(['"]?)([^\s'"]+)\2/g, (m, name, q) => (SECRET_NAME.test(name) ? `${name}=${q}***${q}` : m)],
335
+ [/\b(?:sk|pk|rk)-[A-Za-z0-9_-]{16,}\b/g, '***'],
336
+ [/\b(?:ghp|gho|ghs|ghu|github_pat)_[A-Za-z0-9_]{16,}\b/g, '***'],
337
+ [/\bAKIA[0-9A-Z]{16}\b/g, '***'],
338
+ [/\bxox[abpors]-[A-Za-z0-9-]{10,}\b/g, '***'],
339
+ [/(https?:\/\/)[^\s/:@]+:[^\s/@]+@/g, '$1***@'],
340
+ ]
341
+ function mask(s) {
342
+ let out = String(s)
343
+ for (const [re, to] of SECRETS) out = out.replace(re, to)
344
+ return out
345
+ }
346
+
347
+ function summarize(state, hitsByCommand) {
348
+ const rules = new Map()
349
+ const sessions = new Set()
350
+ const projects = new Map()
351
+ let commandsHit = 0
352
+ for (const { cmd, hits } of hitsByCommand) {
353
+ if (!hits.length) continue
354
+ commandsHit++
355
+ sessions.add(cmd.session)
356
+ const proj = treeRoot(cmd.cwd) || '?'
357
+ projects.set(proj, (projects.get(proj) || 0) + 1)
358
+ const outcome = (cmd.id && state.outcomes.get(cmd.id)) || null
359
+ for (const h of hits) {
360
+ const k = `${h.guard}\u0000${h.rule}`
361
+ const r = rules.get(k) || { guard: h.guard, rule: h.rule, count: 0, sessions: new Set(), hook: 0, user: 0, last: null, examples: [] }
362
+ r.count++
363
+ r.sessions.add(cmd.session)
364
+ if (outcome === 'hook') r.hook++
365
+ if (outcome === 'user') r.user++
366
+ if (Number.isFinite(cmd.ts) && (!r.last || cmd.ts > r.last)) r.last = cmd.ts
367
+ r.examples.push({ command: mask(cmd.command), cwd: cmd.cwd, ts: cmd.ts, outcome })
368
+ rules.set(k, r)
369
+ }
370
+ }
371
+ const list = [...rules.values()].map(r => ({
372
+ ...r,
373
+ sessions: r.sessions.size,
374
+ // newest first, one example per distinct command
375
+ examples: dedupe(r.examples.sort((a, b) => (b.ts || 0) - (a.ts || 0))),
376
+ })).sort((a, b) => b.count - a.count || a.guard.localeCompare(b.guard) || a.rule.localeCompare(b.rule))
377
+ return {
378
+ commandsHit,
379
+ sessionsHit: sessions.size,
380
+ rules: list,
381
+ projects: [...projects].map(([project, count]) => ({ project, count })).sort((a, b) => b.count - a.count),
382
+ }
383
+ }
384
+
385
+ function dedupe(examples) {
386
+ const seen = new Set()
387
+ return examples.filter(e => {
388
+ const k = e.command.replace(/\s+/g, ' ').trim()
389
+ if (seen.has(k)) return false
390
+ seen.add(k)
391
+ return true
392
+ })
393
+ }
394
+
395
+ // ---------------------------------------------------------------------------------------------
396
+ // Output
397
+
398
+ const pad2 = n => String(n).padStart(2, '0')
399
+ const MONTHS = 'Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec'.split(' ')
400
+ const day = ms => { const d = new Date(ms); return `${MONTHS[d.getMonth()]} ${pad2(d.getDate())}` }
401
+ const plural = (n, word) => `${n.toLocaleString('en-US')} ${word}${n === 1 ? '' : 's'}`
402
+
403
+ // What each guard protects, said once under its first rule.
404
+ const RULE_NOTES = {
405
+ 'kill-guard': 'kills by program name take down every session\'s servers',
406
+ 'wait-guard': 'foreground waits hold the session and expire the prompt cache',
407
+ 'memory-guard': 'hand-edited slot files leave a slot held by nobody',
408
+ 'disk-guard': 'whole-disk and parallel scans stall every session\'s I/O',
409
+ basics: 'mistakes that cannot be undone',
410
+ 'checkout-guard': 'another session was active in the same tree at that minute',
411
+ }
412
+
413
+ function renderScan(r, { color = false, home = os.homedir(), examples = 3, width = 120 } = {}) {
414
+ const out = []
415
+ const b = s => paint(color, 'bold', s)
416
+ out.push(`${b('bulkhead scan')} replaying your Claude Code sessions against the guards`)
417
+ out.push(` read ${plural(r.files, 'transcript')} (${bytes(r.bytes)}), ${plural(r.sessions, 'session')}, ${plural(r.commands, 'shell command')} in ${r.seconds.toFixed(1)}s`)
418
+ if (r.first) out.push(` span ${day(r.first)} .. ${day(r.last)}${r.sinceLabel ? ` (since ${r.sinceLabel})` : ''}`)
419
+ if (r.concurrency.max >= 2) {
420
+ out.push(` overlap up to ${b(r.concurrency.max)} sessions running at once (${day(Date.parse(r.concurrency.at))}), ` +
421
+ `${plural(r.concurrency.overlapDays, 'day')} with two or more at once`)
422
+ }
423
+ out.push('')
424
+ if (!r.summary.rules.length) {
425
+ out.push(r.commands ? 'Nothing in these transcripts would have been stopped.' : 'No shell commands found.')
426
+ return out.join('\n')
427
+ }
428
+ const rows = [['GUARD', 'RULE', 'HITS', 'SESSIONS', 'STOPPED', 'LAST']]
429
+ for (const x of r.summary.rules) {
430
+ const stopped = x.hook + x.user
431
+ rows.push([x.guard, x.rule, String(x.count).padStart(4), String(x.sessions).padStart(8), stopped ? `${stopped}`.padStart(7) : ' -', x.last ? day(x.last) : ''])
432
+ }
433
+ const lines = table(rows)
434
+ out.push(b(lines[0]), ...lines.slice(1))
435
+ if (examples > 0) {
436
+ let lastGuard = null
437
+ for (const x of r.summary.rules) {
438
+ out.push('')
439
+ const note = x.guard !== lastGuard && RULE_NOTES[x.guard] ? paint(color, 'dim', ` ${RULE_NOTES[x.guard]}`) : ''
440
+ lastGuard = x.guard
441
+ out.push(`${paint(color, x.guard === 'checkout-guard' || x.guard === 'kill-guard' ? 'red' : 'yellow', `${x.guard} ${x.rule}`)} ${plural(x.count, 'time')} in ${plural(x.sessions, 'session')}${note}`)
442
+ for (const e of x.examples.slice(0, examples)) {
443
+ const tag = e.outcome === 'hook' ? ' [stopped by a hook]' : e.outcome === 'user' ? ' [declined by you]' : ''
444
+ const meta = `${shortPath(e.cwd ? treeRoot(e.cwd) : '?', home)} ${e.ts ? day(e.ts) : ''}${tag}`
445
+ const room = Math.max(30, width - meta.length - 8)
446
+ out.push(` $ ${fit(e.command.replace(/\s*\n\s*/g, ' ⏎ '), room)} ${paint(color, 'gray', meta)}`)
447
+ }
448
+ }
449
+ }
450
+ out.push('')
451
+ const s = r.summary
452
+ const already = s.rules.reduce((n, x) => n + x.hook + x.user, 0)
453
+ out.push(b(`${plural(s.commandsHit, 'command')} in ${plural(s.sessionsHit, 'session')} would have been stopped by bulkhead.`) +
454
+ (already ? ` ${already.toLocaleString('en-US')} of the hits were already stopped by a hook or declined by you.` : ''))
455
+ out.push(paint(color, 'dim', 'A hit means the command had the shape a guard blocks, not that damage was done. port-guard and the low-RAM check depend on'))
456
+ out.push(paint(color, 'dim', 'what was running at that moment and are not replayed. checkout-guard is replayed from which sessions were active in the same folder.'))
457
+ return out.join('\n')
458
+ }
459
+
460
+ function toJson(r) {
461
+ return {
462
+ files: r.files,
463
+ bytes: r.bytes,
464
+ sessions: r.sessions,
465
+ commands: r.commands,
466
+ seconds: Number(r.seconds.toFixed(2)),
467
+ first: r.first ? new Date(r.first).toISOString() : null,
468
+ last: r.last ? new Date(r.last).toISOString() : null,
469
+ concurrency: r.concurrency,
470
+ commandsHit: r.summary.commandsHit,
471
+ sessionsHit: r.summary.sessionsHit,
472
+ rules: r.summary.rules.map(x => ({
473
+ guard: x.guard, rule: x.rule, count: x.count, sessions: x.sessions, stoppedByHook: x.hook, declinedByUser: x.user,
474
+ last: x.last ? new Date(x.last).toISOString() : null,
475
+ examples: x.examples.slice(0, 5).map(e => ({ command: e.command, project: treeRoot(e.cwd) || e.cwd, time: e.ts ? new Date(e.ts).toISOString() : null, outcome: e.outcome })),
476
+ })),
477
+ projects: r.summary.projects,
478
+ }
479
+ }
480
+
481
+ // ---------------------------------------------------------------------------------------------
482
+
483
+ // Earliest and latest timestamp. A loop, not Math.min(...times): a heavy user has hundreds of
484
+ // thousands of commands and spreading them into a call overflows the stack.
485
+ function timeSpan(commands) {
486
+ let first = null
487
+ let last = null
488
+ for (const c of commands) {
489
+ if (!Number.isFinite(c.ts)) continue
490
+ if (first === null || c.ts < first) first = c.ts
491
+ if (last === null || c.ts > last) last = c.ts
492
+ }
493
+ return { first, last }
494
+ }
495
+
496
+ async function scan({ dir = path.join(claudeDir(), 'projects'), since = null, project = null, home = os.homedir(), platform = process.platform, onProgress = null } = {}) {
497
+ const t0 = Date.now()
498
+ const files = findTranscripts(dir, { since })
499
+ const state = { commands: [], outcomes: new Map(), activity: new Map(), lines: 0 }
500
+ const total = files.reduce((n, f) => n + f.size, 0)
501
+ let done = 0
502
+ let doneFiles = 0
503
+ let tick = Date.now()
504
+ const report = () => {
505
+ if (!onProgress || Date.now() - tick < 500) return
506
+ tick = Date.now()
507
+ onProgress({ files: doneFiles, totalFiles: files.length, bytes: done, totalBytes: total, seconds: (tick - t0) / 1000 })
508
+ }
509
+ for (const f of files) {
510
+ try { await readTranscript(f.file, state, { since, onBytes: n => { done += n; report() } }) } catch {}
511
+ doneFiles++
512
+ report()
513
+ }
514
+ let commands = state.commands
515
+ if (project) commands = commands.filter(c => String(c.cwd || '').toLowerCase().includes(project.toLowerCase()))
516
+ const settings = { allowNames: setting('killGuard.allowNames', []), extraSharedNames: setting('killGuard.extraSharedNames', []), waitLimit: setting('waitGuard.limitSeconds', 180) }
517
+ const tRead = Date.now()
518
+ const hitsByCommand = commands.map(cmd => ({ cmd, hits: replay(cmd, state, { home, platform, settings }) }))
519
+ const { first, last } = timeSpan(commands)
520
+ return {
521
+ files: files.length,
522
+ bytes: files.reduce((n, f) => n + f.size, 0),
523
+ sessions: new Set(commands.map(c => c.session)).size,
524
+ commands: commands.length,
525
+ first,
526
+ last,
527
+ concurrency: concurrency(state),
528
+ summary: summarize(state, hitsByCommand),
529
+ seconds: (Date.now() - t0) / 1000,
530
+ readSeconds: (tRead - t0) / 1000,
531
+ }
532
+ }
533
+
534
+ const VALUE_FLAGS = ['since', 'path', 'project']
535
+ const BOOL_FLAGS = ['json']
536
+ // The first bare argument that is not a flag's value: `bulkhead scan 30d`.
537
+ function positionals(args) {
538
+ const out = []
539
+ for (let i = 0; i < args.length; i++) {
540
+ const a = args[i]
541
+ if (a === '-n') { i++; continue }
542
+ if (a.startsWith('--')) {
543
+ if (!a.includes('=') && VALUE_FLAGS.includes(a.slice(2)) && args[i + 1] !== undefined && !args[i + 1].startsWith('--')) i++
544
+ continue
545
+ }
546
+ out.push(a)
547
+ }
548
+ return out
549
+ }
550
+ const positionalSince = args => positionals(args)[0] || null
551
+ function checkArgs(args) {
552
+ for (const a of args) {
553
+ if (!a.startsWith('-') || a === '-n') continue
554
+ const name = a.startsWith('--') ? a.slice(2).split('=')[0] : null
555
+ if (!name || ![...VALUE_FLAGS, ...BOOL_FLAGS].includes(name)) return `unknown option ${a}`
556
+ }
557
+ const pos = positionals(args)
558
+ if (pos.length > 1) return `unexpected argument ${pos[1]}`
559
+ if (pos.length && option(args, 'since') !== undefined) return `give the time span once: --since ${option(args, 'since')} or ${pos[0]}, not both`
560
+ return null
561
+ }
562
+
563
+ async function main(args, { env = process.env, stdout = process.stdout, stderr = process.stderr, now = Date.now() } = {}) {
564
+ if (args.includes('--help') || args.includes('-h')) return stdout.write(USAGE + '\n')
565
+ const bad = checkArgs(args)
566
+ if (bad) {
567
+ stderr.write(`bulkhead scan: ${bad}\n${USAGE}\n`)
568
+ process.exitCode = 1
569
+ return
570
+ }
571
+ const sinceArg = option(args, 'since') || positionalSince(args)
572
+ let since = null
573
+ if (typeof sinceArg === 'string' && sinceArg) {
574
+ since = parseSince(sinceArg, now)
575
+ if (!Number.isFinite(since)) {
576
+ stderr.write(`bulkhead scan: cannot read --since ${sinceArg}; use 24h, 7d, 2w or a date like 2026-10-01\n`)
577
+ process.exitCode = 1
578
+ return
579
+ }
580
+ }
581
+ const p = option(args, 'path')
582
+ const dir = typeof p === 'string' ? path.resolve(p.replace(/^~(?=[\\/]|$)/, os.homedir())) : path.join(claudeDir(), 'projects')
583
+ if (!fs.existsSync(dir)) {
584
+ stderr.write(`bulkhead scan: ${shortPath(dir)} does not exist. Claude Code keeps transcripts in ~/.claude/projects; pass another folder with --path.\n`)
585
+ process.exitCode = 1
586
+ return
587
+ }
588
+ const proj = option(args, 'project')
589
+ const nIdx = args.indexOf('-n')
590
+ const n = nIdx >= 0 && Number(args[nIdx + 1]) >= 0 ? Math.floor(Number(args[nIdx + 1])) : 3
591
+ let shown = false
592
+ const onProgress = stderr.isTTY ? prog => {
593
+ if (prog.seconds < 3) return
594
+ shown = true
595
+ stderr.write(`\r\x1b[2Kscanning ${prog.files.toLocaleString('en-US')}/${prog.totalFiles.toLocaleString('en-US')} transcripts, ${bytes(prog.bytes)} of ${bytes(prog.totalBytes)}`)
596
+ } : null
597
+ const r = await scan({ dir, since, project: typeof proj === 'string' ? proj : null, onProgress })
598
+ if (shown) stderr.write('\r\x1b[2K')
599
+ r.sinceLabel = typeof sinceArg === 'string' ? sinceArg : null
600
+ if (args.includes('--json')) return stdout.write(JSON.stringify(toJson(r), null, 2) + '\n')
601
+ stdout.write(renderScan(r, { color: useColor(stdout, env), examples: n, width: stdout.columns || 120 }) + '\n')
602
+ }
603
+
604
+ module.exports = { main, scan, timeSpan, replay, renderScan, toJson, mask, outcomeOf, sessionKey, findTranscripts, concurrency, USAGE }