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
@@ -0,0 +1,152 @@
1
+ 'use strict'
2
+ const crypto = require('crypto')
3
+ const fs = require('fs')
4
+ const path = require('path')
5
+ const { stateDir } = require('./hook')
6
+
7
+ // Allow once: a block gets a short code; the user runs `bulkhead allow <code>` in their own
8
+ // terminal, and the next identical tool call (same command text, same folder) passes the guard
9
+ // that blocked it. One JSON file per (code, guard, rule) in <state dir>/allow, so two guards
10
+ // blocking the same command in parallel never overwrite each other's write.
11
+
12
+ const ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789' // base32 without I, O, 0, 1
13
+ const CODE_LENGTH = 5
14
+ const DEFAULT_TTL_MINUTES = 10
15
+
16
+ const dirOf = (base = stateDir()) => path.join(base, 'allow')
17
+
18
+ function ttlMs(minutes) {
19
+ let m = minutes
20
+ if (m === undefined) {
21
+ try { m = require('./config').get('allowOnce.ttlMinutes') } catch {}
22
+ }
23
+ return (Number.isFinite(m) && m > 0 ? m : DEFAULT_TTL_MINUTES) * 60000
24
+ }
25
+
26
+ const normalizeCommand = command => String(command || '').replace(/\r\n/g, '\n').trim()
27
+ const normalizeCwd = cwd => {
28
+ const s = String(cwd || '').replace(/\\/g, '/').replace(/\/+$/, '')
29
+ return process.platform === 'win32' ? s.toLowerCase() : s
30
+ }
31
+
32
+ // The identity of a tool call as far as allowing it goes: the command text and the folder.
33
+ function commandKey(command, cwd) {
34
+ return crypto.createHash('sha256').update(`${normalizeCommand(command)}\0${normalizeCwd(cwd)}`).digest('hex')
35
+ }
36
+
37
+ function newCode(taken = new Set()) {
38
+ for (;;) {
39
+ const bytes = crypto.randomBytes(CODE_LENGTH)
40
+ let code = ''
41
+ for (const b of bytes) code += ALPHABET[b % 32]
42
+ if (!taken.has(code)) return code
43
+ }
44
+ }
45
+
46
+ const cleanCode = s => String(s || '').toUpperCase().replace(/[^A-Z0-9]/g, '')
47
+
48
+ function writeAtomic(file, data) {
49
+ const tmp = `${file}.${process.pid}.${crypto.randomBytes(3).toString('hex')}.tmp`
50
+ fs.writeFileSync(tmp, JSON.stringify(data))
51
+ try {
52
+ fs.renameSync(tmp, file)
53
+ } catch (e) {
54
+ try { fs.unlinkSync(tmp) } catch {}
55
+ throw e
56
+ }
57
+ }
58
+
59
+ const valid = e => e && typeof e === 'object' && typeof e.code === 'string' && typeof e.guard === 'string' &&
60
+ typeof e.rule === 'string' && typeof e.key === 'string' && Number.isFinite(e.expires) && Number.isFinite(e.created)
61
+
62
+ // Every readable entry as { file, entry }. Expired and unreadable files are deleted on the way.
63
+ function readAll(base, now) {
64
+ const dir = dirOf(base)
65
+ let names
66
+ try { names = fs.readdirSync(dir) } catch { return [] }
67
+ const out = []
68
+ for (const name of names) {
69
+ const file = path.join(dir, name)
70
+ if (!name.endsWith('.json')) {
71
+ // a temp file left by a crash
72
+ try { if (now - fs.statSync(file).mtimeMs > 60000) fs.unlinkSync(file) } catch {}
73
+ continue
74
+ }
75
+ let entry = null
76
+ try { entry = JSON.parse(fs.readFileSync(file, 'utf8')) } catch {}
77
+ if (!valid(entry) || entry.expires <= now) {
78
+ try { fs.unlinkSync(file) } catch {}
79
+ continue
80
+ }
81
+ out.push({ file, entry })
82
+ }
83
+ return out
84
+ }
85
+
86
+ const safe = s => String(s).replace(/[^\w.-]/g, '_')
87
+
88
+ // The code a block shows. A second guard blocking the same command in the same minute reuses
89
+ // the code of the first, so the person types one code. Returns null when nothing can be stored.
90
+ function issue({ guard, rule, command, cwd, session }, { base, now = Date.now(), ttl } = {}) {
91
+ try {
92
+ const key = commandKey(command, cwd)
93
+ const all = readAll(base, now)
94
+ const same = all.find(x => x.entry.key === key && !x.entry.approved)
95
+ const code = same ? same.entry.code : newCode(new Set(all.map(x => x.entry.code)))
96
+ const dir = dirOf(base)
97
+ fs.mkdirSync(dir, { recursive: true })
98
+ const entry = {
99
+ code, guard, rule, key, cmd: normalizeCommand(command).replace(/\s+/g, ' ').slice(0, 160),
100
+ cwd: cwd || '', session: session || null, created: now, expires: now + ttlMs(ttl), approved: null,
101
+ }
102
+ writeAtomic(path.join(dir, `${code}-${safe(guard)}-${safe(rule)}.json`), entry)
103
+ return code
104
+ } catch {
105
+ return null
106
+ }
107
+ }
108
+
109
+ // Marks every pending entry of that command as allowed once. { ok, entries } or { ok: false, reason }.
110
+ function approve(input, { base, now = Date.now(), ttl } = {}) {
111
+ const code = cleanCode(input)
112
+ if (!code) return { ok: false, reason: 'no-code' }
113
+ let all
114
+ try { all = readAll(base, now) } catch { return { ok: false, reason: 'unreadable' } }
115
+ const hit = all.find(x => x.entry.code === code)
116
+ if (!hit) return { ok: false, reason: 'unknown' }
117
+ if (hit.entry.approved) return { ok: false, reason: 'used', entries: [hit.entry] }
118
+ const approved = []
119
+ for (const x of all) {
120
+ if (x.entry.key !== hit.entry.key || x.entry.approved) continue
121
+ const e = { ...x.entry, approved: now, expires: now + ttlMs(ttl) }
122
+ try { writeAtomic(x.file, e) } catch { return { ok: false, reason: 'unwritable' } }
123
+ approved.push(e)
124
+ }
125
+ return { ok: true, entries: approved }
126
+ }
127
+
128
+ // Called by a guard that is about to block. True when the user approved this exact command for
129
+ // this guard and rule; the approval is deleted, so it works once. Any trouble means false.
130
+ function consume(guard, rule, command, cwd, { base, now = Date.now() } = {}) {
131
+ try {
132
+ const key = commandKey(command, cwd)
133
+ for (const x of readAll(base, now)) {
134
+ const e = x.entry
135
+ if (!e.approved || e.key !== key || e.guard !== guard || e.rule !== rule) continue
136
+ try { fs.unlinkSync(x.file) } catch { continue } // somebody else used it first
137
+ return true
138
+ }
139
+ } catch {}
140
+ return false
141
+ }
142
+
143
+ // Pending and approved entries, newest first, for `bulkhead allow --list`.
144
+ function list({ base, now = Date.now() } = {}) {
145
+ try {
146
+ return readAll(base, now).map(x => x.entry).sort((a, b) => b.created - a.created)
147
+ } catch {
148
+ return []
149
+ }
150
+ }
151
+
152
+ module.exports = { issue, approve, consume, list, commandKey, normalizeCommand, newCode, dirOf, ttlMs, ALPHABET, DEFAULT_TTL_MINUTES }
@@ -0,0 +1,93 @@
1
+ 'use strict'
2
+ const fs = require('fs')
3
+ const path = require('path')
4
+ const shell = require('./shell')
5
+
6
+ // Rewrites a heavy command so it runs through bulkhead-slot, which queues it against the
7
+ // machine-wide RAM budget. Pure functions: the hook (memory-guard.js) supplies the settings.
8
+
9
+ // Typical peak RAM per tool, in GB. memoryGuard.estimatesGb overrides single entries.
10
+ const ESTIMATES_GB = {
11
+ vitest: 3, jest: 3, karma: 2, playwright: 4, cypress: 4, gradle: 4, gradlew: 4, mvn: 3, mvnw: 3,
12
+ dotnet: 2, next: 3, vite: 2, tsc: 2, webpack: 3, ffmpeg: 2, cargo: 4, go: 3, pytest: 3,
13
+ docker: 3, 'docker-compose': 3, bazel: 6, turbo: 4, nx: 4, xcodebuild: 6, swift: 4, fastlane: 4,
14
+ npm: 3, pnpm: 3, yarn: 3, bun: 3, browser: 3,
15
+ }
16
+ const DEFAULT_GB = 3
17
+
18
+ function estimateGb(name, overrides) {
19
+ const n = overrides && overrides[name]
20
+ if (typeof n === 'number' && n > 0 && n <= 256) return n
21
+ return ESTIMATES_GB[name] || DEFAULT_GB
22
+ }
23
+
24
+ // A short name for bulkhead status: "npm test", "cargo build". Only characters that need no
25
+ // quoting inside the label, so a command's own quotes never reach the wrapper.
26
+ function labelFor(argv) {
27
+ const sub = argv.slice(1).find(a => !a.startsWith('-')) || ''
28
+ const raw = `${shell.bin(argv[0])} ${sub}`.trim().slice(0, 40)
29
+ return raw.replace(/[^\w .:/@=+-]/g, '_')
30
+ }
31
+
32
+ // One word for each shell. bash: single quotes, a quote inside becomes '\''. PowerShell: single
33
+ // quotes, a quote inside is doubled. cmd: double quotes, a quote inside is doubled.
34
+ const SAFE = /^[\w@%+=:,./-]+$/
35
+ const SAFE_WIN = /^[\w@%+=:,./\\-]+$/
36
+ const quoters = {
37
+ sh: a => (SAFE.test(a) ? a : `'${a.replace(/'/g, `'\\''`)}'`),
38
+ ps: a => (SAFE_WIN.test(a) ? a : `'${a.replace(/'/g, "''")}'`),
39
+ cmd: a => (SAFE_WIN.test(a) ? a : `"${a.replace(/"/g, '""')}"`),
40
+ }
41
+
42
+ function which(name, env = process.env, exists = fs.existsSync, platform = process.platform) {
43
+ const dirs = String(env.PATH || env.Path || '').split(platform === 'win32' ? ';' : ':').filter(Boolean)
44
+ const file = platform === 'win32' ? `${name}.exe` : name
45
+ return dirs.some(d => exists(path.join(d, file))) ? name : null
46
+ }
47
+
48
+ // The slot script of this package, from the plugin root when Claude Code sets it. null when the
49
+ // file is missing: the caller then leaves the command alone.
50
+ function slotScript({ env = process.env, exists = fs.existsSync, dir = __dirname } = {}) {
51
+ const roots = [env.CLAUDE_PLUGIN_ROOT, path.join(dir, '..')].filter(Boolean)
52
+ for (const r of roots) {
53
+ const f = path.join(r, 'bin', 'bulkhead-slot')
54
+ if (exists(f)) return f
55
+ }
56
+ return null
57
+ }
58
+
59
+ // The command text with `-- <command>` handed to bulkhead-slot, or null when this shell cannot
60
+ // carry it (a newline in a cmd line is a second command). The original runs in the same kind
61
+ // of shell as before: bash -c for the Bash tool, an encoded script for PowerShell.
62
+ function wrap(command, { shell: sh = 'sh', node = process.execPath, script, gb, label, wait, env = process.env, exists, platform } = {}) {
63
+ const q = quoters[sh]
64
+ if (!q || !script) return null
65
+ const head = [node, script].map(q)
66
+ const opts = ['run', `--gb=${gb}`, q(`--label=${label}`)]
67
+ if (wait) opts.push(`--wait=${wait}`)
68
+ let tail
69
+ if (sh === 'sh') tail = `bash -c ${q(command)}`
70
+ else if (sh === 'ps') {
71
+ const exe = which('pwsh', env, exists, platform) || 'powershell'
72
+ tail = `${exe} -NoProfile -EncodedCommand ${Buffer.from(command, 'utf16le').toString('base64')}`
73
+ } else {
74
+ if (/[\r\n]/.test(command)) return null
75
+ tail = q(command)
76
+ }
77
+ return `${sh === 'ps' ? '& ' : ''}${head.join(' ')} ${opts.join(' ')} -- ${tail}`
78
+ }
79
+
80
+ // A single & backgrounds a command, so the slot would be released at once and hold nothing.
81
+ function backgrounded(command, mode = 'sh') {
82
+ const text = String(command)
83
+ if (mode === 'ps') return /\bStart-(?:Job|Process)\b|\bsaps\b/i.test(text)
84
+ const bare = text.replace(/'[^']*'|"(?:\\.|[^"\\])*"/g, ' ').replace(/&&|\d?>&\d?|&>>?|\|&/g, ' ')
85
+ return bare.includes('&') || /\b(?:nohup|setsid|disown)\b/.test(bare)
86
+ }
87
+
88
+ // Already queued, or this is the wrapper we wrote.
89
+ function wrapped(command) {
90
+ return /bulkhead(?:-|\s+)slot/.test(String(command))
91
+ }
92
+
93
+ module.exports = { ESTIMATES_GB, estimateGb, labelFor, wrap, slotScript, backgrounded, wrapped, quoters, which }
@@ -0,0 +1,36 @@
1
+ 'use strict'
2
+ const { runHook, enabled } = require('./hook')
3
+
4
+ // Every Bash and PowerShell guard in one process. Seven separate hooks meant seven Node starts
5
+ // per tool call, which adds up when ten sessions share a machine. The guards run in order of
6
+ // cost, text-only checks first and the ones that read git, memory, processes or ports last, and
7
+ // the first block stops the rest. A guard that throws is skipped, so one bug never silences the
8
+ // others; the whole hook still fails open on anything unexpected.
9
+ const ORDER = [
10
+ ['secrets-guard', './secrets-guard'],
11
+ ['wait-guard', './wait-guard'],
12
+ ['disk-guard', './disk-guard'],
13
+ ['kill-guard', './kill-guard'],
14
+ ['basics', './basics'],
15
+ ['checkout-guard', './checkout-guard'],
16
+ ['memory-guard', './memory-guard'],
17
+ ['port-guard', './port-guard'],
18
+ ]
19
+
20
+ // { block } from the first guard that blocks, else the first non-blocking output (memory-guard's
21
+ // queued rewrite), else nothing. `rewrite: false` drops rewrites for callers that cannot use them.
22
+ async function judge(input, { rewrite = true, order = ORDER } = {}) {
23
+ let output = null
24
+ for (const [name, file] of order) {
25
+ if (!enabled(name)) continue
26
+ let result = null
27
+ try { result = await (typeof file === 'string' ? require(file) : file).handle(input) } catch {}
28
+ if (result && result.block) return { block: result.block }
29
+ if (rewrite && result && result.output && !output) output = result.output
30
+ }
31
+ return output ? { output } : null
32
+ }
33
+
34
+ if (require.main === module) runHook('bash-guards', input => judge(input))
35
+
36
+ module.exports = { judge, ORDER }
package/src/basics.js CHANGED
@@ -5,9 +5,10 @@ const { execFileSync } = require('child_process')
5
5
  const { runHook, enabled, setting, blocked, oneLine, ASK_USER } = require('./hook')
6
6
  const shell = require('./shell')
7
7
  const { realPath } = require('./paths')
8
+ const { gitCall, gitEnv, parseArgs } = require('./gitargs')
8
9
 
9
10
  // Narrow rules for mistakes that cannot be undone. Each can be turned off on its own:
10
- // rm-root, force-push-main, discard-changes, agent-author.
11
+ // rm-root, force-push-main, discard-changes, agent-author, self-allow.
11
12
 
12
13
  // ~, $HOME and %USERPROFILE% become the home directory; /c/... becomes C:/... on Windows.
13
14
  // Returns null for anything with a variable we cannot expand.
@@ -94,20 +95,6 @@ function git(args, cwd) {
94
95
  return execFileSync('git', args, { cwd, encoding: 'utf8', timeout: 5000, windowsHide: true, stdio: ['ignore', 'pipe', 'ignore'] }).trim()
95
96
  }
96
97
 
97
- function gitCwd(t, cwd) {
98
- const i = t.indexOf('-C')
99
- return i > 0 && t[i + 1] ? path.resolve(cwd, t[i + 1]) : cwd
100
- }
101
-
102
- function gitSub(t) {
103
- const rest = []
104
- for (let i = 1; i < t.length; i++) {
105
- if (t[i] === '-C' || t[i] === '-c') i++
106
- else rest.push(t[i])
107
- }
108
- return rest
109
- }
110
-
111
98
  function forcePushToMain(rest, branch, protectedBranches = ['main', 'master']) {
112
99
  if (rest[0] !== 'push') return false
113
100
  const args = rest.slice(1)
@@ -119,6 +106,26 @@ function forcePushToMain(rest, branch, protectedBranches = ['main', 'master']) {
119
106
  return targets.some(r => protectedBranches.includes(String(r || '').replace(/^refs\/heads\//, '')))
120
107
  }
121
108
 
109
+ // git push origin :main, git push --delete origin main, git push -d origin main, and
110
+ // git branch -D main (or -d -f, --delete --force): removing a protected branch. A plain
111
+ // git branch -d refuses unmerged work and is left alone.
112
+ function deletesProtected(rest, protectedBranches = ['main', 'master']) {
113
+ const name = r => String(r || '').replace(/^\+/, '').replace(/^refs\/heads\//, '')
114
+ const isProtected = r => protectedBranches.includes(name(r))
115
+ if (rest[0] === 'push') {
116
+ const o = parseArgs(rest.slice(1), { valuedShort: 'o', valuedLong: ['repo', 'push-option', 'receive-pack', 'exec'] })
117
+ const del = o.short.has('d') || [...o.long].some(n => n.length >= 3 && 'delete'.startsWith(n))
118
+ return o.pos.slice(1).map(r => r.replace(/^\+/, '')).some(r => (r.startsWith(':') ? isProtected(r.slice(1)) : del && isProtected(r)))
119
+ }
120
+ if (rest[0] === 'branch') {
121
+ const o = parseArgs(rest.slice(1), { valuedShort: 'uts', valuedLong: ['set-upstream-to', 'track', 'sort', 'format', 'contains', 'merged', 'no-merged', 'points-at'] })
122
+ const del = o.short.has('D') || o.short.has('d') || o.long.has('delete')
123
+ const force = o.short.has('D') || o.short.has('f') || o.long.has('force')
124
+ return del && force && o.pos.some(isProtected)
125
+ }
126
+ return false
127
+ }
128
+
122
129
  // An identity that is the agent, not the person: Claude <noreply@anthropic.com> and the like.
123
130
  const AGENT_ID = /\bclaude\b|anthropic/i
124
131
 
@@ -164,7 +171,12 @@ function check(cmd, {
164
171
  const rmRoot = once(() => enabled('rm-root'))
165
172
  const forcePush = once(() => enabled('force-push-main'))
166
173
  const discard = once(() => enabled('discard-changes'))
174
+ const protectedDelete = once(() => enabled('protected-delete'))
167
175
  const author = once(() => enabled('agent-author'))
176
+ if (/allow/i.test(String(cmd || '')) && enabled('self-allow')) {
177
+ const self = require('./self-allow').check(cmd, { mode })
178
+ if (self) return self
179
+ }
168
180
  const prepared = shell.prepare(String(cmd || ''), mode)
169
181
  const cmds = shell.commands(prepared, mode).filter(x => !x.remote)
170
182
  if (author() && /GIT_(?:AUTHOR|COMMITTER)_/i.test(prepared)) {
@@ -192,45 +204,52 @@ function check(cmd, {
192
204
  }
193
205
  }
194
206
  if (path.basename(t[0]).replace(/\.exe$/i, '') !== 'git') continue
195
- const dir = gitCwd(t, cwd)
196
- const rest = gitSub(t)
207
+ const call = gitCall(t, cwd, c.depth === 0 ? gitEnv(prepared.slice(c.s, c.e)) : {})
208
+ const dir = call.dir
209
+ const rest = call.sub ? [call.sub, ...call.args] : []
197
210
  const who = author() && agentAuthor(t, rest)
198
211
  if (who) return agentMessage(who)
199
212
  if (rest[0] === 'push' && forcePush() && forcePushToMain(rest, () => { try { return run(['rev-parse', '--abbrev-ref', 'HEAD'], dir) } catch { return '' } }, protectedBranches)) {
200
213
  return `[bulkhead] basics: force-pushing to ${protectedBranches.join(' or ')} rewrites history other people and sessions have pulled.\n` +
201
214
  'Push to a branch, or use --force-with-lease if you are sure.'
202
215
  }
216
+ if (rest[0] && protectedDelete() && deletesProtected(rest, protectedBranches)) {
217
+ return `[bulkhead] basics: \`${oneLine(seg, 120)}\` deletes the protected branch (${protectedBranches.join(', ')}), and the work on it goes with it.\n` +
218
+ 'Delete a feature branch instead, or turn the rule off for this project if you really mean it.'
219
+ }
203
220
  const reset = rest[0] === 'reset' && rest.includes('--hard')
204
221
  const clean = rest[0] === 'clean' && rest.some(a => /^-[a-z]*f/.test(a) || a === '--force') && rest.some(a => /^-[a-z]*[dx]/.test(a))
205
222
  if ((reset || clean) && discard()) {
206
223
  let status
207
- try { status = run(['status', '--porcelain'], dir) } catch { continue }
224
+ try { status = run([...call.opts, 'status', '--porcelain'], dir) } catch { continue }
208
225
  const lines = status.split('\n').filter(Boolean)
209
226
  const lost = reset ? lines.filter(l => !l.startsWith('??')) : lines.filter(l => l.startsWith('??'))
210
227
  if (lost.length) {
211
228
  return `[bulkhead] basics: \`${oneLine(seg, 120)}\` would throw away ${lost.length} ${reset ? 'uncommitted change' : 'untracked file'}${lost.length === 1 ? '' : 's'}` +
212
229
  ` (${lost.slice(0, 3).map(l => l.slice(3)).join(', ')}${lost.length > 3 ? ', ...' : ''}).\n` +
213
- 'Commit them or run `git stash push -u` first, so they can be brought back.'
230
+ 'Commit them, or move the files somewhere else, first. `git clean -n` and `git status` show what would go.'
214
231
  }
215
232
  }
216
233
  }
217
234
  return null
218
235
  }
219
236
 
220
- if (require.main === module) {
221
- runHook('basics', input => {
222
- const cmd = input.tool_input && input.tool_input.command
223
- const why = typeof cmd === 'string' && cmd && check(cmd, { cwd: input.cwd || process.cwd(), mode: shell.modeFor(input) })
224
- if (!why) return
225
- return blocked('basics', ruleOf(why), input, `${why}\n${ASK_USER}`)
226
- })
237
+ function handle(input) {
238
+ const cmd = input.tool_input && input.tool_input.command
239
+ const why = typeof cmd === 'string' && cmd && check(cmd, { cwd: input.cwd || process.cwd(), mode: shell.modeFor(input) })
240
+ if (!why) return
241
+ return blocked('basics', ruleOf(why), input, ruleOf(why) === 'self-allow' ? why : `${why}\n${ASK_USER}`)
227
242
  }
228
243
 
244
+ if (require.main === module) runHook('basics', handle)
245
+
229
246
  function ruleOf(message) {
247
+ if (/cannot approve its own blocks/.test(message)) return 'self-allow'
230
248
  if (/makes the agent the author/.test(message)) return 'agent-author'
231
249
  if (/force-pushing/.test(message)) return 'force-push-main'
250
+ if (/deletes the protected branch/.test(message)) return 'protected-delete'
232
251
  if (/would throw away/.test(message)) return 'discard-changes'
233
252
  return 'rm-root'
234
253
  }
235
254
 
236
- module.exports = { check, rmTargets, dangerousTarget, forcePushToMain, ruleOf, agentAuthor }
255
+ module.exports = { handle, check, rmTargets, dangerousTarget, forcePushToMain, deletesProtected, ruleOf, agentAuthor }