claude-code-session-manager 0.83.0 → 0.84.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 (92) hide show
  1. package/dist/assets/{AgentLibrary-B-OM-4gk.js → AgentLibrary-CCVIpoSz.js} +1 -1
  2. package/dist/assets/{DataModel-BC6ppcBr.js → DataModel-BltREYde.js} +1 -1
  3. package/dist/assets/{History-D3Qw2BWn.js → History-BZxFkOp6.js} +2 -2
  4. package/dist/assets/{Hooks-OxBQ0nIc.js → Hooks-Bznlfaaa.js} +1 -1
  5. package/dist/assets/{HostBilko-BNFC6bNZ.js → HostBilko-DUG5YHA_.js} +1 -1
  6. package/dist/assets/{Library-DAMyrUtY.js → Library-Bi2Fn3w9.js} +1 -1
  7. package/dist/assets/{ListDetail-BwywrpvE.js → ListDetail-4VBvXKrz.js} +1 -1
  8. package/dist/assets/{MarkdownEditor-HEwjN1bp.js → MarkdownEditor-D2ft_v5j.js} +1 -1
  9. package/dist/assets/{McpServers-D1VZF7Ix.js → McpServers-FDekKkyE.js} +1 -1
  10. package/dist/assets/{Memory-CgYJ_QqD.js → Memory-Dkl80uj_.js} +6 -6
  11. package/dist/assets/{Panel-dTNWC59Q.js → Panel-LurG5VfD.js} +1 -1
  12. package/dist/assets/{Permissions-qI_NKi1b.js → Permissions-D2wHBCFA.js} +1 -1
  13. package/dist/assets/{Plugins-DZ0_CHiF.js → Plugins-CTw_wwbI.js} +2 -2
  14. package/dist/assets/{ProvenanceBadge-B2i_uxCu.js → ProvenanceBadge-CW6HNUv6.js} +1 -1
  15. package/dist/assets/{SaveBar-4tNMCjUd.js → SaveBar-alDHG6cP.js} +1 -1
  16. package/dist/assets/{Scheduler-CUen18Gc.js → Scheduler-DxiPcaiW.js} +7 -7
  17. package/dist/assets/{ScopeSwitcher-Co3tkLTT.js → ScopeSwitcher-DzFXUKLZ.js} +1 -1
  18. package/dist/assets/Settings-B6H3v2am.js +3 -0
  19. package/dist/assets/{SkillReferenceGraph-BKcHtMPc.js → SkillReferenceGraph-B8EZalpN.js} +1 -1
  20. package/dist/assets/{Skills-DHb4INtF.js → Skills-DwuHuA08.js} +2 -2
  21. package/dist/assets/SystemPrompt-CMMqpGYn.js +1 -0
  22. package/dist/assets/{TagLibrary-xLP5dJvh.js → TagLibrary-C2N5C1n9.js} +1 -1
  23. package/dist/assets/{TiptapBody-T-ULTjK4.js → TiptapBody-btlID-dQ.js} +1 -1
  24. package/dist/assets/{Toggle-B-5M2hGe.js → Toggle-BGOn3DZj.js} +1 -1
  25. package/dist/assets/{index-CzAxC432.js → index-QLRf0epp.js} +316 -314
  26. package/dist/assets/{index-CYhdtisq.css → index-mnjNDpb1.css} +1 -1
  27. package/dist/assets/{settingsSchema-C_nsemcF.js → settingsSchema-DA3N2Up3.js} +1 -1
  28. package/dist/index.html +2 -2
  29. package/package.json +4 -1
  30. package/scripts/hooks/guard-destructive-git.cjs +514 -0
  31. package/scripts/hooks/guard-inline-implementation.cjs +219 -0
  32. package/scripts/hooks/guard-prd-writes.cjs +200 -0
  33. package/src/main/__tests__/epicMintTelemetryTap.test.cjs +64 -0
  34. package/src/main/__tests__/health-delegation-chain.test.cjs +2 -1
  35. package/src/main/__tests__/health-queue-dispatch.test.cjs +84 -0
  36. package/src/main/__tests__/health-usage-poller.test.cjs +97 -0
  37. package/src/main/__tests__/health-worktree-cap-blocked.test.cjs +65 -0
  38. package/src/main/__tests__/opsErrorLogTelemetryTap.test.cjs +143 -0
  39. package/src/main/__tests__/pollLoop-dispatch-on-failure.test.cjs +120 -0
  40. package/src/main/__tests__/promptSessionTranscript.test.cjs +0 -0
  41. package/src/main/__tests__/queue-starvation-dispatch-driver.test.cjs +143 -0
  42. package/src/main/__tests__/rateLimitPollerStreak.test.cjs +79 -0
  43. package/src/main/__tests__/scheduleJobTransitionsTelemetryTap.test.cjs +72 -0
  44. package/src/main/__tests__/scheduler-inplace-salvage.test.cjs +74 -0
  45. package/src/main/__tests__/scheduler-job-overrun.test.cjs +58 -0
  46. package/src/main/__tests__/scheduler-notify-originating-tab-transcript.test.cjs +1 -0
  47. package/src/main/__tests__/scheduler-periodic-reverify-guard.test.cjs +134 -2
  48. package/src/main/__tests__/scheduler-reap-dead-running-jobs.test.cjs +33 -0
  49. package/src/main/__tests__/scheduler-stuck-failed-escalation.test.cjs +136 -0
  50. package/src/main/__tests__/telemetryClient.test.cjs +66 -0
  51. package/src/main/__tests__/telemetryContract.test.cjs +883 -0
  52. package/src/main/crashDiagnostics.cjs +29 -1
  53. package/src/main/health.cjs +197 -3
  54. package/src/main/index.cjs +65 -6
  55. package/src/main/ipcSchemas.cjs +19 -2
  56. package/src/main/lib/__tests__/crashTelemetry.test.cjs +97 -0
  57. package/src/main/lib/__tests__/delegationReadiness.test.cjs +302 -4
  58. package/src/main/lib/__tests__/fixtures/scheduler-machine.json.corrupt-1789147548 +34 -0
  59. package/src/main/lib/__tests__/gitWorktree.test.cjs +413 -1
  60. package/src/main/lib/__tests__/jobWorktreeBootLive.test.cjs +71 -0
  61. package/src/main/lib/__tests__/queueStoreAtomicWrite.test.cjs +88 -0
  62. package/src/main/lib/__tests__/queueStoreMachineStateRecovery.test.cjs +123 -0
  63. package/src/main/lib/__tests__/reaperHelpers.test.cjs +58 -0
  64. package/src/main/lib/__tests__/telemetryBacklog.test.cjs +620 -0
  65. package/src/main/lib/__tests__/telemetryBoot.test.cjs +125 -0
  66. package/src/main/lib/__tests__/telemetryConsent.test.cjs +130 -0
  67. package/src/main/lib/__tests__/telemetryCounters.test.cjs +57 -0
  68. package/src/main/lib/__tests__/telemetryCountersMetadataColumn.test.cjs +89 -0
  69. package/src/main/lib/crashTelemetry.cjs +37 -0
  70. package/src/main/lib/delegationReadiness.cjs +119 -1
  71. package/src/main/lib/epicMint.cjs +2 -0
  72. package/src/main/lib/gitWorktree.cjs +427 -17
  73. package/src/main/lib/jobWorktree.cjs +2 -1
  74. package/src/main/lib/jobWorktreeBootLive.cjs +51 -0
  75. package/src/main/lib/jobWorktreeTerminalOrphanLive.cjs +68 -0
  76. package/src/main/lib/opsErrorLog.cjs +78 -25
  77. package/src/main/lib/queueStore.cjs +233 -20
  78. package/src/main/lib/reaperHelpers.cjs +23 -1
  79. package/src/main/lib/scheduleJobSchema.cjs +7 -0
  80. package/src/main/lib/scheduleJobTransitions.cjs +12 -0
  81. package/src/main/lib/telemetryBacklog.cjs +601 -0
  82. package/src/main/lib/telemetryBoot.cjs +71 -0
  83. package/src/main/lib/telemetryClient.cjs +31 -0
  84. package/src/main/lib/telemetryConsent.cjs +34 -0
  85. package/src/main/lib/telemetryCounters.cjs +45 -0
  86. package/src/main/promptSessionTranscript.cjs +0 -0
  87. package/src/main/pty.cjs +2 -0
  88. package/src/main/scheduler.cjs +481 -33
  89. package/src/preload/api.d.ts +84 -4
  90. package/src/preload/index.cjs +9 -0
  91. package/dist/assets/Settings-DXVgKLUx.js +0 -3
  92. package/dist/assets/SystemPrompt-BjqFOHSk.js +0 -1
@@ -0,0 +1,514 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * guard-destructive-git.cjs — PreToolUse hook that denies a `Bash` tool call
4
+ * whose command would run a destructive git operation (stash/reset --hard/
5
+ * checkout --/restore/clean -f|-d|-x/add -A|./--all/commit -a) against a
6
+ * SHARED working tree.
7
+ *
8
+ * Why this exists: standards.md tells an executor never to stash/reset/clean
9
+ * a tree it shares with other in-flight jobs or a human's own WIP, but prose
10
+ * is something a model under pressure can fail to weigh. Two real incidents
11
+ * on 2026-09-01 happened with that rule already written down: live trading
12
+ * config stashed and never restored (social-signals-trader), and ~1,400 lines
13
+ * across four PRDs stashed plus three files deleted from disk (starry-night-
14
+ * ships). A PreToolUse hook is the one lever that sits OUTSIDE the model and
15
+ * can refuse the call before it ever reaches git — see guard-prd-writes.cjs's
16
+ * header for the fuller argument for why a hook, not a rule, is the fix.
17
+ *
18
+ * ── Scope: shared trees only ─────────────────────────────────────────────
19
+ * A scheduler job or Epic running inside its OWN linked worktree
20
+ * (`sm-job/<slug>` / `sm-epic/<epicId>`, minted by src/main/lib/gitWorktree.cjs)
21
+ * owns that checkout exclusively — destroying it harms nobody, so every one
22
+ * of the operations above is permitted there. This hook only denies when the
23
+ * command's cwd is the shared base tree (or any other tree not recognized as
24
+ * one of ours). Detection intentionally duplicates just gitWorktree.cjs's two
25
+ * branch-prefix constants and its worktree-root path shape (see
26
+ * WORKTREE_ROOTS/BRANCH_PREFIXES below) rather than requiring that module —
27
+ * this script runs standalone, outside the app's process, and must not pull
28
+ * the whole main-process require graph into a hook invoked on every Bash call.
29
+ * src/main/lib/gitWorktree.cjs stays the source of truth for those constants;
30
+ * if it ever renames the prefixes or root folder names, update them here too.
31
+ *
32
+ * ── Fail-closed on ambiguous parsing ─────────────────────────────────────
33
+ * Command strings can hide a destructive git call inside `sh -c '...'`,
34
+ * chained with `&&`/`;`/`|`/newlines, or preceded by env assignments / `git -C
35
+ * <dir>`. This hook parses those shapes explicitly. When a segment cannot be
36
+ * confidently tokenized (e.g. an unterminated quote) AND it textually mentions
37
+ * `git`, the DEFAULT is to DENY, not allow — the inverse of guard-prd-writes.cjs's
38
+ * fail-OPEN contract. That asymmetry is deliberate: guard-prd-writes guards a
39
+ * narrow, low-consequence path (a hand-written PRD file) where a false deny
40
+ * merely inconveniences an executor that has a real MCP tool alternative
41
+ * anyway; this hook guards irreversible loss of a human's or a sibling job's
42
+ * uncommitted work, where a false ALLOW is the one outcome with no undo. Any
43
+ * OTHER internal error (malformed stdin JSON, an unexpected payload shape)
44
+ * still fails open — a broken guard must never brick the whole Bash tool.
45
+ *
46
+ * ── Install (per-project, NOT machine-wide) ─────────────────────────────
47
+ * Add to this project's `.claude/settings.json` (not `~/.claude/settings.json`
48
+ * — project scope only, so the guard applies to sessions run against THIS
49
+ * repo, not every project on the machine):
50
+ *
51
+ * {
52
+ * "hooks": {
53
+ * "PreToolUse": [
54
+ * {
55
+ * "matcher": "Bash",
56
+ * "hooks": [
57
+ * { "type": "command", "command": "node scripts/hooks/guard-destructive-git.cjs" }
58
+ * ]
59
+ * }
60
+ * ]
61
+ * }
62
+ * }
63
+ *
64
+ * ── Adopting this hook in a DIFFERENT (non-session-manager) project ──────
65
+ * DON'T HAND-WRITE THIS. Session Manager installs it: open New Session in
66
+ * that project and press "Fix it" on the readiness banner's destructive-git-
67
+ * guard row (src/main/lib/delegationReadiness.cjs's installDestructiveGitGuard,
68
+ * which merges into any existing hooks block instead of clobbering it).
69
+ *
70
+ * The standardized approach is REFERENCE, not vendor — do NOT copy this file
71
+ * into the adopting repo. Vendoring drifts (guard-prd-writes.cjs already saw
72
+ * this happen with its one adopter), and the enforcement logic belongs with
73
+ * the owner (this repo), not forked into every consumer.
74
+ *
75
+ * This file lives in the session-manager repo, so a relative
76
+ * `node scripts/hooks/guard-destructive-git.cjs` command only resolves when
77
+ * the hook runs with THIS repo as cwd. Pasting that relative string into
78
+ * another project yields a hook that exits non-zero WITHOUT code 2 — a
79
+ * non-blocking error — so it silently guards nothing. Any other project's
80
+ * `.claude/settings.json` must reference this file by its ABSOLUTE path:
81
+ *
82
+ * {
83
+ * "hooks": {
84
+ * "PreToolUse": [
85
+ * {
86
+ * "matcher": "Bash",
87
+ * "hooks": [
88
+ * {
89
+ * "type": "command",
90
+ * "command": "node /home/bilko/Projects/session-manager/scripts/hooks/guard-destructive-git.cjs"
91
+ * }
92
+ * ]
93
+ * }
94
+ * ]
95
+ * }
96
+ * }
97
+ *
98
+ * This is still opt-in per project, same as above — never auto-install this
99
+ * into every project's settings without the human choosing to add it there.
100
+ *
101
+ * ── Uninstall ────────────────────────────────────────────────────────────
102
+ * Remove the `PreToolUse` entry above from `.claude/settings.json`. The
103
+ * script is inert with no settings.json entry pointing at it — deleting the
104
+ * file itself is optional and has no other effect.
105
+ *
106
+ * ── Contract ─────────────────────────────────────────────────────────────
107
+ * - Reads a PreToolUse payload on stdin: { tool_name, tool_input, cwd, ... }.
108
+ * - Only inspects `Bash` calls; every other tool_name is allowed by
109
+ * construction (the matcher already scopes this hook to Bash, but the
110
+ * tool_name check below is a second, defense-in-depth gate in case the
111
+ * matcher is ever widened).
112
+ * - Read-only git (`status`, `diff`, `log`, `show`, `stash list`, `stash
113
+ * show`, `rev-parse`, plain `git add <path>`, `git commit -m ...`) is
114
+ * never touched by the rules below and always passes through.
115
+ */
116
+ 'use strict';
117
+
118
+ const path = require('node:path');
119
+ const os = require('node:os');
120
+ const { execFileSync } = require('node:child_process');
121
+
122
+ // Duplicated from src/main/lib/gitWorktree.cjs's KIND_CONFIG — see this
123
+ // file's header for why this is a deliberate duplication, not a require.
124
+ const WORKTREE_ROOTS = [
125
+ path.join(os.tmpdir(), 'session-manager-job-worktrees'),
126
+ path.join(os.tmpdir(), 'session-manager-epic-worktrees'),
127
+ ];
128
+ const BRANCH_PREFIXES = ['sm-job/', 'sm-epic/'];
129
+
130
+ const MAX_SH_C_DEPTH = 5;
131
+
132
+ function readStdin() {
133
+ return new Promise((resolve) => {
134
+ let data = '';
135
+ process.stdin.setEncoding('utf8');
136
+ process.stdin.on('data', (chunk) => { data += chunk; });
137
+ process.stdin.on('end', () => resolve(data));
138
+ // Fail open if stdin never closes/errors for some reason.
139
+ process.stdin.on('error', () => resolve(data));
140
+ });
141
+ }
142
+
143
+ /** Quote-aware split on top-level `&&`, `||`, `;`, `|`, and newlines. */
144
+ function splitTopLevel(cmd) {
145
+ const parts = [];
146
+ let cur = '';
147
+ let inSingle = false;
148
+ let inDouble = false;
149
+ for (let i = 0; i < cmd.length; i++) {
150
+ const c = cmd[i];
151
+ if (inSingle) {
152
+ cur += c;
153
+ if (c === "'") inSingle = false;
154
+ continue;
155
+ }
156
+ if (inDouble) {
157
+ cur += c;
158
+ if (c === '"' && cmd[i - 1] !== '\\') inDouble = false;
159
+ continue;
160
+ }
161
+ if (c === "'") { inSingle = true; cur += c; continue; }
162
+ if (c === '"') { inDouble = true; cur += c; continue; }
163
+ const two = cmd.slice(i, i + 2);
164
+ if (two === '&&' || two === '||') { parts.push(cur); cur = ''; i++; continue; }
165
+ if (c === ';' || c === '|' || c === '\n') { parts.push(cur); cur = ''; continue; }
166
+ cur += c;
167
+ }
168
+ parts.push(cur);
169
+ return { parts: parts.map((s) => s.trim()).filter(Boolean), unterminatedQuote: inSingle || inDouble };
170
+ }
171
+
172
+ /** Quote-aware whitespace tokenizer. Returns null on an unterminated quote. */
173
+ function tokenize(str) {
174
+ const tokens = [];
175
+ let cur = '';
176
+ let started = false;
177
+ let inSingle = false;
178
+ let inDouble = false;
179
+ for (let i = 0; i < str.length; i++) {
180
+ const c = str[i];
181
+ if (inSingle) {
182
+ if (c === "'") inSingle = false; else cur += c;
183
+ started = true;
184
+ continue;
185
+ }
186
+ if (inDouble) {
187
+ if (c === '"') inDouble = false; else cur += c;
188
+ started = true;
189
+ continue;
190
+ }
191
+ if (c === "'") { inSingle = true; started = true; continue; }
192
+ if (c === '"') { inDouble = true; started = true; continue; }
193
+ if (/\s/.test(c)) {
194
+ if (started) { tokens.push(cur); cur = ''; started = false; }
195
+ continue;
196
+ }
197
+ cur += c;
198
+ started = true;
199
+ }
200
+ if (started) tokens.push(cur);
201
+ if (inSingle || inDouble) return null;
202
+ return tokens;
203
+ }
204
+
205
+ function isFlagWithChar(rest, longForms, shortChar) {
206
+ for (const t of rest) {
207
+ if (longForms.includes(t)) return true;
208
+ if (shortChar && t.startsWith('-') && !t.startsWith('--') && t.includes(shortChar)) return true;
209
+ }
210
+ return false;
211
+ }
212
+
213
+ /** Skip git's global options (`-C <dir>`, `-c <k>=<v>`, `--git-dir=...`, ...) to find the subcommand. */
214
+ function subcommandOf(args) {
215
+ const OPTS_WITH_SEPARATE_ARG = new Set(['-C', '-c', '--namespace', '--super-prefix']);
216
+ let i = 0;
217
+ while (i < args.length) {
218
+ const t = args[i];
219
+ if (OPTS_WITH_SEPARATE_ARG.has(t)) { i += 2; continue; }
220
+ if (t.startsWith('-')) { i += 1; continue; }
221
+ return { subcommand: t, rest: args.slice(i + 1) };
222
+ }
223
+ return null;
224
+ }
225
+
226
+ /** Extract an explicit `git -C <dir>` argument (or `git -C=<dir>`), if present, from the tokens AFTER `git`. */
227
+ function gitDashCDirOf(args) {
228
+ for (let i = 0; i < args.length; i++) {
229
+ const t = args[i];
230
+ if (t === '-C' && args[i + 1] != null) return args[i + 1];
231
+ if (t.startsWith('-C') && t.length > 2) return t.slice(2);
232
+ if (!t.startsWith('-')) break; // reached the subcommand — no more global opts
233
+ }
234
+ return null;
235
+ }
236
+
237
+ /**
238
+ * Text-level check for whether an UNPARSABLE segment mentions a verb this
239
+ * guard actually polices. Used only to narrow the unparsable-command fallback
240
+ * — an unparsable command that mentions none of these is allowed through
241
+ * rather than blanket-blocked, since blocking every command merely containing
242
+ * the substring "git" (e.g. a heredoc `git commit -m "$(cat <<'EOF' ...)"`)
243
+ * was itself a false-positive incident.
244
+ */
245
+ function mentionsPolicedDestructiveToken(text) {
246
+ if (/\b(stash|reset|checkout|restore|clean)\b/.test(text)) return true;
247
+ if (/\badd\s+(-A\b|--all\b|\.(?:\s|$))/.test(text)) return true;
248
+ if (/\bcommit\b.*(-a\b|--all\b)/.test(text)) return true;
249
+ return false;
250
+ }
251
+
252
+ /**
253
+ * Evaluate one `git <...>` invocation (args = tokens AFTER `git`). Returns
254
+ * `{ verb, alt }` when destructive, or null when this invocation is fine
255
+ * (read-only, or a form this guard doesn't restrict).
256
+ */
257
+ function evaluateGit(args) {
258
+ const parsed = subcommandOf(args);
259
+ if (!parsed) return null;
260
+ const { subcommand, rest } = parsed;
261
+
262
+ switch (subcommand) {
263
+ case 'stash': {
264
+ const sub = rest.find((a) => !a.startsWith('-'));
265
+ if (sub === 'list' || sub === 'show') return null;
266
+ return {
267
+ verb: 'git stash',
268
+ alt: 'do not stash on a shared tree — a stash you never restore silently strands someone else\'s work; commit or discard only paths you created, or stop and report',
269
+ };
270
+ }
271
+ case 'reset': {
272
+ const hasHard = rest.includes('--hard');
273
+ const positional = rest.filter((a) => !a.startsWith('-'));
274
+ if (hasHard) {
275
+ return {
276
+ verb: 'git reset --hard',
277
+ alt: 'never hard-reset a shared tree — this discards a sibling job\'s or the human\'s uncommitted work outright; undo only your own change with `git checkout -- <your-path>` inside your own sm-job/ or sm-epic/ worktree, or stop and report',
278
+ };
279
+ }
280
+ if (positional.length > 0) {
281
+ return {
282
+ verb: 'git reset <path>',
283
+ alt: 'do not `git reset` a path on a shared tree — that can unstage a sibling job\'s or the human\'s in-progress staging, not just your own',
284
+ };
285
+ }
286
+ return null;
287
+ }
288
+ case 'checkout': {
289
+ const dashIdx = rest.indexOf('--');
290
+ if (dashIdx !== -1 && rest.slice(dashIdx + 1).length > 0) {
291
+ return {
292
+ verb: 'git checkout -- <path>',
293
+ alt: 'never discard tracked-file edits on a shared tree with `checkout --` — that can erase a sibling job\'s or the human\'s uncommitted work; scope changes to files you created, or stop and report',
294
+ };
295
+ }
296
+ // `.` is never a valid branch/commit name, so `git checkout .` (no
297
+ // `--` at all) is unambiguously the pathspec form — and, like
298
+ // `checkout -- .`, discards every dirty tracked file in the tree. Git
299
+ // accepts this bare form; requiring `--` before flagging it let this
300
+ // exact incident shape (real precedent: `starry-night-ships`, see this
301
+ // file's header) walk straight past the guard.
302
+ const positional = rest.filter((a) => a !== '--' && !a.startsWith('-'));
303
+ if (positional.includes('.')) {
304
+ return {
305
+ verb: 'git checkout .',
306
+ alt: 'never discard tracked-file edits on a shared tree with `checkout .` — that can erase a sibling job\'s or the human\'s uncommitted work; scope changes to files you created, or stop and report',
307
+ };
308
+ }
309
+ return null;
310
+ }
311
+ case 'restore': {
312
+ if (rest.includes('-n') || rest.includes('--dry-run')) return null;
313
+ const positional = rest.filter((a) => !a.startsWith('-'));
314
+ if (positional.length > 0) {
315
+ return {
316
+ verb: 'git restore',
317
+ alt: 'never `git restore` tracked paths on a shared tree — that discards whatever is currently dirty there, which may not be yours',
318
+ };
319
+ }
320
+ return null;
321
+ }
322
+ case 'clean': {
323
+ if (isFlagWithChar(rest, ['--dry-run'], 'n')) return null;
324
+ const hasF = isFlagWithChar(rest, ['--force'], 'f');
325
+ const hasD = isFlagWithChar(rest, [], 'd');
326
+ const hasX = isFlagWithChar(rest, [], 'x');
327
+ if (hasF || hasD || hasX) {
328
+ return {
329
+ verb: 'git clean -f/-d/-x',
330
+ alt: 'never force-clean a shared tree — that deletes files git doesn\'t track, which may be a sibling job\'s scratch output or the human\'s untracked WIP',
331
+ };
332
+ }
333
+ return null;
334
+ }
335
+ case 'add': {
336
+ const hasBlanket = rest.some((a) => a === '-A' || a === '--all' || a === '.');
337
+ if (hasBlanket) {
338
+ return {
339
+ verb: 'git add -A/./--all',
340
+ alt: 'stage only the exact paths you created or modified — `git add <path> [<path>...]` — a blanket add on a shared tree captures a sibling job\'s or the human\'s in-progress edits and mis-attributes them to your commit',
341
+ };
342
+ }
343
+ return null;
344
+ }
345
+ case 'commit': {
346
+ const hasAll = isFlagWithChar(rest, ['--all'], 'a');
347
+ if (hasAll) {
348
+ return {
349
+ verb: 'git commit -a',
350
+ alt: 'commit only the paths you explicitly `git add`ed — `git commit -a`/`--all` on a shared tree sweeps up whatever a sibling job or the human currently has dirty',
351
+ };
352
+ }
353
+ return null;
354
+ }
355
+ default:
356
+ return null;
357
+ }
358
+ }
359
+
360
+ const SH_WRAPPERS = new Set(['sh', 'bash', 'zsh', 'dash']);
361
+
362
+ /**
363
+ * Returns the first destructive verdict found anywhere in `command`, or null.
364
+ * `baseCwd` is the shell's starting cwd; a `cd <dir>` segment updates the
365
+ * EFFECTIVE cwd for every subsequent segment in the same top-level command
366
+ * (segments share one shell), and an explicit `git -C <dir>` overrides it for
367
+ * that one invocation only. The returned verdict carries the effective cwd at
368
+ * the point the destructive call was found, so the caller checks exemption
369
+ * against where the command actually runs, not just `payload.cwd`.
370
+ */
371
+ function findDestructiveVerdict(command, baseCwd, depth = 0) {
372
+ if (depth > MAX_SH_C_DEPTH) return null;
373
+ const { parts, unterminatedQuote } = splitTopLevel(command);
374
+ if (unterminatedQuote) {
375
+ if (mentionsPolicedDestructiveToken(command)) {
376
+ return { verb: 'an unparsable command mentioning git', alt: 'this hook could not confidently parse this command\'s quoting to confirm it is safe — split it into a plain, simply-quoted git invocation, or stop and report', cwd: baseCwd };
377
+ }
378
+ return null;
379
+ }
380
+
381
+ let effectiveCwd = baseCwd;
382
+
383
+ for (const segment of parts) {
384
+ const tokens = tokenize(segment);
385
+ if (tokens === null) {
386
+ if (mentionsPolicedDestructiveToken(segment)) {
387
+ return { verb: 'an unparsable command mentioning git', alt: 'this hook could not confidently parse this command\'s quoting to confirm it is safe — split it into a plain, simply-quoted git invocation, or stop and report', cwd: effectiveCwd };
388
+ }
389
+ continue;
390
+ }
391
+ if (tokens.length === 0) continue;
392
+
393
+ // Strip leading env assignments (FOO=bar git ...).
394
+ let i = 0;
395
+ while (i < tokens.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[i])) i++;
396
+ const cmdTokens = tokens.slice(i);
397
+ if (cmdTokens.length === 0) continue;
398
+
399
+ const head = path.basename(cmdTokens[0]);
400
+
401
+ if (head === 'cd' && cmdTokens[1] != null) {
402
+ const target = cmdTokens[1];
403
+ effectiveCwd = path.isAbsolute(target) ? target : path.resolve(effectiveCwd || process.cwd(), target);
404
+ continue;
405
+ }
406
+
407
+ if (SH_WRAPPERS.has(head)) {
408
+ const cIdx = cmdTokens.indexOf('-c');
409
+ if (cIdx !== -1 && cmdTokens[cIdx + 1] != null) {
410
+ const nested = findDestructiveVerdict(cmdTokens[cIdx + 1], effectiveCwd, depth + 1);
411
+ if (nested) return nested;
412
+ }
413
+ continue;
414
+ }
415
+
416
+ if (head === 'git') {
417
+ const gitArgs = cmdTokens.slice(1);
418
+ const verdict = evaluateGit(gitArgs);
419
+ if (verdict) {
420
+ const dashC = gitDashCDirOf(gitArgs);
421
+ const invocationCwd = dashC
422
+ ? (path.isAbsolute(dashC) ? dashC : path.resolve(effectiveCwd || process.cwd(), dashC))
423
+ : effectiveCwd;
424
+ return { ...verdict, cwd: invocationCwd };
425
+ }
426
+ }
427
+ }
428
+ return null;
429
+ }
430
+
431
+ function isInsideManagedWorktreeByPath(cwd) {
432
+ if (!cwd) return false;
433
+ const resolved = path.resolve(cwd);
434
+ return WORKTREE_ROOTS.some((root) => resolved === root || resolved.startsWith(root + path.sep));
435
+ }
436
+
437
+ function isInsideManagedWorktreeByBranch(cwd) {
438
+ if (!cwd) return false;
439
+ try {
440
+ const branch = execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
441
+ cwd,
442
+ timeout: 3_000,
443
+ encoding: 'utf8',
444
+ windowsHide: true,
445
+ stdio: ['ignore', 'pipe', 'ignore'],
446
+ }).trim();
447
+ return BRANCH_PREFIXES.some((p) => branch.startsWith(p));
448
+ } catch {
449
+ return false;
450
+ }
451
+ }
452
+
453
+ function isExemptSharedTree(cwd) {
454
+ return isInsideManagedWorktreeByPath(cwd) || isInsideManagedWorktreeByBranch(cwd);
455
+ }
456
+
457
+ function allow() {
458
+ process.stdout.write(JSON.stringify({ continue: true }));
459
+ process.exit(0);
460
+ }
461
+
462
+ function deny(reason) {
463
+ process.stdout.write(JSON.stringify({
464
+ continue: true,
465
+ decision: 'block',
466
+ reason,
467
+ hookSpecificOutput: {
468
+ hookEventName: 'PreToolUse',
469
+ permissionDecision: 'deny',
470
+ permissionDecisionReason: reason,
471
+ },
472
+ }));
473
+ process.exit(0);
474
+ }
475
+
476
+ async function main() {
477
+ const raw = await readStdin();
478
+ let payload;
479
+ try {
480
+ payload = raw ? JSON.parse(raw) : {};
481
+ } catch (e) {
482
+ console.error(`[guard-destructive-git] malformed stdin JSON, failing open: ${e?.message}`);
483
+ return allow();
484
+ }
485
+
486
+ try {
487
+ if (payload.tool_name !== 'Bash') return allow();
488
+
489
+ const command = payload?.tool_input?.command;
490
+ if (!command || typeof command !== 'string') return allow();
491
+
492
+ const cwd = typeof payload.cwd === 'string' && payload.cwd ? payload.cwd : process.cwd();
493
+ const verdict = findDestructiveVerdict(command, cwd);
494
+ if (!verdict) return allow();
495
+
496
+ const effectiveCwd = verdict.cwd || cwd;
497
+ if (isExemptSharedTree(effectiveCwd)) return allow();
498
+
499
+ const reason = [
500
+ `Blocked: \`${verdict.verb}\` in what this hook believes is a SHARED working tree (${effectiveCwd}).`,
501
+ verdict.alt,
502
+ 'You are normally inside your own sm-job/<slug> or sm-epic/<epicId> worktree for this kind of operation — run `git rev-parse --git-common-dir` to check; this guard permits the same command there.',
503
+ ].join(' ');
504
+ return deny(reason);
505
+ } catch (e) {
506
+ console.error(`[guard-destructive-git] internal error, failing open: ${e?.message}`);
507
+ return allow();
508
+ }
509
+ }
510
+
511
+ main().catch((e) => {
512
+ console.error(`[guard-destructive-git] unhandled error, failing open: ${e?.message}`);
513
+ allow();
514
+ });