claude-bulkhead 0.2.2 → 0.3.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 (66) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/README.es.md +142 -50
  3. package/README.ja.md +142 -50
  4. package/README.ko.md +143 -51
  5. package/README.md +142 -50
  6. package/README.pt-BR.md +142 -50
  7. package/README.zh-CN.md +154 -62
  8. package/hooks/hooks.json +68 -8
  9. package/package.json +5 -2
  10. package/src/agent-hook.js +162 -0
  11. package/src/agent-setup.js +256 -0
  12. package/src/agents.js +89 -0
  13. package/src/allow-cli.js +65 -0
  14. package/src/allow-hint.js +62 -0
  15. package/src/allow-once.js +152 -0
  16. package/src/auto-queue.js +93 -0
  17. package/src/bash-guards.js +36 -0
  18. package/src/basics.js +46 -27
  19. package/src/checkout-guard.js +342 -74
  20. package/src/cli-util.js +13 -1
  21. package/src/cli.js +40 -4
  22. package/src/config-check.js +21 -4
  23. package/src/config-cli.js +75 -10
  24. package/src/config.js +106 -9
  25. package/src/dev-servers.js +361 -61
  26. package/src/disk-guard.js +8 -8
  27. package/src/doctor-enforcement.js +273 -0
  28. package/src/doctor.js +19 -9
  29. package/src/edit-guard.js +196 -0
  30. package/src/events.js +30 -5
  31. package/src/explain.js +251 -0
  32. package/src/git-info.js +55 -0
  33. package/src/gitargs.js +82 -0
  34. package/src/hook.js +38 -6
  35. package/src/kill-extras.js +219 -0
  36. package/src/kill-guard.js +228 -77
  37. package/src/kill-owners.js +116 -0
  38. package/src/log-cli.js +15 -10
  39. package/src/mcp.js +131 -0
  40. package/src/memory-guard.js +126 -24
  41. package/src/memory.js +21 -2
  42. package/src/notices.js +95 -0
  43. package/src/orphan-reaper.js +58 -11
  44. package/src/port-cli.js +78 -3
  45. package/src/port-guard.js +80 -68
  46. package/src/port-kills.js +198 -0
  47. package/src/pressure.js +75 -0
  48. package/src/processes.js +44 -12
  49. package/src/scan-report.js +166 -0
  50. package/src/scan.js +87 -18
  51. package/src/secrets-guard.js +301 -0
  52. package/src/self-allow.js +73 -0
  53. package/src/session-info.js +121 -0
  54. package/src/session-state.js +176 -0
  55. package/src/sessions.js +109 -20
  56. package/src/shell.js +279 -19
  57. package/src/slot-cli.js +92 -21
  58. package/src/slots.js +61 -13
  59. package/src/stable-port.js +61 -0
  60. package/src/status.js +55 -17
  61. package/src/statusline.js +45 -16
  62. package/src/subagent-guard.js +24 -0
  63. package/src/top.js +36 -11
  64. package/src/wait-guard.js +96 -32
  65. package/src/worktree-cli.js +454 -0
  66. package/src/worktree-copy.js +82 -0
package/src/processes.js CHANGED
@@ -3,6 +3,12 @@ const fs = require('fs')
3
3
  const path = require('path')
4
4
  const { execFileSync } = require('child_process')
5
5
 
6
+ // How long a process read may take. Inside a hook a slow read fails open, so it is kept short;
7
+ // the CLI commands wait longer for a full answer.
8
+ function readTimeout(cliMs, hookMs, env = process.env) {
9
+ return env.BULKHEAD_IN_HOOK === '1' ? hookMs : cliMs
10
+ }
11
+
6
12
  // Every reader returns { procs: [{ pid, ppid, name, cmd, start, rss, claudePid }], listening: Set<pid>,
7
13
  // ports: [{ port, pid }] }. start is milliseconds since the epoch, rss is bytes, and claudePid is null
8
14
  // when the environment is unreadable.
@@ -34,7 +40,7 @@ function parseWindows(json) {
34
40
 
35
41
  function readWindows(run = execFileSync) {
36
42
  const out = run('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', WINDOWS_QUERY],
37
- { encoding: 'utf8', windowsHide: true, timeout: 60000, maxBuffer: 64 * 1024 * 1024 })
43
+ { encoding: 'utf8', windowsHide: true, timeout: readTimeout(60000, 8000), maxBuffer: 64 * 1024 * 1024 })
38
44
  return parseWindows(out)
39
45
  }
40
46
 
@@ -57,6 +63,11 @@ function parseNetTcp(text) {
57
63
  return inodes
58
64
  }
59
65
 
66
+ // BULKHEAD_KEEP=1 in a process's environment marks something started on purpose to outlive its
67
+ // session (see orphan-reaper). Children inherit it, so a marked server's helpers are marked too.
68
+ const KEEP_VALUE = /^(?:1|true|yes)$/i
69
+ const isKeepValue = v => KEEP_VALUE.test(String(v || '').trim())
70
+
60
71
  function envValue(environ, key) {
61
72
  for (const kv of environ.split('\0')) if (kv.startsWith(key + '=')) return kv.slice(key.length + 1)
62
73
  return null
@@ -68,11 +79,11 @@ const realFs = {
68
79
  link: p => fs.readlinkSync(p),
69
80
  }
70
81
 
71
- function readLinux(io = realFs, clockTicks = 100, pageSize = 4096) {
82
+ function readLinux(io = realFs, clockTicks = 100, pageSize = 4096, { withPorts = true } = {}) {
72
83
  const bootMs = Number(io.read('/proc/stat').match(/^btime (\d+)/m)[1]) * 1000
73
84
  const tryRead = p => { try { return io.read(p) } catch { return null } }
74
85
  const listenInodes = new Map()
75
- for (const f of ['/proc/net/tcp', '/proc/net/tcp6']) {
86
+ for (const f of withPorts ? ['/proc/net/tcp', '/proc/net/tcp6'] : []) {
76
87
  const t = tryRead(f)
77
88
  if (t) for (const [inode, port] of parseNetTcp(t)) listenInodes.set(inode, port)
78
89
  }
@@ -94,6 +105,7 @@ function readLinux(io = realFs, clockTicks = 100, pageSize = 4096) {
94
105
  pid, ppid: s.ppid, name: s.name, cmd, start: bootMs + (s.startTicks / clockTicks) * 1000,
95
106
  rss: s.rssPages * pageSize, claudePid: claudePid || null,
96
107
  })
108
+ if (environ && isKeepValue(envValue(environ, 'BULKHEAD_KEEP'))) procs[procs.length - 1].keep = true
97
109
  if (!listenInodes.size) continue
98
110
  let fds = []
99
111
  try { fds = io.list(`/proc/${entry}/fd`) } catch {}
@@ -150,8 +162,18 @@ function parsePsEnv(text) {
150
162
  return out
151
163
  }
152
164
 
153
- function readMac(run = execFileSync, { withEnv = false } = {}) {
154
- const opts = { encoding: 'utf8', timeout: 15000, maxBuffer: 64 * 1024 * 1024, env: { ...process.env, LC_ALL: 'C' } }
165
+ // The PIDs whose `ps -E` line carries BULKHEAD_KEEP=1 as a whole word.
166
+ function parsePsKeep(text) {
167
+ const out = new Set()
168
+ for (const line of text.split('\n')) {
169
+ const m = /^\s*(\d+)\s.*\sBULKHEAD_KEEP=(\w+)(?=\s|$)/.exec(line)
170
+ if (m && isKeepValue(m[2])) out.add(Number(m[1]))
171
+ }
172
+ return out
173
+ }
174
+
175
+ function readMac(run = execFileSync, { withEnv = false, withPorts = true } = {}) {
176
+ const opts = { encoding: 'utf8', timeout: readTimeout(15000, 4000), maxBuffer: 64 * 1024 * 1024, env: { ...process.env, LC_ALL: 'C' } }
155
177
  const names = new Map()
156
178
  for (const line of run('ps', ['-axo', 'pid=,ucomm='], opts).split('\n')) {
157
179
  const m = /^\s*(\d+)\s+(.*)$/.exec(line)
@@ -160,22 +182,32 @@ function readMac(run = execFileSync, { withEnv = false } = {}) {
160
182
  const procs = parsePs(run('ps', ['-axww', '-o', 'pid=,ppid=,rss=,lstart=,args='], opts), names)
161
183
  if (withEnv) {
162
184
  let env = new Map()
163
- try { env = parsePsEnv(run('ps', ['-axwwE', '-o', 'pid=,command='], opts)) } catch {}
164
- for (const p of procs) p.claudePid = env.get(p.pid) || null
185
+ let keep = new Set()
186
+ try {
187
+ const text = run('ps', ['-axwwE', '-o', 'pid=,command='], opts)
188
+ env = parsePsEnv(text)
189
+ keep = parsePsKeep(text)
190
+ } catch {}
191
+ for (const p of procs) {
192
+ p.claudePid = env.get(p.pid) || null
193
+ if (keep.has(p.pid)) p.keep = true
194
+ }
165
195
  }
166
196
  let ports = []
167
197
  // lsof exits 1 when nothing is listening
168
- try { ports = parseLsof(run('lsof', ['-nP', '-iTCP', '-sTCP:LISTEN', '-Fn'], opts)) } catch (e) { ports = parseLsof(String(e.stdout || '')) }
198
+ if (withPorts) try { ports = parseLsof(run('lsof', ['-nP', '-iTCP', '-sTCP:LISTEN', '-Fn'], opts)) } catch (e) { ports = parseLsof(String(e.stdout || '')) }
169
199
  return { procs, listening: new Set(ports.map(l => l.pid)), ports }
170
200
  }
171
201
 
172
- function readProcesses(platform = process.platform, { withEnv = false } = {}) {
202
+ // withPorts: false (or ports: false) skips the listening sockets, the slowest part, for callers that need processes only.
203
+ function readProcesses(platform = process.platform, { withEnv = false, withPorts, ports } = {}) {
204
+ const wantPorts = withPorts !== undefined ? withPorts : ports !== undefined ? ports : true
173
205
  if (platform === 'win32') return readWindows()
174
- if (platform === 'linux') return readLinux()
175
- if (platform === 'darwin') return readMac(execFileSync, { withEnv })
206
+ if (platform === 'linux') return readLinux(realFs, 100, 4096, { withPorts: wantPorts })
207
+ if (platform === 'darwin') return readMac(execFileSync, { withEnv, withPorts: wantPorts })
176
208
  return null
177
209
  }
178
210
 
179
211
  module.exports = {
180
- readProcesses, parseWindows, readWindows, parseProcStat, parseNetTcp, readLinux, envValue, parsePs, parseLsof, readMac, parsePsEnv,
212
+ readProcesses, parseWindows, readWindows, parseProcStat, parseNetTcp, readLinux, envValue, parsePs, parseLsof, readMac, parsePsEnv, parsePsKeep, isKeepValue, readTimeout,
181
213
  }
@@ -0,0 +1,166 @@
1
+ 'use strict'
2
+ // Plain-words views of a scan result: the shareable summary, the matched part of a command, and
3
+ // --explain. Everything here reads the same r.summary.rules the normal report prints.
4
+ const REPO = 'https://github.com/dooly6276/claude-bulkhead'
5
+ const DAY_MS = 86400000
6
+
7
+ // What each rule means, in words a person who has never seen the guard names understands.
8
+ // A group is one line of the share text; rules in the same group are added together.
9
+ const GROUPS = [
10
+ { key: 'stage-all', match: (g, r) => g === 'checkout-guard' && r === 'stage-all', text: 'git add -A while another session was active in the same tree' },
11
+ { key: 'git', match: (g, r) => g === 'checkout-guard' && r.startsWith('git-'), text: 'checkout/merge/rebase/stash in a tree another session was using' },
12
+ { key: 'remove-dir', match: (g, r) => g === 'checkout-guard' && r === 'remove-session-dir', text: 'rm -rf or worktree remove on a folder another session was in' },
13
+ { key: 'kill', match: g => g === 'kill-guard', text: 'pkill/killall by name, which also stops other sessions\' processes' },
14
+ { key: 'wait', match: g => g === 'wait-guard', text: 'foreground sleeps and polling loops past the wait limit' },
15
+ { key: 'disk', match: g => g === 'disk-guard', text: 'whole-disk or parallel disk scans' },
16
+ { key: 'rm-root', match: (g, r) => g === 'basics' && r === 'rm-root', text: 'rm -rf at a root, home or system folder' },
17
+ { key: 'force-push', match: (g, r) => g === 'basics' && r === 'force-push-main', text: 'force-pushes to main' },
18
+ { key: 'discard', match: (g, r) => g === 'basics' && r === 'discard-changes', text: 'reset --hard or clean -f that lose uncommitted work' },
19
+ { key: 'author', match: (g, r) => g === 'basics' && r === 'agent-author', text: 'commits authored as Claude' },
20
+ { key: 'slots', match: g => g === 'memory-guard', text: 'hand-edited slot files' },
21
+ ]
22
+
23
+ function describe(guard, rule) {
24
+ const g = GROUPS.find(x => x.match(guard, rule))
25
+ return g ? g.text : `${guard} ${rule}`
26
+ }
27
+
28
+ // Counts per group, largest first. Hits are commands, so the same command can count in two groups.
29
+ function groups(r) {
30
+ const by = new Map()
31
+ for (const x of r.summary.rules) {
32
+ const g = GROUPS.find(y => y.match(x.guard, x.rule))
33
+ const key = g ? g.key : `${x.guard} ${x.rule}`
34
+ const cur = by.get(key) || { text: g ? g.text : `${x.guard} ${x.rule}`, count: 0 }
35
+ cur.count += x.count
36
+ by.set(key, cur)
37
+ }
38
+ return [...by.values()].sort((a, b) => b.count - a.count || a.text.localeCompare(b.text))
39
+ }
40
+
41
+ const num = n => n.toLocaleString('en-US')
42
+ const plural = (n, word) => `${num(n)} ${word}${n === 1 ? '' : 's'}`
43
+
44
+ function days(r) {
45
+ if (!r.first || !r.last) return 0
46
+ return Math.max(1, Math.ceil((r.last - r.first) / DAY_MS))
47
+ }
48
+
49
+ // The numbers the share text carries. Nothing else leaves this function: no paths, no commands.
50
+ function shareSummary(r) {
51
+ return {
52
+ days: days(r),
53
+ sessions: r.sessions,
54
+ maxAtOnce: r.concurrency.max,
55
+ commandsHit: r.summary.commandsHit,
56
+ sessionsHit: r.summary.sessionsHit,
57
+ items: groups(r),
58
+ }
59
+ }
60
+
61
+ const SHOWN = 4
62
+
63
+ function headline(s) {
64
+ const parts = []
65
+ if (s.days) parts.push(plural(s.days, 'day'))
66
+ parts.push(plural(s.sessions, 'session'))
67
+ if (s.maxAtOnce >= 2) parts.push(`up to ${s.maxAtOnce} at once`)
68
+ return parts.join(', ')
69
+ }
70
+
71
+ // Folds the groups beyond the shown ones into one "other" line.
72
+ function shownItems(s) {
73
+ if (s.items.length <= SHOWN) return s.items
74
+ const rest = s.items.slice(SHOWN - 1)
75
+ return [...s.items.slice(0, SHOWN - 1), { text: `other risky commands (${plural(rest.length, 'kind')})`, count: rest.reduce((n, x) => n + x.count, 0) }]
76
+ }
77
+
78
+ function shareText(r) {
79
+ const s = shareSummary(r)
80
+ if (!r.commands) return 'bulkhead scan found no Claude Code shell commands to replay.'
81
+ const out = [`bulkhead scan, ${headline(s)}.`]
82
+ if (!s.items.length) out.push('Nothing in these sessions would have been stopped.')
83
+ else {
84
+ out.push(`It would have stopped ${plural(s.commandsHit, 'command')} in ${plural(s.sessionsHit, 'session')}:`)
85
+ for (const x of shownItems(s)) out.push(` ${num(x.count)} ${x.text}`)
86
+ }
87
+ out.push(`(bulkhead scan, ${REPO.replace('https://', '')})`)
88
+ return out.join('\n')
89
+ }
90
+
91
+ function shareMarkdown(r) {
92
+ const s = shareSummary(r)
93
+ if (!r.commands) return 'bulkhead scan found no Claude Code shell commands to replay.'
94
+ const out = [`**bulkhead scan**, ${headline(s)}.`, '']
95
+ if (!s.items.length) out.push('Nothing in these sessions would have been stopped.')
96
+ else {
97
+ out.push('| Would have been stopped | Count |', '| --- | ---: |')
98
+ for (const x of shownItems(s)) out.push(`| ${x.text} | ${num(x.count)} |`)
99
+ out.push(`| **Total** | **${num(s.commandsHit)}** commands in ${plural(s.sessionsHit, 'session')} |`)
100
+ }
101
+ out.push('', `_bulkhead scan, [${REPO.replace('https://', '')}](${REPO})_`)
102
+ return out.join('\n')
103
+ }
104
+
105
+ // ---------------------------------------------------------------------------------------------
106
+ // The part of a command a rule matched. A best guess from the rule's name: the guards decide on a
107
+ // parsed command, scan only has the text, so this finds the clause a person would point at.
108
+
109
+ // One shell clause: up to ; & | or a newline, but `2>&1` stays inside it.
110
+ const CLAUSE = '(?:[^\\n;&|]|(?<=>)&)*'
111
+ const MATCHERS = {
112
+ 'kill-guard': /\b(?:pkill|killall|Stop-Process|taskkill|wmic|Get-Process|xargs\s+kill|kill)\b[^\n;&|]*/,
113
+ 'wait-guard': /\b(?:sleep|Start-Sleep|timeout|Wait-Process|tail\s+-\w*f)\b[^\n;&|]*/i,
114
+ 'disk-guard': /\b(?:du|ncdu|dust|find|tree|xargs|parallel|ForEach-Object|Get-ChildItem|dir|ls\s+-\w*R)\b[^\n;&|]*/,
115
+ 'memory-guard': /\S*slots?\S*/,
116
+ 'basics:rm-root': /\brm\s[^\n;&|]*/,
117
+ 'basics:force-push-main': /\bgit\s[^\n;&|]*?\bpush\b[^\n;&|]*/,
118
+ 'basics:discard-changes': /\bgit\s[^\n;&|]*?\b(?:reset|clean|checkout|restore)\b[^\n;&|]*/,
119
+ 'basics:agent-author': /--author\S*(?:\s*=?\s*(?:"[^"]*"|'[^']*'|\S+))?|git\s[^\n;&|]*user\.\w+[^\n;&|]*|GIT_(?:AUTHOR|COMMITTER)_\w+=\S+/,
120
+ 'checkout-guard:stage-all': /\bgit\s[^\n;&|]*?\b(?:add|commit)\b[^\n;&|]*/,
121
+ 'checkout-guard:remove-session-dir': /\b(?:rm\s[^\n;&|]*|git\s[^\n;&|]*?worktree\s+remove[^\n;&|]*)/,
122
+ }
123
+
124
+ const widen = re => new RegExp(re.source.split('[^\\n;&|]*').join(CLAUSE), re.flags)
125
+
126
+ function matcherFor(guard, rule) {
127
+ const given = MATCHERS[`${guard}:${rule}`]
128
+ if (given) return widen(given)
129
+ if (guard === 'checkout-guard' && rule.startsWith('git-')) return new RegExp(`\\bgit\\s[^\\n;&|]*?\\b${rule.slice(4).replace(/[^\w-]/g, '')}\\b${CLAUSE}`)
130
+ return MATCHERS[guard] ? widen(MATCHERS[guard]) : null
131
+ }
132
+
133
+ // { start, end } of the matched clause in the command, or null.
134
+ function findMatch(guard, rule, command) {
135
+ const re = matcherFor(guard, rule)
136
+ const m = re ? re.exec(command) : null
137
+ if (!m || !m[0].trim()) return null
138
+ const text = m[0].replace(/\s+$/, '')
139
+ return { start: m.index, end: m.index + text.length }
140
+ }
141
+
142
+ // A command cut to `width` characters around the match. The match is wrapped by `mark`.
143
+ function clipAround(command, match, width, mark) {
144
+ const flat = command.replace(/\s*\n\s*/g, ' ⏎ ')
145
+ // newline collapsing moves offsets, so find the match again in the flat text
146
+ let m = null
147
+ if (match) {
148
+ const raw = command.slice(match.start, match.end).replace(/\s*\n\s*/g, ' ⏎ ')
149
+ const at = flat.indexOf(raw)
150
+ if (at >= 0) m = { start: at, end: at + raw.length }
151
+ }
152
+ if (!m) return flat.length > width ? flat.slice(0, Math.max(0, width - 1)) + '…' : flat
153
+ if (flat.length <= width) return flat.slice(0, m.start) + mark(flat.slice(m.start, m.end)) + flat.slice(m.end)
154
+ const len = Math.min(m.end - m.start, Math.max(10, width - 8))
155
+ const room = Math.max(0, width - len)
156
+ let from = Math.max(0, m.start - Math.floor(room * 0.4))
157
+ if (from + width > flat.length) from = Math.max(0, flat.length - width)
158
+ const to = Math.min(flat.length, from + width)
159
+ const mEnd = Math.min(m.start + len, to)
160
+ const body = flat.slice(from, m.start) + mark(flat.slice(m.start, mEnd)) + flat.slice(mEnd, to)
161
+ return (from > 0 ? '…' : '') + body + (to < flat.length ? '…' : '')
162
+ }
163
+
164
+ const docsLink = guard => `${REPO}/blob/main/docs/guards/${guard}.md`
165
+
166
+ module.exports = { GROUPS, describe, groups, shareSummary, shareText, shareMarkdown, findMatch, clipAround, docsLink, days, REPO }
package/src/scan.js CHANGED
@@ -8,10 +8,13 @@ const os = require('os')
8
8
  const path = require('path')
9
9
  const { claudeDir } = require('./sessions')
10
10
  const { setting } = require('./hook')
11
- const { useColor, paint, shortPath, option, fit, table, bytes } = require('./cli-util')
11
+ const { useColor, paint, shortPath, option, table, bytes } = require('./cli-util')
12
12
  const { parseSince } = require('./log-cli')
13
+ const scanReport = require('./scan-report')
13
14
 
14
15
  const USAGE = `usage: bulkhead scan [30d|DATE] [--since 30d|DATE] [--path DIR|FILE] [--project TEXT] [-n N] [--json]
16
+ bulkhead scan [30d] --share [--markdown]
17
+ bulkhead scan [30d] --explain N
15
18
 
16
19
  Replays the shell commands in your Claude Code transcripts against bulkhead's guards and shows
17
20
  what they would have stopped. Read-only. Nothing leaves this machine.
@@ -19,7 +22,9 @@ what they would have stopped. Read-only. Nothing leaves this machine.
19
22
  --path P transcripts to read (default: ~/.claude/projects)
20
23
  --project TEXT only sessions whose folder contains TEXT
21
24
  -n N examples to show per rule (default 3, 0 for none)
22
- --json machine-readable output`
25
+ --json machine-readable output
26
+ --share 5 to 8 plain lines with counts only, to paste in a post or a chat (--markdown: a small table)
27
+ --explain N one example in full: the command, the part that matched, the rule, and the guard's docs`
23
28
 
24
29
  // Minutes on either side of a command in which another session counts as active.
25
30
  const ACTIVE_WINDOW_MIN = 10
@@ -410,53 +415,110 @@ const RULE_NOTES = {
410
415
  'checkout-guard': 'another session was active in the same tree at that minute',
411
416
  }
412
417
 
418
+ // Examples in the order the report prints them, numbered for --explain.
419
+ function exampleList(r, n) {
420
+ const out = []
421
+ for (const x of r.summary.rules) for (const e of x.examples.slice(0, n)) out.push({ n: out.length + 1, rule: x, example: e })
422
+ return out
423
+ }
424
+
425
+ // Rules where at least half of the hits were already stopped by a hook or declined by the user.
426
+ function mostlyStopped(rules) {
427
+ return rules.filter(x => x.hook + x.user > 0 && (x.hook + x.user) * 2 >= x.count)
428
+ }
429
+
413
430
  function renderScan(r, { color = false, home = os.homedir(), examples = 3, width = 120 } = {}) {
414
431
  const out = []
415
432
  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})` : ''}`)
433
+ const s = r.summary
434
+ out.push(`${b('bulkhead scan')} what your past Claude Code sessions would have run into`)
435
+ if (!r.commands) {
436
+ out.push('No shell commands found in these transcripts.')
437
+ return out.join('\n')
438
+ }
439
+ if (!s.rules.length) out.push(`Found nothing bulkhead would have stopped in ${plural(r.commands, 'shell command')} from ${plural(r.sessions, 'session')}.`)
440
+ else {
441
+ out.push(`Found ${b(plural(s.commandsHit, 'shell command'))} in ${s.sessionsHit.toLocaleString('en-US')} of your ${plural(r.sessions, 'session')} that bulkhead would have stopped.`)
442
+ const top = scanReport.groups(r).slice(0, 2).map(g => `${g.count.toLocaleString('en-US')} ${g.text}`)
443
+ out.push(`Most common: ${top.join('; ')}.`)
444
+ }
419
445
  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`)
446
+ out.push(`Up to ${b(r.concurrency.max)} Claude Code sessions ran at the same time (${day(Date.parse(r.concurrency.at))}); ` +
447
+ `on ${plural(r.concurrency.overlapDays, 'day')} two or more did.`)
422
448
  }
449
+ out.push(paint(color, 'dim', ` read ${plural(r.files, 'transcript')} (${bytes(r.bytes)}), ${plural(r.commands, 'shell command')} in ${r.seconds.toFixed(1)}s` +
450
+ (r.first ? `, ${day(r.first)} .. ${day(r.last)}${r.sinceLabel ? ` (since ${r.sinceLabel})` : ''}` : '')))
423
451
  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.')
452
+ if (!s.rules.length) {
453
+ out.push('Nothing in these transcripts would have been stopped.')
426
454
  return out.join('\n')
427
455
  }
428
456
  const rows = [['GUARD', 'RULE', 'HITS', 'SESSIONS', 'STOPPED', 'LAST']]
429
- for (const x of r.summary.rules) {
457
+ for (const x of s.rules) {
430
458
  const stopped = x.hook + x.user
431
459
  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
460
  }
433
461
  const lines = table(rows)
434
462
  out.push(b(lines[0]), ...lines.slice(1))
463
+ out.push(paint(color, 'dim', 'STOPPED: hits a hook already stopped or you declined, as the transcript recorded them. "-" means none recorded: the command ran.'))
435
464
  if (examples > 0) {
436
465
  let lastGuard = null
437
- for (const x of r.summary.rules) {
466
+ const mark = color ? t => paint(color, 'bold', t) : t => `[${t}]`
467
+ const numbered = exampleList(r, examples)
468
+ for (const x of s.rules) {
438
469
  out.push('')
439
470
  const note = x.guard !== lastGuard && RULE_NOTES[x.guard] ? paint(color, 'dim', ` ${RULE_NOTES[x.guard]}`) : ''
440
471
  lastGuard = x.guard
441
472
  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)) {
473
+ for (const { n, example: e } of numbered.filter(y => y.rule === x)) {
443
474
  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}`
475
+ const meta = `${shortPath(e.cwd ? treeRoot(e.cwd) : '?', home)} ${e.ts ? day(e.ts) : ''}${tag} #${n}`
445
476
  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)}`)
477
+ out.push(` $ ${scanReport.clipAround(e.command, scanReport.findMatch(x.guard, x.rule, e.command), room, mark)} ${paint(color, 'gray', meta)}`)
447
478
  }
448
479
  }
480
+ if (!color) out.push('', paint(color, 'dim', 'The part of a command that matched is shown in [brackets].'))
449
481
  }
450
482
  out.push('')
451
- const s = r.summary
452
483
  const already = s.rules.reduce((n, x) => n + x.hook + x.user, 0)
453
484
  out.push(b(`${plural(s.commandsHit, 'command')} in ${plural(s.sessionsHit, 'session')} would have been stopped by bulkhead.`) +
454
485
  (already ? ` ${already.toLocaleString('en-US')} of the hits were already stopped by a hook or declined by you.` : ''))
486
+ const mostly = mostlyStopped(s.rules)
487
+ if (mostly.length) {
488
+ const names = mostly.slice(0, 3).map(x => `${x.guard} ${x.rule} (${x.hook + x.user} of ${x.count})`).join(', ')
489
+ out.push(`Most hits of ${names} were already stopped by your own hooks or by you, so bulkhead adds less there than the count suggests.`)
490
+ }
455
491
  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
492
  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.'))
493
+ out.push(paint(color, 'dim', 'Share these numbers: bulkhead scan --share'))
494
+ if (examples > 0) out.push(paint(color, 'dim', 'One example in full, with the matched part and the rule: bulkhead scan --explain 1'))
457
495
  return out.join('\n')
458
496
  }
459
497
 
498
+ // One example in full: the whole command, what matched, the rule and where it is documented.
499
+ function renderExplain(r, number, { examples = 3, home = os.homedir(), color = false } = {}) {
500
+ const list = exampleList(r, examples > 0 ? examples : 3)
501
+ const hit = list.find(y => y.n === number)
502
+ if (!hit) return `No example #${number}: this scan has ${list.length}. Examples are numbered in the report (#1 ...).`
503
+ const { rule: x, example: e } = hit
504
+ const match = scanReport.findMatch(x.guard, x.rule, e.command)
505
+ const mark = color ? t => paint(color, 'bold', t) : t => `[${t}]`
506
+ const full = e.command.length > 4000 ? e.command.slice(0, 4000) + '\n…' : e.command
507
+ const body = match && match.end <= full.length ? full.slice(0, match.start) + mark(full.slice(match.start, match.end)) + full.slice(match.end) : full
508
+ const outcome = e.outcome === 'hook' ? 'stopped by a hook at the time' : e.outcome === 'user' ? 'declined by you at the time' : 'it ran'
509
+ return [
510
+ `${paint(color, 'bold', `Example #${number}`)} ${x.guard} ${x.rule}`,
511
+ ` when ${e.ts ? day(e.ts) : '?'} in ${shortPath(e.cwd ? treeRoot(e.cwd) || e.cwd : '?', home)} (${outcome})`,
512
+ ` meaning ${scanReport.describe(x.guard, x.rule)}`,
513
+ RULE_NOTES[x.guard] ? ` why ${RULE_NOTES[x.guard]}` : null,
514
+ match ? ` matched ${e.command.slice(match.start, match.end).replace(/\s*\n\s*/g, ' ⏎ ')}` : ' matched (the whole command; scan could not narrow it down)',
515
+ ` docs ${scanReport.docsLink(x.guard)}`,
516
+ '',
517
+ 'The command (secret-looking values are masked):',
518
+ body.replace(/^/gm, ' '),
519
+ ].filter(l => l !== null).join('\n')
520
+ }
521
+
460
522
  function toJson(r) {
461
523
  return {
462
524
  files: r.files,
@@ -531,8 +593,8 @@ async function scan({ dir = path.join(claudeDir(), 'projects'), since = null, pr
531
593
  }
532
594
  }
533
595
 
534
- const VALUE_FLAGS = ['since', 'path', 'project']
535
- const BOOL_FLAGS = ['json']
596
+ const VALUE_FLAGS = ['since', 'path', 'project', 'explain']
597
+ const BOOL_FLAGS = ['json', 'share', 'markdown']
536
598
  // The first bare argument that is not a flag's value: `bulkhead scan 30d`.
537
599
  function positionals(args) {
538
600
  const out = []
@@ -554,6 +616,11 @@ function checkArgs(args) {
554
616
  const name = a.startsWith('--') ? a.slice(2).split('=')[0] : null
555
617
  if (!name || ![...VALUE_FLAGS, ...BOOL_FLAGS].includes(name)) return `unknown option ${a}`
556
618
  }
619
+ if (args.includes('--markdown') && !args.includes('--share')) return '--markdown goes with --share'
620
+ const explain = option(args, 'explain')
621
+ if (explain !== undefined && !(Number.isInteger(Number(explain)) && Number(explain) >= 1)) return '--explain takes an example number, 1 or more'
622
+ const modes = ['json', 'share', 'explain'].filter(m => args.some(a => a === `--${m}` || a.startsWith(`--${m}=`)))
623
+ if (modes.length > 1) return `use one of --${modes.join(', --')}, not several`
557
624
  const pos = positionals(args)
558
625
  if (pos.length > 1) return `unexpected argument ${pos[1]}`
559
626
  if (pos.length && option(args, 'since') !== undefined) return `give the time span once: --since ${option(args, 'since')} or ${pos[0]}, not both`
@@ -598,7 +665,9 @@ async function main(args, { env = process.env, stdout = process.stdout, stderr =
598
665
  if (shown) stderr.write('\r\x1b[2K')
599
666
  r.sinceLabel = typeof sinceArg === 'string' ? sinceArg : null
600
667
  if (args.includes('--json')) return stdout.write(JSON.stringify(toJson(r), null, 2) + '\n')
668
+ if (args.includes('--share')) return stdout.write((args.includes('--markdown') ? scanReport.shareMarkdown(r) : scanReport.shareText(r)) + '\n')
669
+ if (option(args, 'explain') !== undefined) return stdout.write(renderExplain(r, Number(option(args, 'explain')), { examples: n, color: useColor(stdout, env) }) + '\n')
601
670
  stdout.write(renderScan(r, { color: useColor(stdout, env), examples: n, width: stdout.columns || 120 }) + '\n')
602
671
  }
603
672
 
604
- module.exports = { main, scan, timeSpan, replay, renderScan, toJson, mask, outcomeOf, sessionKey, findTranscripts, concurrency, USAGE }
673
+ module.exports = { main, scan, timeSpan, replay, renderScan, renderExplain, exampleList, toJson, mask, outcomeOf, sessionKey, findTranscripts, concurrency, USAGE }