supermind-claude 2.1.1 → 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.
- package/.claude-plugin/plugin.json +21 -0
- package/README.md +34 -46
- package/agents/code-reviewer.md +81 -0
- package/cli/commands/doctor.js +415 -79
- package/cli/commands/install.js +16 -17
- package/cli/commands/skill.js +164 -0
- package/cli/commands/uninstall.js +32 -3
- package/cli/commands/update.js +25 -4
- package/cli/index.js +16 -4
- package/cli/lib/agents.js +413 -0
- package/cli/lib/executor.js +365 -0
- package/cli/lib/hooks.js +8 -1
- package/cli/lib/logger.js +1 -1
- package/cli/lib/planning.js +502 -0
- package/cli/lib/platform.js +4 -0
- package/cli/lib/plugin.js +127 -0
- package/cli/lib/settings.js +2 -40
- package/cli/lib/skills.js +39 -2
- package/cli/lib/vendor-skills.js +594 -0
- package/hooks/bash-permissions.js +196 -176
- package/hooks/context-monitor.js +79 -0
- package/hooks/improvement-logger.js +94 -0
- package/hooks/pre-merge-checklist.js +102 -0
- package/hooks/session-start.js +109 -5
- package/hooks/statusline-command.js +123 -29
- package/package.json +4 -2
- package/skills/anti-rationalization/SKILL.md +38 -0
- package/skills/brainstorming/SKILL.md +165 -0
- package/skills/code-review/SKILL.md +144 -0
- package/skills/executing-plans/SKILL.md +138 -0
- package/skills/finishing-branches/SKILL.md +144 -0
- package/skills/project/SKILL.md +533 -0
- package/skills/quick/SKILL.md +178 -0
- package/skills/supermind/SKILL.md +58 -4
- package/skills/supermind-init/SKILL.md +48 -2
- package/skills/systematic-debugging/SKILL.md +129 -0
- package/skills/tdd/SKILL.md +179 -0
- package/skills/using-git-worktrees/SKILL.md +138 -0
- package/skills/verification-before-completion/SKILL.md +54 -0
- package/skills/writing-plans/SKILL.md +169 -0
- package/templates/CLAUDE.md +124 -62
- package/cli/lib/plugins.js +0 -23
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// PreToolUse hook for Bash —
|
|
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
|
-
// ───
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
87
|
+
const DANGEROUS_FLAGS = [
|
|
88
|
+
/--force(?![\w-])/,
|
|
89
|
+
/--hard(?![\w-])/,
|
|
93
90
|
];
|
|
94
91
|
|
|
95
|
-
// ───
|
|
92
|
+
// ─── Blocklist: Filesystem destructive ──────────────────────────────────────
|
|
96
93
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
//
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
100
|
+
// ─── Blocklist: Process termination ─────────────────────────────────────────
|
|
101
|
+
|
|
102
|
+
const PROCESS_BLOCKED = [
|
|
103
|
+
/^kill\b/,
|
|
104
|
+
/^killall\b/,
|
|
105
|
+
/^pkill\b/,
|
|
111
106
|
];
|
|
112
107
|
|
|
113
|
-
//
|
|
114
|
-
|
|
115
|
-
|
|
108
|
+
// ─── Blocklist: Publishing / deployment ─────────────────────────────────────
|
|
109
|
+
|
|
110
|
+
const PUBLISH_BLOCKED = [
|
|
111
|
+
/^npm\s+publish\b/,
|
|
112
|
+
/^docker\s+push\b/,
|
|
116
113
|
];
|
|
117
114
|
|
|
118
|
-
//
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
// ───
|
|
128
|
+
// ─── Blocklist: curl/wget with mutating HTTP methods ────────────────────────
|
|
131
129
|
|
|
132
|
-
const
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
/\
|
|
137
|
-
/\
|
|
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
|
-
// ───
|
|
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
|
|
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
|
|
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
|
|
163
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
-
|
|
186
|
-
const gitCmd = stripGitGlobalFlags(cmd.slice(4).trim());
|
|
194
|
+
// ─── Git merge classification ───────────────────────────────────────────────
|
|
187
195
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
-
|
|
194
|
-
for (const s of GIT_STASH_DESTRUCTIVE) {
|
|
195
|
-
if (gitCmd.startsWith(s)) return "ask";
|
|
196
|
-
}
|
|
203
|
+
// ─── Command classifiers ─────────────────────────────────────────────────────
|
|
197
204
|
|
|
198
|
-
|
|
199
|
-
|
|
205
|
+
function classifyGitCommand(cmd, { inWorktree = false } = {}) {
|
|
206
|
+
const gitCmd = stripGitGlobalFlags(cmd.slice(4).trim());
|
|
200
207
|
|
|
201
|
-
// Dangerous
|
|
202
|
-
for (const p of
|
|
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
|
-
//
|
|
207
|
-
|
|
208
|
-
|
|
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
|
-
//
|
|
212
|
-
|
|
213
|
-
|
|
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
|
|
217
|
-
|
|
218
|
-
|
|
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
|
-
//
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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
|
-
|
|
229
|
-
|
|
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
|
-
//
|
|
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
|
|
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
|
-
//
|
|
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
|
-
//
|
|
252
|
-
if (withoutEnv.startsWith("
|
|
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
|
-
//
|
|
255
|
-
for (const p of
|
|
256
|
-
if (
|
|
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
|
-
//
|
|
260
|
-
const
|
|
261
|
-
|
|
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
|
-
//
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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
|
-
//
|
|
272
|
-
const
|
|
273
|
-
|
|
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
|
-
//
|
|
276
|
-
return "
|
|
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++;
|
|
360
|
+
i++;
|
|
345
361
|
continue;
|
|
346
362
|
}
|
|
347
363
|
if (ch === "|" && command[i + 1] === "|") {
|
|
348
364
|
segments.push(current);
|
|
349
365
|
current = "";
|
|
350
|
-
i++;
|
|
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
|
-
? "
|
|
426
|
-
: `
|
|
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
|
-
|
|
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
|
+
}
|