supermind-claude 2.1.0 → 4.0.1

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 (44) hide show
  1. package/.claude-plugin/plugin.json +21 -0
  2. package/README.md +34 -46
  3. package/agents/code-reviewer.md +81 -0
  4. package/cli/commands/doctor.js +415 -79
  5. package/cli/commands/install.js +17 -18
  6. package/cli/commands/skill.js +164 -0
  7. package/cli/commands/uninstall.js +32 -3
  8. package/cli/commands/update.js +27 -5
  9. package/cli/index.js +16 -4
  10. package/cli/lib/agents.js +413 -0
  11. package/cli/lib/executor.js +365 -0
  12. package/cli/lib/hooks.js +8 -1
  13. package/cli/lib/logger.js +1 -1
  14. package/cli/lib/mcp.js +25 -5
  15. package/cli/lib/planning.js +502 -0
  16. package/cli/lib/platform.js +4 -0
  17. package/cli/lib/plugin.js +127 -0
  18. package/cli/lib/settings.js +2 -40
  19. package/cli/lib/skills.js +39 -2
  20. package/cli/lib/templates.js +48 -1
  21. package/cli/lib/vendor-skills.js +594 -0
  22. package/hooks/bash-permissions.js +196 -176
  23. package/hooks/context-monitor.js +79 -0
  24. package/hooks/improvement-logger.js +94 -0
  25. package/hooks/pre-merge-checklist.js +102 -0
  26. package/hooks/session-start.js +109 -5
  27. package/hooks/statusline-command.js +123 -29
  28. package/package.json +4 -2
  29. package/skills/anti-rationalization/SKILL.md +38 -0
  30. package/skills/brainstorming/SKILL.md +165 -0
  31. package/skills/code-review/SKILL.md +144 -0
  32. package/skills/executing-plans/SKILL.md +138 -0
  33. package/skills/finishing-branches/SKILL.md +144 -0
  34. package/skills/project/SKILL.md +533 -0
  35. package/skills/quick/SKILL.md +178 -0
  36. package/skills/supermind/SKILL.md +58 -4
  37. package/skills/supermind-init/SKILL.md +48 -2
  38. package/skills/systematic-debugging/SKILL.md +129 -0
  39. package/skills/tdd/SKILL.md +179 -0
  40. package/skills/using-git-worktrees/SKILL.md +138 -0
  41. package/skills/verification-before-completion/SKILL.md +54 -0
  42. package/skills/writing-plans/SKILL.md +169 -0
  43. package/templates/CLAUDE.md +124 -61
  44. package/cli/lib/plugins.js +0 -23
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
- // PreToolUse hook for Bash — classifies commands as safe or needing approval.
2
+ // PreToolUse hook for Bash — blocklist-based command classification.
3
+ // Default: everything auto-approved. Only explicitly dangerous commands require approval.
3
4
  // Splits compound commands on && || ; and checks each segment (including pipes).
4
5
  // Returns permissionDecision: "allow" | "ask"
5
6
 
@@ -12,6 +13,7 @@ const os = require('os');
12
13
  // that the user has explicitly approved for auto-allow.
13
14
 
14
15
  const APPROVED_FILE = path.join(os.homedir(), '.claude', 'supermind-approved.json');
16
+ const SAFETY_LOG_FILE = path.join(os.homedir(), '.claude', 'safety-log.jsonl');
15
17
 
16
18
  function loadApprovedCommands() {
17
19
  try {
@@ -37,11 +39,8 @@ function isUserApproved(cmd) {
37
39
  const approved = getApprovedCommands();
38
40
  const trimmed = cmd.trim();
39
41
  for (const pattern of approved) {
40
- // Exact match
41
42
  if (trimmed === pattern) return true;
42
- // Prefix match (e.g., "git push" approves "git push origin main")
43
43
  if (trimmed.startsWith(pattern + ' ') || trimmed.startsWith(pattern + '\t')) return true;
44
- // Regex match (patterns starting with / are treated as regex)
45
44
  if (pattern.startsWith('/') && pattern.endsWith('/')) {
46
45
  try {
47
46
  if (new RegExp(pattern.slice(1, -1)).test(trimmed)) return true;
@@ -53,95 +52,108 @@ function isUserApproved(cmd) {
53
52
  return false;
54
53
  }
55
54
 
56
- // ─── Constants ───────────────────────────────────────────────────────────────
57
-
58
- // Filesystem + shell read-only commands (match by first word or first two words)
59
- const SAFE_READ_COMMANDS = [
60
- // Filesystem read-only
61
- "ls", "cat", "head", "tail", "find", "tree", "du", "df", "wc",
62
- "file", "which", "type", "readlink", "realpath", "stat", "test",
63
- // Shell builtins / safe
64
- "echo", "printf", "pwd", "true", "false", "set", "export",
65
- "cd", "pushd", "popd", "source", ".",
66
- // Text processing (read-only)
67
- "grep", "rg", "awk", "sort", "uniq", "cut", "tr",
68
- "diff", "comm", "paste", "column", "fmt", "fold", "rev",
69
- "jq", "yq",
70
- // System info
71
- "uname", "whoami", "hostname", "date", "env", "printenv",
72
- "nproc", "free", "uptime", "id",
73
- // Encoding utilities
74
- "base64",
75
- // Claude CLI management (config, MCP, plugin operations)
76
- "claude config", "claude mcp", "claude plugin",
77
- "claude --version", "claude -v",
78
- // Node/npm/npx (two-word matches)
79
- "node -e", "node -p", "npm ls", "npm list", "npm view", "npm info",
80
- "npx which", "npx tsc", "npx eslint", "npx prettier", "npx vitest",
81
- "npx jest", "npx tsx", "npx ts-node",
82
- ];
55
+ // ─── Gate override logging ──────────────────────────────────────────────────
56
+ // Logs every blocked command for safety audit trail.
83
57
 
84
- // Prefix-matched safe syntax (bash conditionals)
85
- const SAFE_PREFIXES = [
86
- "[[ ", // bash conditional
87
- "[ ", // test
88
- ];
58
+ function logBlockedCommand(command, reason) {
59
+ try {
60
+ const entry = {
61
+ timestamp: new Date().toISOString(),
62
+ command: command.length > 500 ? command.slice(0, 500) + '...' : command,
63
+ reason,
64
+ cwd: process.cwd(),
65
+ };
66
+ fs.appendFileSync(SAFETY_LOG_FILE, JSON.stringify(entry) + '\n');
67
+ } catch (_) {
68
+ // Non-critical — don't block on logging failure
69
+ }
70
+ }
71
+
72
+ // ─── Git global flag stripping ───────────────────────────────────────────────
73
+
74
+ function stripGitGlobalFlags(gitArgs) {
75
+ let args = gitArgs;
76
+ args = args.replace(/^(-C\s+"[^"]*"|-C\s+'[^']*'|-C\s+\S+)\s*/g, "");
77
+ args = args.replace(/^(-c\s+"[^"]*"|-c\s+'[^']*'|-c\s+\S+)\s*/g, "");
78
+ args = args.replace(/^(--git-dir[= ]\S+)\s*/g, "");
79
+ args = args.replace(/^(--work-tree[= ]\S+)\s*/g, "");
80
+ args = args.replace(/^(--no-pager|--bare|--no-replace-objects|--literal-pathspecs|--no-optional-locks)\s*/g, "");
81
+ if (args !== gitArgs) return stripGitGlobalFlags(args);
82
+ return args;
83
+ }
84
+
85
+ // ─── Blocklist: Dangerous flags ─────────────────────────────────────────────
89
86
 
90
- // Safe write commands (mkdir, touch, etc.) — still checked against DANGEROUS_PATTERNS
91
- const SAFE_WRITE_COMMANDS = [
92
- "mkdir", "touch", "cp", "mv", "tee", "xargs",
87
+ const DANGEROUS_FLAGS = [
88
+ /--force(?![\w-])/,
89
+ /--hard(?![\w-])/,
93
90
  ];
94
91
 
95
- // ─── Git classification lists ────────────────────────────────────────────────
92
+ // ─── Blocklist: Filesystem destructive ──────────────────────────────────────
96
93
 
97
- // Git read-only subcommands (always auto-approved)
98
- const GIT_SAFE_READ = [
99
- "status", "diff", "log", "show", "blame", "rev-parse", "symbolic-ref",
100
- "remote", "ls-files", "shortlog", "tag -l", "tag --list", "config --get",
101
- "config --list", "check-ignore", "rev-list", "name-rev", "describe",
102
- "for-each-ref", "cat-file", "ls-tree", "verify-commit", "branch -a",
103
- "branch -r", "branch --list", "branch -l", "branch -v",
94
+ const FILESYSTEM_BLOCKED = [
95
+ /^rm\b/,
96
+ /^rmdir\b/,
97
+ /^del\b/,
104
98
  ];
105
99
 
106
- // Git non-destructive write subcommands (always auto-approved)
107
- const GIT_SAFE_WRITE = [
108
- "add", "commit", "stash push", "stash save", "stash list", "stash show",
109
- "worktree add", "worktree list",
110
- "branch -m",
100
+ // ─── Blocklist: Process termination ─────────────────────────────────────────
101
+
102
+ const PROCESS_BLOCKED = [
103
+ /^kill\b/,
104
+ /^killall\b/,
105
+ /^pkill\b/,
111
106
  ];
112
107
 
113
- // Git stash subcommands that destroy stash entries
114
- const GIT_STASH_DESTRUCTIVE = [
115
- "stash drop", "stash pop", "stash clear",
108
+ // ─── Blocklist: Publishing / deployment ─────────────────────────────────────
109
+
110
+ const PUBLISH_BLOCKED = [
111
+ /^npm\s+publish\b/,
112
+ /^docker\s+push\b/,
116
113
  ];
117
114
 
118
- // Git commands auto-approved ONLY inside a worktree directory
119
- const GIT_WORKTREE_ONLY = [
120
- "worktree remove", "worktree prune", "merge", "branch -d",
115
+ // ─── Blocklist: Database CLIs with destructive SQL ──────────────────────────
116
+
117
+ const DB_CLI_PATTERNS = [
118
+ /^(psql|mysql|mongo|mongosh|redis-cli)\b/,
121
119
  ];
122
120
 
123
- // Git commands that always require human approval
124
- const GIT_DANGEROUS = [
125
- "push", "pull", "fetch", "reset", "revert", "rebase", "clean",
126
- "checkout -- ", "checkout .", "restore",
127
- "branch -D",
121
+ const DB_DESTRUCTIVE_SQL = [
122
+ /\bDROP\b/i,
123
+ /\bDELETE\s+FROM\b/i,
124
+ /\bTRUNCATE\b/i,
125
+ /\bALTER\s+TABLE\b/i,
128
126
  ];
129
127
 
130
- // ─── Dangerous patterns (checked across all command types) ───────────────────
128
+ // ─── Blocklist: curl/wget with mutating HTTP methods ────────────────────────
131
129
 
132
- const DANGEROUS_PATTERNS = [
133
- /--force/,
134
- /--hard/,
135
- /-rf\s/,
136
- /\brm\b/,
137
- /\brmdir\b/,
138
- /\bdel\b/,
139
- // Note: redirects to /dev/null are safe and handled by allowing the base command
130
+ const HTTP_MUTATING = [
131
+ /\bcurl\b.*\s(-X\s*(POST|PUT|PATCH|DELETE)|--request\s*(POST|PUT|PATCH|DELETE))/,
132
+ /\bcurl\b.*\s(-d[\s=]|--data[\s=]|--data-raw[\s=]|--data-binary[\s=]|--data-urlencode[\s=]|-F[\s=]|--form[\s=])/,
133
+ /\bwget\b.*\s--method=(POST|PUT|PATCH|DELETE)/,
134
+ /\bwget\b.*\s--post-data\b/,
135
+ /\bwget\b.*\s--post-file\b/,
140
136
  ];
141
137
 
142
- // ─── GitHub CLI patterns ─────────────────────────────────────────────────────
138
+ // ─── Blocklist: Git commands ────────────────────────────────────────────────
139
+
140
+ const GIT_BLOCKED = [
141
+ /^reset\b/,
142
+ /^clean\b/,
143
+ /^rebase\b/,
144
+ /^revert\b/,
145
+ /^checkout\s+--\s/,
146
+ /^checkout\s+\.\s*$/,
147
+ /^restore\b/,
148
+ /^branch\s+-D\b/,
149
+ /^stash\s+drop\b/,
150
+ /^stash\s+pop\b/,
151
+ /^stash\s+clear\b/,
152
+ ];
153
+
154
+ // ─── Blocklist: GitHub CLI mutating operations ──────────────────────────────
143
155
 
144
- const GH_DANGEROUS_PATTERNS = [
156
+ const GH_BLOCKED = [
145
157
  /^gh\s+pr\s+merge/,
146
158
  /^gh\s+pr\s+close/,
147
159
  /^gh\s+pr\s+ready/,
@@ -155,142 +167,149 @@ const GH_DANGEROUS_PATTERNS = [
155
167
  /^gh\s+api\s+(\S+\s+)*(-f[\s=]|-f\S|--field[\s=]|--raw-field[\s=]|-F[\s=]|-F\S|--typed-field[\s=]|--input[\s=])/,
156
168
  ];
157
169
 
158
- // ─── Git global flag stripping ───────────────────────────────────────────────
159
- // Strips flags like -C <path>, -c <key=val>, --git-dir, --work-tree, --no-pager
160
- // that appear before the actual git subcommand.
170
+ // ─── Git push classification ────────────────────────────────────────────────
161
171
 
162
- function stripGitGlobalFlags(gitArgs) {
163
- let args = gitArgs;
164
- // Flags that consume the next argument
165
- args = args.replace(/^(-C\s+"[^"]*"|-C\s+'[^']*'|-C\s+\S+)\s*/g, "");
166
- args = args.replace(/^(-c\s+"[^"]*"|-c\s+'[^']*'|-c\s+\S+)\s*/g, "");
167
- args = args.replace(/^(--git-dir[= ]\S+)\s*/g, "");
168
- args = args.replace(/^(--work-tree[= ]\S+)\s*/g, "");
169
- // Standalone flags
170
- args = args.replace(/^(--no-pager|--bare|--no-replace-objects|--literal-pathspecs|--no-optional-locks)\s*/g, "");
171
- // Recurse in case multiple global flags are chained
172
- if (args !== gitArgs) return stripGitGlobalFlags(args);
173
- return args;
174
- }
172
+ function classifyGitPush(gitCmd) {
173
+ if (/--force(?![\w-])/.test(gitCmd)) return "ask";
175
174
 
176
- // ─── Command classifiers ─────────────────────────────────────────────────────
175
+ // Extract positional args (skip flags like -u, --set-upstream, etc.)
176
+ const parts = gitCmd.replace(/^push\s*/, '').split(/\s+/).filter(p => !p.startsWith('-'));
177
+ // parts[0] = remote (or refspec if no remote), parts[1] = refspec
178
+ const remote = parts.length >= 2 ? parts[0] : '';
179
+ const rawRefspec = parts.length >= 2 ? parts[1] : (parts[0] || '');
180
+ // Strip leading + (git force-push shorthand) for branch name matching
181
+ const refspec = rawRefspec.replace(/^\+/, '');
177
182
 
178
- function classifyGhCommand(cmd) {
179
- for (const pattern of GH_DANGEROUS_PATTERNS) {
180
- if (pattern.test(cmd)) return "ask";
181
- }
183
+ // Block push to main/master (as source or destination in refspec)
184
+ if (/^(main|master)(:|$)/.test(refspec)) return "ask";
185
+ if (remote && /^(main|master)$/.test(refspec)) return "ask";
186
+ if (/[:/](main|master)$/.test(refspec)) return "ask";
187
+ // Also block the raw +refspec form (force push shorthand)
188
+ if (/^\+/.test(rawRefspec)) return "ask";
189
+
190
+ // Everything else auto-approved (bare "git push", feature branches, etc.)
182
191
  return "allow";
183
192
  }
184
193
 
185
- function classifyGitCommand(cmd, { inWorktree = false } = {}) {
186
- const gitCmd = stripGitGlobalFlags(cmd.slice(4).trim());
194
+ // ─── Git merge classification ───────────────────────────────────────────────
187
195
 
188
- // Dangerous subcommands take priority
189
- for (const d of GIT_DANGEROUS) {
190
- if (gitCmd.startsWith(d)) return "ask";
191
- }
196
+ function classifyGitMerge(inWorktree) {
197
+ // In worktree context, merge is auto-approved (worktree workflow)
198
+ if (inWorktree) return "allow";
199
+ // Outside worktree, require approval
200
+ return "ask";
201
+ }
192
202
 
193
- // Stash destructive subcommands (drop, pop, clear)
194
- for (const s of GIT_STASH_DESTRUCTIVE) {
195
- if (gitCmd.startsWith(s)) return "ask";
196
- }
203
+ // ─── Command classifiers ─────────────────────────────────────────────────────
197
204
 
198
- // Bare "git stash" (no subcommand) is safe equivalent to stash push
199
- if (/^stash\s*$/.test(gitCmd)) return "allow";
205
+ function classifyGitCommand(cmd, { inWorktree = false } = {}) {
206
+ const gitCmd = stripGitGlobalFlags(cmd.slice(4).trim());
200
207
 
201
- // Dangerous patterns (--force, --hard, rm, etc.)
202
- for (const p of DANGEROUS_PATTERNS) {
203
- if (p.test(cmd)) return "ask";
208
+ // Dangerous flags first (--force, --hard)
209
+ for (const p of DANGEROUS_FLAGS) {
210
+ if (p.test(cmd)) return { decision: "ask", reason: "dangerous flag" };
204
211
  }
205
212
 
206
- // Safe read-only subcommands
207
- for (const r of GIT_SAFE_READ) {
208
- if (gitCmd.startsWith(r)) return "allow";
213
+ // Git push — smart branch-aware classification
214
+ if (/^push\b/.test(gitCmd)) {
215
+ const result = classifyGitPush(gitCmd);
216
+ return { decision: result, reason: result === "ask" ? "push to protected branch or with --force" : null };
209
217
  }
210
218
 
211
- // Safe write subcommands
212
- for (const w of GIT_SAFE_WRITE) {
213
- if (gitCmd.startsWith(w)) return "allow";
219
+ // Git merge — worktree-aware
220
+ if (/^merge\b/.test(gitCmd)) {
221
+ const result = classifyGitMerge(inWorktree);
222
+ return { decision: result, reason: result === "ask" ? "merge outside worktree context" : null };
214
223
  }
215
224
 
216
- // Worktree-only commands auto-approve only inside a worktree dir
217
- for (const w of GIT_WORKTREE_ONLY) {
218
- if (gitCmd.startsWith(w)) return inWorktree ? "allow" : "ask";
225
+ // Worktree-only commands (worktree remove/prune, branch -d)
226
+ if (/^(worktree\s+remove|worktree\s+prune|branch\s+-d)\b/.test(gitCmd)) {
227
+ return inWorktree
228
+ ? { decision: "allow", reason: null }
229
+ : { decision: "ask", reason: "worktree-only command outside worktree" };
219
230
  }
220
231
 
221
- // Bare "git branch <name>" (create branch) safe
222
- if (/^branch\s+[^-]/.test(gitCmd)) return "allow";
223
-
224
- // Unknown git subcommand — ask
225
- return "ask";
226
- }
232
+ // Blocked git subcommands (reset, clean, rebase, revert, etc.)
233
+ for (const p of GIT_BLOCKED) {
234
+ if (p.test(gitCmd)) return { decision: "ask", reason: "blocked git command" };
235
+ }
227
236
 
228
- function isSedSafe(cmd) {
229
- // sed is safe only when it does NOT have -i (in-place edit)
230
- // Note: conservative match — may flag -i in filenames/patterns, which errs on the side of asking
231
- return /^sed\s/.test(cmd) && !/-i/.test(cmd);
237
+ // Everything else auto-approved (add, commit, status, diff, log, branch, tag, stash push, fetch, pull, etc.)
238
+ return { decision: "allow", reason: null };
232
239
  }
233
240
 
234
- // Classify a single command segment (no pipes, no compound operators)
235
241
  function classifySegment(raw, { inWorktree = false } = {}) {
236
242
  const cmd = raw.trim();
237
- if (!cmd || cmd === "true" || cmd === "false") return "allow";
243
+ if (!cmd || cmd === "true" || cmd === "false") return { decision: "allow", reason: null };
238
244
 
239
- // Check user-approved commands first (overrides all other classification)
240
- if (isUserApproved(cmd)) return "allow";
245
+ // User-approved commands override all blocklist checks
246
+ if (isUserApproved(cmd)) return { decision: "allow", reason: null };
241
247
 
242
- // Strip leading environment variable assignments: FOO=bar BAZ=qux cmd ...
248
+ // Strip leading environment variable assignments
243
249
  const withoutEnv = cmd.replace(/^(\w+=\S+\s+)+/, "");
250
+ const firstWord = withoutEnv.split(/\s/)[0];
244
251
 
245
- // gh CLI
246
- if (withoutEnv.startsWith("gh ")) return classifyGhCommand(withoutEnv);
247
-
248
- // git
252
+ // ── Git commands (complex rules) ──
249
253
  if (withoutEnv.startsWith("git ")) return classifyGitCommand(withoutEnv, { inWorktree });
250
254
 
251
- // sed special handling (safe only without -i)
252
- if (withoutEnv.startsWith("sed ")) return isSedSafe(withoutEnv) ? "allow" : "ask";
255
+ // ── GitHub CLI ──
256
+ if (withoutEnv.startsWith("gh ")) {
257
+ for (const p of GH_BLOCKED) {
258
+ if (p.test(withoutEnv)) return { decision: "ask", reason: "mutating gh command" };
259
+ }
260
+ return { decision: "allow", reason: null };
261
+ }
253
262
 
254
- // Safe prefixes ([[ , [ conditionals)
255
- for (const p of SAFE_PREFIXES) {
256
- if (withoutEnv.startsWith(p)) return "allow";
263
+ // ── Dangerous flags (any command) ──
264
+ for (const p of DANGEROUS_FLAGS) {
265
+ if (p.test(cmd)) return { decision: "ask", reason: "dangerous flag" };
257
266
  }
258
267
 
259
- // Safe read commands — match first word
260
- const firstWord = withoutEnv.split(/\s/)[0];
261
- if (SAFE_READ_COMMANDS.includes(firstWord)) return "allow";
268
+ // ── Filesystem destructive ──
269
+ for (const p of FILESYSTEM_BLOCKED) {
270
+ if (p.test(withoutEnv)) return { decision: "ask", reason: "destructive filesystem command" };
271
+ }
272
+
273
+ // ── Process termination ──
274
+ for (const p of PROCESS_BLOCKED) {
275
+ if (p.test(withoutEnv)) return { decision: "ask", reason: "process termination" };
276
+ }
262
277
 
263
- // Safe write commands — still check dangerous patterns
264
- if (SAFE_WRITE_COMMANDS.includes(firstWord)) {
265
- for (const p of DANGEROUS_PATTERNS) {
266
- if (p.test(cmd)) return "ask";
278
+ // ── Publishing ──
279
+ for (const p of PUBLISH_BLOCKED) {
280
+ if (p.test(withoutEnv)) return { decision: "ask", reason: "publishing command" };
281
+ }
282
+
283
+ // ── Database CLIs with destructive SQL ──
284
+ for (const p of DB_CLI_PATTERNS) {
285
+ if (p.test(withoutEnv)) {
286
+ for (const sql of DB_DESTRUCTIVE_SQL) {
287
+ if (sql.test(cmd)) return { decision: "ask", reason: "destructive database operation" };
288
+ }
289
+ return { decision: "allow", reason: null };
267
290
  }
268
- return "allow";
269
291
  }
270
292
 
271
- // Multi-word safe read commands (e.g., "node -e", "npm ls")
272
- const firstTwo = withoutEnv.split(/\s/).slice(0, 2).join(" ");
273
- if (SAFE_READ_COMMANDS.includes(firstTwo)) return "allow";
293
+ // ── curl/wget with mutating methods ──
294
+ for (const p of HTTP_MUTATING) {
295
+ if (p.test(withoutEnv)) return { decision: "ask", reason: "HTTP mutation" };
296
+ }
274
297
 
275
- // Unknown command ask
276
- return "ask";
298
+ // ── Everything else: auto-approved ──
299
+ return { decision: "allow", reason: null };
277
300
  }
278
301
 
279
302
  // ─── Worktree context detection ──────────────────────────────────────────────
280
- // Checks if any segment cd's into a .worktrees/ path or references one in
281
- // a git worktree command.
282
303
 
283
304
  function detectWorktreeContext(segments) {
284
305
  for (const seg of segments) {
285
306
  const trimmed = seg.trim();
286
- // cd into a worktree
287
307
  if (trimmed.startsWith("cd ")) {
288
308
  const target = trimmed.slice(3).trim().replace(/^["']|["']$/g, "");
289
309
  if (/[/\\]\.worktrees?[/\\]/.test(target) || /^\.worktrees?[/\\]/.test(target)) {
290
310
  return true;
291
311
  }
292
312
  }
293
- // git worktree remove targeting a .worktrees/ path
294
313
  if (/git\s+worktree\s+remove\s+.*\.worktrees?[/\\]/.test(trimmed)) {
295
314
  return true;
296
315
  }
@@ -299,11 +318,8 @@ function detectWorktreeContext(segments) {
299
318
  }
300
319
 
301
320
  // ─── Compound command parser ─────────────────────────────────────────────────
302
- // Splits on && || ; while respecting single and double quotes.
303
- // Then splits each segment on pipes. If ANY part is "ask", the whole command is "ask".
304
321
 
305
322
  function classifyCommand(command, { cwdInWorktree = false } = {}) {
306
- // Split compound commands on && || ; while respecting quotes
307
323
  const segments = [];
308
324
  let current = "";
309
325
  let inSingle = false;
@@ -341,13 +357,13 @@ function classifyCommand(command, { cwdInWorktree = false } = {}) {
341
357
  if (ch === "&" && command[i + 1] === "&") {
342
358
  segments.push(current);
343
359
  current = "";
344
- i++; // skip second &
360
+ i++;
345
361
  continue;
346
362
  }
347
363
  if (ch === "|" && command[i + 1] === "|") {
348
364
  segments.push(current);
349
365
  current = "";
350
- i++; // skip second |
366
+ i++;
351
367
  continue;
352
368
  }
353
369
  if (ch === ";") {
@@ -361,10 +377,8 @@ function classifyCommand(command, { cwdInWorktree = false } = {}) {
361
377
  }
362
378
  if (current.trim()) segments.push(current);
363
379
 
364
- // Detect worktree context from cd targets or CWD
365
380
  const inWorktree = cwdInWorktree || detectWorktreeContext(segments);
366
381
 
367
- // Classify each segment — if ANY is "ask", the whole command is "ask"
368
382
  for (const seg of segments) {
369
383
  const trimmed = seg.trim();
370
384
  if (!trimmed) continue;
@@ -388,11 +402,10 @@ function classifyCommand(command, { cwdInWorktree = false } = {}) {
388
402
  }
389
403
  pipeParts.push(pipeCurrent);
390
404
 
391
- // Check each pipe part
392
405
  for (const pipePart of pipeParts) {
393
406
  const result = classifySegment(pipePart, { inWorktree });
394
- if (result === "ask") {
395
- return { decision: "ask", segment: pipePart.trim() };
407
+ if (result.decision === "ask") {
408
+ return { decision: "ask", segment: pipePart.trim(), reason: result.reason };
396
409
  }
397
410
  }
398
411
  }
@@ -411,25 +424,27 @@ function main() {
411
424
  const data = JSON.parse(input);
412
425
  const command = data.tool_input?.command || "";
413
426
 
414
- // Check if CWD is inside a worktree directory
415
427
  const cwd = process.cwd();
416
428
  const cwdInWorktree = /[/\\]\.worktrees?[/\\]/.test(cwd);
417
429
 
418
- const { decision, segment } = classifyCommand(command, { cwdInWorktree });
430
+ const { decision, segment, reason } = classifyCommand(command, { cwdInWorktree });
431
+
432
+ if (decision === "ask") {
433
+ logBlockedCommand(command, reason || `Segment needs approval: ${segment}`);
434
+ }
419
435
 
420
436
  const output = {
421
437
  hookSpecificOutput: {
422
438
  hookEventName: "PreToolUse",
423
439
  permissionDecision: decision,
424
440
  permissionDecisionReason: decision === "allow"
425
- ? "All command segments classified as safe"
426
- : `Segment needs approval: ${segment}`,
441
+ ? "Auto-approved (not on blocklist)"
442
+ : `Blocked: ${reason || segment}`,
427
443
  },
428
444
  };
429
445
 
430
446
  console.log(JSON.stringify(output));
431
447
  } catch (err) {
432
- // On parse error, don't block — let the normal permission system handle it
433
448
  if (!(err instanceof SyntaxError)) {
434
449
  process.stderr.write(`[bash-permissions] Unexpected error: ${err.stack || err.message}\n`);
435
450
  }
@@ -444,4 +459,9 @@ function main() {
444
459
  });
445
460
  }
446
461
 
447
- main();
462
+ // Export for testing
463
+ module.exports = { classifyCommand, classifySegment, classifyGitCommand, classifyGitPush, isUserApproved };
464
+
465
+ if (require.main === module) {
466
+ main();
467
+ }
@@ -0,0 +1,79 @@
1
+ #!/usr/bin/env node
2
+ // Context monitor — reads metrics from statusline hook and injects warnings at thresholds
3
+ // PostToolUse hook (all tool uses), non-blocking advisory output
4
+
5
+ const { readFileSync, writeFileSync, existsSync } = require("fs");
6
+ const { join } = require("path");
7
+
8
+ const METRICS_PATH = join(process.env.HOME || process.env.USERPROFILE, ".claude", "context-metrics.json");
9
+ const STATE_PATH = join(process.env.HOME || process.env.USERPROFILE, ".claude", "context-monitor-state.json");
10
+ const STALENESS_MS = 60_000;
11
+ const WARN_THRESHOLD = 35;
12
+ const CRITICAL_THRESHOLD = 25;
13
+ const SPAM_INTERVAL = 5; // below critical, warn every Nth tool call
14
+
15
+ // Drain stdin (PostToolUse sends tool data, but we read metrics from file instead)
16
+ process.stdin.setEncoding("utf8");
17
+ process.stdin.resume();
18
+ process.stdin.on("end", () => {
19
+ try {
20
+ // Read metrics written by statusline hook
21
+ if (!existsSync(METRICS_PATH)) { output(null); return; }
22
+ const metrics = JSON.parse(readFileSync(METRICS_PATH, "utf8"));
23
+
24
+ // Skip if metrics are stale (>60s old)
25
+ if (Date.now() - metrics.timestamp > STALENESS_MS) { output(null); return; }
26
+
27
+ const pct = metrics.percentRemaining;
28
+
29
+ // Load state to track warnings
30
+ let state = { lastLevel: null, toolCallsSinceCriticalWarn: 0 };
31
+ try {
32
+ if (existsSync(STATE_PATH)) state = JSON.parse(readFileSync(STATE_PATH, "utf8"));
33
+ } catch {}
34
+
35
+ state.toolCallsSinceCriticalWarn = (state.toolCallsSinceCriticalWarn || 0) + 1;
36
+
37
+ let message = null;
38
+
39
+ if (pct <= CRITICAL_THRESHOLD) {
40
+ // Below critical: warn every SPAM_INTERVAL tool calls to avoid noise
41
+ if (state.lastLevel !== "critical" || state.toolCallsSinceCriticalWarn >= SPAM_INTERVAL) {
42
+ const rounded = Math.round(pct);
43
+ message = `\u26a0\ufe0f Context critical at ${rounded}% remaining. Commit current work now. Remaining tasks should run in fresh subagents.`;
44
+ state.lastLevel = "critical";
45
+ state.toolCallsSinceCriticalWarn = 0;
46
+ }
47
+ } else if (pct <= WARN_THRESHOLD) {
48
+ // Advisory zone: warn once on entry
49
+ if (state.lastLevel !== "warn" && state.lastLevel !== "critical") {
50
+ const rounded = Math.round(pct);
51
+ message = `\u26a0 Context at ${rounded}% remaining. Consider wrapping up current task or spawning a fresh executor.`;
52
+ state.lastLevel = "warn";
53
+ state.toolCallsSinceCriticalWarn = 0;
54
+ }
55
+ } else {
56
+ // Above threshold — reset state
57
+ state.lastLevel = null;
58
+ state.toolCallsSinceCriticalWarn = 0;
59
+ }
60
+
61
+ // Persist state
62
+ try { writeFileSync(STATE_PATH, JSON.stringify(state)); } catch {}
63
+
64
+ output(message);
65
+ } catch {
66
+ // Any failure — silent, non-blocking
67
+ output(null);
68
+ }
69
+ });
70
+
71
+ function output(message) {
72
+ if (message) {
73
+ process.stdout.write(JSON.stringify({
74
+ hookSpecificOutput: { hookEventName: "PostToolUse", additionalContext: message },
75
+ }));
76
+ } else {
77
+ process.stdout.write("{}");
78
+ }
79
+ }