supermind-claude 2.0.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.
- package/.env.example +5 -0
- package/README.md +77 -0
- package/airis/docker-compose.yml +76 -0
- package/airis/mcp-config.json +43 -0
- package/cli/commands/approve.js +72 -0
- package/cli/commands/doctor.js +101 -0
- package/cli/commands/install.js +85 -0
- package/cli/commands/uninstall.js +75 -0
- package/cli/commands/update.js +52 -0
- package/cli/index.js +69 -0
- package/cli/lib/hooks.js +66 -0
- package/cli/lib/logger.js +38 -0
- package/cli/lib/mcp.js +112 -0
- package/cli/lib/platform.js +31 -0
- package/cli/lib/plugins.js +19 -0
- package/cli/lib/settings.js +158 -0
- package/cli/lib/skills.js +70 -0
- package/cli/lib/templates.js +24 -0
- package/hooks/bash-permissions.js +430 -0
- package/hooks/cost-tracker.js +26 -0
- package/hooks/session-end.js +82 -0
- package/hooks/session-start.js +162 -0
- package/hooks/statusline-command.js +234 -0
- package/package.json +26 -0
- package/skills/supermind/SKILL.md +13 -0
- package/skills/supermind-init/SKILL.md +174 -0
- package/skills/supermind-init/architecture-template.md +48 -0
- package/skills/supermind-init/design-template.md +43 -0
- package/skills/supermind-living-docs/SKILL.md +55 -0
- package/templates/CLAUDE.md +100 -0
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// PreToolUse hook for Bash — classifies commands as safe or needing approval.
|
|
3
|
+
// Splits compound commands on && || ; and checks each segment (including pipes).
|
|
4
|
+
// Returns permissionDecision: "allow" | "ask"
|
|
5
|
+
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
const os = require('os');
|
|
9
|
+
|
|
10
|
+
// ─── User-approved commands ──────────────────────────────────────────────────
|
|
11
|
+
// Reads ~/.claude/supermind-approved.json — an array of command strings/patterns
|
|
12
|
+
// that the user has explicitly approved for auto-allow.
|
|
13
|
+
|
|
14
|
+
const APPROVED_FILE = path.join(os.homedir(), '.claude', 'supermind-approved.json');
|
|
15
|
+
|
|
16
|
+
function loadApprovedCommands() {
|
|
17
|
+
try {
|
|
18
|
+
const data = JSON.parse(fs.readFileSync(APPROVED_FILE, 'utf-8'));
|
|
19
|
+
return Array.isArray(data) ? data : [];
|
|
20
|
+
} catch {
|
|
21
|
+
return [];
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function isUserApproved(cmd) {
|
|
26
|
+
const approved = loadApprovedCommands();
|
|
27
|
+
const trimmed = cmd.trim();
|
|
28
|
+
for (const pattern of approved) {
|
|
29
|
+
// Exact match
|
|
30
|
+
if (trimmed === pattern) return true;
|
|
31
|
+
// Prefix match (e.g., "git push" approves "git push origin main")
|
|
32
|
+
if (trimmed.startsWith(pattern + ' ') || trimmed.startsWith(pattern + '\t')) return true;
|
|
33
|
+
// Regex match (patterns starting with / are treated as regex)
|
|
34
|
+
if (pattern.startsWith('/') && pattern.endsWith('/')) {
|
|
35
|
+
try {
|
|
36
|
+
if (new RegExp(pattern.slice(1, -1)).test(trimmed)) return true;
|
|
37
|
+
} catch { /* invalid regex, skip */ }
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ─── Constants ───────────────────────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
// Filesystem + shell read-only commands (match by first word or first two words)
|
|
46
|
+
const SAFE_READ_COMMANDS = [
|
|
47
|
+
// Filesystem read-only
|
|
48
|
+
"ls", "cat", "head", "tail", "find", "tree", "du", "df", "wc",
|
|
49
|
+
"file", "which", "type", "readlink", "realpath", "stat", "test",
|
|
50
|
+
// Shell builtins / safe
|
|
51
|
+
"echo", "printf", "pwd", "true", "false", "set", "export",
|
|
52
|
+
"cd", "pushd", "popd", "source", ".",
|
|
53
|
+
// Text processing (read-only)
|
|
54
|
+
"grep", "rg", "awk", "sort", "uniq", "cut", "tr", "tee",
|
|
55
|
+
"diff", "comm", "paste", "column", "fmt", "fold", "rev",
|
|
56
|
+
"jq", "yq", "xargs",
|
|
57
|
+
// System info
|
|
58
|
+
"uname", "whoami", "hostname", "date", "env", "printenv",
|
|
59
|
+
"nproc", "free", "uptime", "id",
|
|
60
|
+
// Node/npm/npx (two-word matches)
|
|
61
|
+
"node -e", "node -p", "npm ls", "npm list", "npm view", "npm info",
|
|
62
|
+
"npx which", "npx tsc", "npx eslint", "npx prettier", "npx vitest",
|
|
63
|
+
"npx jest", "npx tsx", "npx ts-node",
|
|
64
|
+
];
|
|
65
|
+
|
|
66
|
+
// Prefix-matched safe commands (not just first word)
|
|
67
|
+
const SAFE_PREFIXES = [
|
|
68
|
+
"sed -n", // read-only sed
|
|
69
|
+
"sed -e", // expression sed (read-only when no -i)
|
|
70
|
+
"[[ ", // bash conditional
|
|
71
|
+
"[ ", // test
|
|
72
|
+
];
|
|
73
|
+
|
|
74
|
+
// Safe write commands (mkdir, touch, etc.) — still checked against DANGEROUS_PATTERNS
|
|
75
|
+
const SAFE_WRITE_COMMANDS = [
|
|
76
|
+
"mkdir", "touch", "cp", "mv",
|
|
77
|
+
];
|
|
78
|
+
|
|
79
|
+
// ─── Git classification lists ────────────────────────────────────────────────
|
|
80
|
+
|
|
81
|
+
// Git read-only subcommands (always auto-approved)
|
|
82
|
+
const GIT_SAFE_READ = [
|
|
83
|
+
"status", "diff", "log", "show", "blame", "rev-parse", "symbolic-ref",
|
|
84
|
+
"remote", "ls-files", "shortlog", "tag -l", "tag --list", "config --get",
|
|
85
|
+
"config --list", "check-ignore", "rev-list", "name-rev", "describe",
|
|
86
|
+
"for-each-ref", "cat-file", "ls-tree", "verify-commit", "branch -a",
|
|
87
|
+
"branch -r", "branch --list", "branch -l", "branch -v",
|
|
88
|
+
];
|
|
89
|
+
|
|
90
|
+
// Git non-destructive write subcommands (always auto-approved)
|
|
91
|
+
const GIT_SAFE_WRITE = [
|
|
92
|
+
"add", "commit", "stash push", "stash save", "stash list", "stash show",
|
|
93
|
+
"worktree add", "worktree list",
|
|
94
|
+
"branch -m",
|
|
95
|
+
];
|
|
96
|
+
|
|
97
|
+
// Git stash subcommands that destroy stash entries
|
|
98
|
+
const GIT_STASH_DESTRUCTIVE = [
|
|
99
|
+
"stash drop", "stash pop", "stash clear",
|
|
100
|
+
];
|
|
101
|
+
|
|
102
|
+
// Git commands auto-approved ONLY inside a worktree directory
|
|
103
|
+
const GIT_WORKTREE_ONLY = [
|
|
104
|
+
"worktree remove", "worktree prune", "merge", "branch -d",
|
|
105
|
+
];
|
|
106
|
+
|
|
107
|
+
// Git commands that always require human approval
|
|
108
|
+
const GIT_DANGEROUS = [
|
|
109
|
+
"push", "pull", "fetch", "reset", "revert", "rebase", "clean",
|
|
110
|
+
"checkout -- ", "checkout .", "restore",
|
|
111
|
+
"branch -D",
|
|
112
|
+
];
|
|
113
|
+
|
|
114
|
+
// ─── Dangerous patterns (checked across all command types) ───────────────────
|
|
115
|
+
|
|
116
|
+
const DANGEROUS_PATTERNS = [
|
|
117
|
+
/--force/,
|
|
118
|
+
/--hard/,
|
|
119
|
+
/-rf\s/,
|
|
120
|
+
/\brm\b/,
|
|
121
|
+
/\brmdir\b/,
|
|
122
|
+
/\bdel\b/,
|
|
123
|
+
// Note: redirects to /dev/null are safe and handled by allowing the base command
|
|
124
|
+
];
|
|
125
|
+
|
|
126
|
+
// ─── GitHub CLI patterns ─────────────────────────────────────────────────────
|
|
127
|
+
|
|
128
|
+
const GH_DANGEROUS_PATTERNS = [
|
|
129
|
+
/^gh\s+pr\s+merge/,
|
|
130
|
+
/^gh\s+pr\s+close/,
|
|
131
|
+
/^gh\s+pr\s+ready/,
|
|
132
|
+
/^gh\s+issue\s+close/,
|
|
133
|
+
/^gh\s+issue\s+delete/,
|
|
134
|
+
/^gh\s+repo\s+delete/,
|
|
135
|
+
/^gh\s+repo\s+archive/,
|
|
136
|
+
/^gh\s+release\s+(create|delete|edit)/,
|
|
137
|
+
/^gh\s+api\s+-X\s+(DELETE|PUT|PATCH|POST)/,
|
|
138
|
+
/^gh\s+api\s+--method\s+(DELETE|PUT|PATCH|POST)/,
|
|
139
|
+
];
|
|
140
|
+
|
|
141
|
+
// ─── Git global flag stripping ───────────────────────────────────────────────
|
|
142
|
+
// Strips flags like -C <path>, -c <key=val>, --git-dir, --work-tree, --no-pager
|
|
143
|
+
// that appear before the actual git subcommand.
|
|
144
|
+
|
|
145
|
+
function stripGitGlobalFlags(gitArgs) {
|
|
146
|
+
let args = gitArgs;
|
|
147
|
+
// Flags that consume the next argument
|
|
148
|
+
args = args.replace(/^(-C\s+"[^"]*"|-C\s+'[^']*'|-C\s+\S+)\s*/g, "");
|
|
149
|
+
args = args.replace(/^(-c\s+"[^"]*"|-c\s+'[^']*'|-c\s+\S+)\s*/g, "");
|
|
150
|
+
args = args.replace(/^(--git-dir[= ]\S+)\s*/g, "");
|
|
151
|
+
args = args.replace(/^(--work-tree[= ]\S+)\s*/g, "");
|
|
152
|
+
// Standalone flags
|
|
153
|
+
args = args.replace(/^(--no-pager|--bare|--no-replace-objects|--literal-pathspecs|--no-optional-locks)\s*/g, "");
|
|
154
|
+
// Recurse in case multiple global flags are chained
|
|
155
|
+
if (args !== gitArgs) return stripGitGlobalFlags(args);
|
|
156
|
+
return args;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ─── Command classifiers ─────────────────────────────────────────────────────
|
|
160
|
+
|
|
161
|
+
function classifyGhCommand(cmd) {
|
|
162
|
+
for (const pattern of GH_DANGEROUS_PATTERNS) {
|
|
163
|
+
if (pattern.test(cmd)) return "ask";
|
|
164
|
+
}
|
|
165
|
+
return "allow";
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function classifyGitCommand(cmd, { inWorktree = false } = {}) {
|
|
169
|
+
const gitCmd = stripGitGlobalFlags(cmd.slice(4).trim());
|
|
170
|
+
|
|
171
|
+
// Dangerous subcommands take priority
|
|
172
|
+
for (const d of GIT_DANGEROUS) {
|
|
173
|
+
if (gitCmd.startsWith(d)) return "ask";
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Stash destructive subcommands (drop, pop, clear)
|
|
177
|
+
for (const s of GIT_STASH_DESTRUCTIVE) {
|
|
178
|
+
if (gitCmd.startsWith(s)) return "ask";
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Bare "git stash" (no subcommand) is safe — equivalent to stash push
|
|
182
|
+
if (gitCmd === "stash" || /^stash\s*$/.test(gitCmd)) return "allow";
|
|
183
|
+
|
|
184
|
+
// Dangerous patterns (--force, --hard, rm, etc.)
|
|
185
|
+
for (const p of DANGEROUS_PATTERNS) {
|
|
186
|
+
if (p.test(cmd)) return "ask";
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Safe read-only subcommands
|
|
190
|
+
for (const r of GIT_SAFE_READ) {
|
|
191
|
+
if (gitCmd.startsWith(r)) return "allow";
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Safe write subcommands
|
|
195
|
+
for (const w of GIT_SAFE_WRITE) {
|
|
196
|
+
if (gitCmd.startsWith(w)) return "allow";
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Worktree-only commands — auto-approve only inside a worktree dir
|
|
200
|
+
for (const w of GIT_WORKTREE_ONLY) {
|
|
201
|
+
if (gitCmd.startsWith(w)) return inWorktree ? "allow" : "ask";
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Bare "git branch <name>" (create branch) — safe
|
|
205
|
+
if (/^branch\s+[^-]/.test(gitCmd)) return "allow";
|
|
206
|
+
|
|
207
|
+
// Unknown git subcommand — ask
|
|
208
|
+
return "ask";
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function isSedSafe(cmd) {
|
|
212
|
+
// sed is safe only when it does NOT have -i (in-place edit)
|
|
213
|
+
return /^sed\s/.test(cmd) && !/-i/.test(cmd);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Classify a single command segment (no pipes, no compound operators)
|
|
217
|
+
function classifySegment(raw, { inWorktree = false } = {}) {
|
|
218
|
+
const cmd = raw.trim();
|
|
219
|
+
if (!cmd || cmd === "true" || cmd === "false") return "allow";
|
|
220
|
+
|
|
221
|
+
// Check user-approved commands first (overrides all other classification)
|
|
222
|
+
if (isUserApproved(cmd)) return "allow";
|
|
223
|
+
|
|
224
|
+
// Strip leading environment variable assignments: FOO=bar BAZ=qux cmd ...
|
|
225
|
+
const withoutEnv = cmd.replace(/^(\w+=\S+\s+)+/, "");
|
|
226
|
+
|
|
227
|
+
// gh CLI
|
|
228
|
+
if (withoutEnv.startsWith("gh ")) return classifyGhCommand(withoutEnv);
|
|
229
|
+
|
|
230
|
+
// git
|
|
231
|
+
if (withoutEnv.startsWith("git ")) return classifyGitCommand(withoutEnv, { inWorktree });
|
|
232
|
+
|
|
233
|
+
// sed special handling (safe only without -i)
|
|
234
|
+
if (withoutEnv.startsWith("sed ")) return isSedSafe(withoutEnv) ? "allow" : "ask";
|
|
235
|
+
|
|
236
|
+
// Safe prefixes (sed -n, [[ , etc.)
|
|
237
|
+
for (const p of SAFE_PREFIXES) {
|
|
238
|
+
if (withoutEnv.startsWith(p)) return "allow";
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Safe read commands — match first word
|
|
242
|
+
const firstWord = withoutEnv.split(/\s/)[0];
|
|
243
|
+
if (SAFE_READ_COMMANDS.includes(firstWord)) return "allow";
|
|
244
|
+
|
|
245
|
+
// Safe write commands — still check dangerous patterns
|
|
246
|
+
if (SAFE_WRITE_COMMANDS.includes(firstWord)) {
|
|
247
|
+
for (const p of DANGEROUS_PATTERNS) {
|
|
248
|
+
if (p.test(cmd)) return "ask";
|
|
249
|
+
}
|
|
250
|
+
return "allow";
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Multi-word safe read commands (e.g., "node -e", "npm ls")
|
|
254
|
+
const firstTwo = withoutEnv.split(/\s/).slice(0, 2).join(" ");
|
|
255
|
+
if (SAFE_READ_COMMANDS.includes(firstTwo)) return "allow";
|
|
256
|
+
|
|
257
|
+
// Unknown command — ask
|
|
258
|
+
return "ask";
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// ─── Worktree context detection ──────────────────────────────────────────────
|
|
262
|
+
// Checks if any segment cd's into a .worktrees/ path or references one in
|
|
263
|
+
// a git worktree command.
|
|
264
|
+
|
|
265
|
+
function detectWorktreeContext(segments) {
|
|
266
|
+
for (const seg of segments) {
|
|
267
|
+
const trimmed = seg.trim();
|
|
268
|
+
// cd into a worktree
|
|
269
|
+
if (trimmed.startsWith("cd ")) {
|
|
270
|
+
const target = trimmed.slice(3).trim().replace(/^["']|["']$/g, "");
|
|
271
|
+
if (/[/\\]\.worktrees?[/\\]/.test(target) || /^\.worktrees?[/\\]/.test(target)) {
|
|
272
|
+
return true;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
// git worktree remove targeting a .worktrees/ path
|
|
276
|
+
if (/git\s+worktree\s+remove\s+.*\.worktrees?[/\\]/.test(trimmed)) {
|
|
277
|
+
return true;
|
|
278
|
+
}
|
|
279
|
+
// git worktree remove with a relative .worktrees path
|
|
280
|
+
if (/git\s+worktree\s+remove\s+\.worktrees?[/\\]/.test(trimmed)) {
|
|
281
|
+
return true;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
return false;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// ─── Compound command parser ─────────────────────────────────────────────────
|
|
288
|
+
// Splits on && || ; while respecting single and double quotes.
|
|
289
|
+
// Then splits each segment on pipes. If ANY part is "ask", the whole command is "ask".
|
|
290
|
+
|
|
291
|
+
function classifyCommand(command, { cwdInWorktree = false } = {}) {
|
|
292
|
+
// Split compound commands on && || ; while respecting quotes
|
|
293
|
+
const segments = [];
|
|
294
|
+
let current = "";
|
|
295
|
+
let inSingle = false;
|
|
296
|
+
let inDouble = false;
|
|
297
|
+
let escaped = false;
|
|
298
|
+
|
|
299
|
+
for (let i = 0; i < command.length; i++) {
|
|
300
|
+
const ch = command[i];
|
|
301
|
+
|
|
302
|
+
if (escaped) {
|
|
303
|
+
current += ch;
|
|
304
|
+
escaped = false;
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
if (ch === "\\") {
|
|
309
|
+
current += ch;
|
|
310
|
+
escaped = true;
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
if (ch === "'" && !inDouble) {
|
|
315
|
+
inSingle = !inSingle;
|
|
316
|
+
current += ch;
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
if (ch === '"' && !inSingle) {
|
|
321
|
+
inDouble = !inDouble;
|
|
322
|
+
current += ch;
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
if (!inSingle && !inDouble) {
|
|
327
|
+
if (ch === "&" && command[i + 1] === "&") {
|
|
328
|
+
segments.push(current);
|
|
329
|
+
current = "";
|
|
330
|
+
i++; // skip second &
|
|
331
|
+
continue;
|
|
332
|
+
}
|
|
333
|
+
if (ch === "|" && command[i + 1] === "|") {
|
|
334
|
+
segments.push(current);
|
|
335
|
+
current = "";
|
|
336
|
+
i++; // skip second |
|
|
337
|
+
continue;
|
|
338
|
+
}
|
|
339
|
+
if (ch === ";") {
|
|
340
|
+
segments.push(current);
|
|
341
|
+
current = "";
|
|
342
|
+
continue;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
current += ch;
|
|
347
|
+
}
|
|
348
|
+
if (current.trim()) segments.push(current);
|
|
349
|
+
|
|
350
|
+
// Detect worktree context from cd targets or CWD
|
|
351
|
+
const inWorktree = cwdInWorktree || detectWorktreeContext(segments);
|
|
352
|
+
|
|
353
|
+
// Classify each segment — if ANY is "ask", the whole command is "ask"
|
|
354
|
+
for (const seg of segments) {
|
|
355
|
+
const trimmed = seg.trim();
|
|
356
|
+
if (!trimmed) continue;
|
|
357
|
+
|
|
358
|
+
// Split pipes within segment (quote-aware)
|
|
359
|
+
const pipeParts = [];
|
|
360
|
+
let pipeCurrent = "";
|
|
361
|
+
let pInSingle = false;
|
|
362
|
+
let pInDouble = false;
|
|
363
|
+
|
|
364
|
+
for (let i = 0; i < trimmed.length; i++) {
|
|
365
|
+
const ch = trimmed[i];
|
|
366
|
+
if (ch === "'" && !pInDouble) pInSingle = !pInSingle;
|
|
367
|
+
if (ch === '"' && !pInSingle) pInDouble = !pInDouble;
|
|
368
|
+
if (ch === "|" && trimmed[i + 1] !== "|" && !pInSingle && !pInDouble) {
|
|
369
|
+
pipeParts.push(pipeCurrent);
|
|
370
|
+
pipeCurrent = "";
|
|
371
|
+
continue;
|
|
372
|
+
}
|
|
373
|
+
pipeCurrent += ch;
|
|
374
|
+
}
|
|
375
|
+
pipeParts.push(pipeCurrent);
|
|
376
|
+
|
|
377
|
+
// Check each pipe part
|
|
378
|
+
for (const pipePart of pipeParts) {
|
|
379
|
+
const result = classifySegment(pipePart, { inWorktree });
|
|
380
|
+
if (result === "ask") {
|
|
381
|
+
return { decision: "ask", segment: pipePart.trim() };
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
return { decision: "allow" };
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// ─── Main ────────────────────────────────────────────────────────────────────
|
|
390
|
+
|
|
391
|
+
function main() {
|
|
392
|
+
let input = "";
|
|
393
|
+
process.stdin.setEncoding("utf-8");
|
|
394
|
+
process.stdin.on("data", (chunk) => { input += chunk; });
|
|
395
|
+
process.stdin.on("end", () => {
|
|
396
|
+
try {
|
|
397
|
+
const data = JSON.parse(input);
|
|
398
|
+
const command = data.tool_input?.command || "";
|
|
399
|
+
|
|
400
|
+
// Check if CWD is inside a worktree directory
|
|
401
|
+
const cwd = process.cwd();
|
|
402
|
+
const cwdInWorktree = /[/\\]\.worktrees?[/\\]/.test(cwd);
|
|
403
|
+
|
|
404
|
+
const { decision, segment } = classifyCommand(command, { cwdInWorktree });
|
|
405
|
+
|
|
406
|
+
const output = {
|
|
407
|
+
hookSpecificOutput: {
|
|
408
|
+
hookEventName: "PreToolUse",
|
|
409
|
+
permissionDecision: decision,
|
|
410
|
+
permissionDecisionReason: decision === "allow"
|
|
411
|
+
? "All command segments classified as safe"
|
|
412
|
+
: `Segment needs approval: ${segment}`,
|
|
413
|
+
},
|
|
414
|
+
};
|
|
415
|
+
|
|
416
|
+
console.log(JSON.stringify(output));
|
|
417
|
+
} catch (err) {
|
|
418
|
+
// On parse error, don't block — let the normal permission system handle it
|
|
419
|
+
console.log(JSON.stringify({
|
|
420
|
+
hookSpecificOutput: {
|
|
421
|
+
hookEventName: "PreToolUse",
|
|
422
|
+
permissionDecision: "ask",
|
|
423
|
+
permissionDecisionReason: `Hook error: ${err.message}`,
|
|
424
|
+
},
|
|
425
|
+
}));
|
|
426
|
+
}
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
main();
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Cost Tracker Hook — logs session cost estimate to JSONL
|
|
3
|
+
// Fires on: Stop (async)
|
|
4
|
+
|
|
5
|
+
const fs = require("fs");
|
|
6
|
+
const path = require("path");
|
|
7
|
+
const os = require("os");
|
|
8
|
+
|
|
9
|
+
const LOG_FILE = path.join(os.homedir(), ".claude", "cost-log.jsonl");
|
|
10
|
+
|
|
11
|
+
function main() {
|
|
12
|
+
const entry = {
|
|
13
|
+
timestamp: new Date().toISOString(),
|
|
14
|
+
project: process.env.PROJECT_DIR || process.cwd(),
|
|
15
|
+
sessionId: process.env.SESSION_ID || "unknown",
|
|
16
|
+
costUsd: process.env.CLAUDE_SESSION_COST_USD || null,
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
try {
|
|
20
|
+
fs.appendFileSync(LOG_FILE, JSON.stringify(entry) + "\n");
|
|
21
|
+
} catch {
|
|
22
|
+
// Non-critical — silently fail
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
main();
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Session End Hook — saves session summary for next session
|
|
3
|
+
// Fires on: Stop
|
|
4
|
+
|
|
5
|
+
const fs = require("fs");
|
|
6
|
+
const path = require("path");
|
|
7
|
+
const os = require("os");
|
|
8
|
+
const { execSync } = require("child_process");
|
|
9
|
+
|
|
10
|
+
const SESSION_DIR = path.join(os.homedir(), ".claude", "sessions");
|
|
11
|
+
const MAX_SESSIONS = 20;
|
|
12
|
+
|
|
13
|
+
// ─── Git info collection ────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
function getGitInfo(cwd) {
|
|
16
|
+
try {
|
|
17
|
+
const branch = execSync("git rev-parse --abbrev-ref HEAD", {
|
|
18
|
+
cwd, encoding: "utf-8",
|
|
19
|
+
}).trim();
|
|
20
|
+
let diff = '';
|
|
21
|
+
try {
|
|
22
|
+
diff = execSync("git diff --name-only HEAD~1 HEAD", {
|
|
23
|
+
cwd, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"],
|
|
24
|
+
}).trim();
|
|
25
|
+
} catch {
|
|
26
|
+
diff = execSync("git diff --name-only", {
|
|
27
|
+
cwd, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"],
|
|
28
|
+
}).trim();
|
|
29
|
+
}
|
|
30
|
+
const filesModified = diff ? diff.split("\n").filter(Boolean) : [];
|
|
31
|
+
return { branch, filesModified };
|
|
32
|
+
} catch {
|
|
33
|
+
return { branch: null, filesModified: [] };
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// ─── Session cleanup ─────────────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
function cleanOldSessions() {
|
|
40
|
+
if (!fs.existsSync(SESSION_DIR)) return;
|
|
41
|
+
|
|
42
|
+
const files = fs.readdirSync(SESSION_DIR)
|
|
43
|
+
.filter(f => f.endsWith(".json"))
|
|
44
|
+
.map(f => {
|
|
45
|
+
const filepath = path.join(SESSION_DIR, f);
|
|
46
|
+
return { filepath, mtime: fs.statSync(filepath).mtimeMs };
|
|
47
|
+
})
|
|
48
|
+
.sort((a, b) => b.mtime - a.mtime);
|
|
49
|
+
|
|
50
|
+
// Keep only MAX_SESSIONS most recent
|
|
51
|
+
for (const file of files.slice(MAX_SESSIONS)) {
|
|
52
|
+
try { fs.unlinkSync(file.filepath); } catch {}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ─── Main ────────────────────────────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
function main() {
|
|
59
|
+
fs.mkdirSync(SESSION_DIR, { recursive: true });
|
|
60
|
+
|
|
61
|
+
const cwd = process.env.PROJECT_DIR || process.cwd();
|
|
62
|
+
const gitInfo = getGitInfo(cwd);
|
|
63
|
+
|
|
64
|
+
const summary = process.env.SESSION_SUMMARY || "Session ended (no summary provided)";
|
|
65
|
+
|
|
66
|
+
const session = {
|
|
67
|
+
timestamp: new Date().toISOString(),
|
|
68
|
+
project: cwd,
|
|
69
|
+
branch: gitInfo.branch,
|
|
70
|
+
filesModified: gitInfo.filesModified,
|
|
71
|
+
summary,
|
|
72
|
+
decisions: [],
|
|
73
|
+
nextSteps: "",
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const filename = `session-${Date.now()}.json`;
|
|
77
|
+
fs.writeFileSync(path.join(SESSION_DIR, filename), JSON.stringify(session, null, 2));
|
|
78
|
+
|
|
79
|
+
cleanOldSessions();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
main();
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Session Start Hook — loads previous session context + living docs summaries
|
|
3
|
+
// Fires on: SessionStart
|
|
4
|
+
// Outputs combined context: session summary + ARCHITECTURE.md + DESIGN.md
|
|
5
|
+
|
|
6
|
+
const fs = require("fs");
|
|
7
|
+
const path = require("path");
|
|
8
|
+
const os = require("os");
|
|
9
|
+
|
|
10
|
+
const SESSION_DIR = path.join(os.homedir(), ".claude", "sessions");
|
|
11
|
+
const MAX_AGE_DAYS = 7;
|
|
12
|
+
|
|
13
|
+
// ─── Session loading ─────────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
function getLatestSession(projectDir) {
|
|
16
|
+
if (!fs.existsSync(SESSION_DIR)) return null;
|
|
17
|
+
|
|
18
|
+
const files = fs.readdirSync(SESSION_DIR)
|
|
19
|
+
.filter(f => f.endsWith(".json"))
|
|
20
|
+
.map(f => {
|
|
21
|
+
const filepath = path.join(SESSION_DIR, f);
|
|
22
|
+
const stat = fs.statSync(filepath);
|
|
23
|
+
return { filepath, mtime: stat.mtimeMs };
|
|
24
|
+
})
|
|
25
|
+
.sort((a, b) => b.mtime - a.mtime);
|
|
26
|
+
|
|
27
|
+
// Find the most recent session for this project (or any session)
|
|
28
|
+
for (const file of files) {
|
|
29
|
+
const ageDays = (Date.now() - file.mtime) / (1000 * 60 * 60 * 24);
|
|
30
|
+
if (ageDays > MAX_AGE_DAYS) continue;
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
const data = JSON.parse(fs.readFileSync(file.filepath, "utf-8"));
|
|
34
|
+
if (!projectDir || data.project === projectDir) {
|
|
35
|
+
return data;
|
|
36
|
+
}
|
|
37
|
+
} catch {
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function formatSessionContext(session) {
|
|
45
|
+
if (!session) return "[Session] No previous session found. Starting fresh.";
|
|
46
|
+
|
|
47
|
+
const age = Math.round((Date.now() - new Date(session.timestamp).getTime()) / (1000 * 60 * 60));
|
|
48
|
+
const ageStr = age < 24 ? `${age}h ago` : `${Math.round(age / 24)}d ago`;
|
|
49
|
+
|
|
50
|
+
const parts = [`[Previous Session: ${ageStr}]`];
|
|
51
|
+
|
|
52
|
+
if (session.summary) parts.push(`Summary: ${session.summary}`);
|
|
53
|
+
if (session.branch) parts.push(`Branch: ${session.branch}`);
|
|
54
|
+
if (session.filesModified?.length) {
|
|
55
|
+
parts.push(`Modified: ${session.filesModified.slice(0, 15).join(", ")}`);
|
|
56
|
+
}
|
|
57
|
+
if (session.decisions?.length) {
|
|
58
|
+
parts.push(`Key decisions: ${session.decisions.join("; ")}`);
|
|
59
|
+
}
|
|
60
|
+
if (session.nextSteps) parts.push(`Next steps: ${session.nextSteps}`);
|
|
61
|
+
|
|
62
|
+
return parts.join("\n");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ─── Living docs extraction ──────────────────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
function extractDocSummary(content, maxChars) {
|
|
68
|
+
const sections = content.split(/^## /m);
|
|
69
|
+
const headings = [];
|
|
70
|
+
let overview = "";
|
|
71
|
+
let techStack = "";
|
|
72
|
+
|
|
73
|
+
for (const section of sections) {
|
|
74
|
+
const lines = section.split("\n");
|
|
75
|
+
const title = lines[0]?.trim();
|
|
76
|
+
if (!title) continue;
|
|
77
|
+
headings.push(title);
|
|
78
|
+
|
|
79
|
+
if (/overview/i.test(title)) {
|
|
80
|
+
const body = lines.slice(1).join("\n").trim();
|
|
81
|
+
overview = body.split(/\n\s*\n/)[0]?.slice(0, 400) || "";
|
|
82
|
+
}
|
|
83
|
+
if (/tech stack/i.test(title)) {
|
|
84
|
+
techStack = lines.filter(l => l.trim().startsWith("|")).join("\n").slice(0, 300);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const parts = [];
|
|
89
|
+
if (overview) parts.push("Overview: " + overview);
|
|
90
|
+
if (techStack) parts.push("Tech Stack:\n" + techStack);
|
|
91
|
+
if (headings.length) parts.push("Sections: " + headings.join(", "));
|
|
92
|
+
return parts.join("\n").slice(0, maxChars);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function formatLivingDocs(projectDir) {
|
|
96
|
+
const parts = [];
|
|
97
|
+
|
|
98
|
+
// ARCHITECTURE.md — always check
|
|
99
|
+
const archPath = path.join(projectDir, "ARCHITECTURE.md");
|
|
100
|
+
if (fs.existsSync(archPath)) {
|
|
101
|
+
try {
|
|
102
|
+
const content = fs.readFileSync(archPath, "utf-8");
|
|
103
|
+
const summary = extractDocSummary(content, 800);
|
|
104
|
+
if (summary) parts.push(`[Architecture]\n${summary}`);
|
|
105
|
+
} catch {}
|
|
106
|
+
} else {
|
|
107
|
+
parts.push("[Setup] No ARCHITECTURE.md found. Run /supermind-init to create one.");
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// DESIGN.md — only if it exists
|
|
111
|
+
const designPath = path.join(projectDir, "DESIGN.md");
|
|
112
|
+
if (fs.existsSync(designPath)) {
|
|
113
|
+
try {
|
|
114
|
+
const content = fs.readFileSync(designPath, "utf-8");
|
|
115
|
+
const summary = extractDocSummary(content, 400);
|
|
116
|
+
if (summary) parts.push(`[Design]\n${summary}`);
|
|
117
|
+
} catch {}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return parts.join("\n");
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ─── Project health check ────────────────────────────────────────────────────
|
|
124
|
+
|
|
125
|
+
function checkProjectHealth(projectDir) {
|
|
126
|
+
const missing = [];
|
|
127
|
+
if (!fs.existsSync(path.join(projectDir, "CLAUDE.md"))) {
|
|
128
|
+
missing.push("CLAUDE.md");
|
|
129
|
+
}
|
|
130
|
+
if (missing.length > 0) {
|
|
131
|
+
return `[Setup] Missing: ${missing.join(", ")} — consider creating one for this project.`;
|
|
132
|
+
}
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ─── Main ────────────────────────────────────────────────────────────────────
|
|
137
|
+
|
|
138
|
+
function main() {
|
|
139
|
+
try {
|
|
140
|
+
const projectDir = process.env.PROJECT_DIR || process.cwd();
|
|
141
|
+
|
|
142
|
+
const outputParts = [];
|
|
143
|
+
|
|
144
|
+
// Project health check
|
|
145
|
+
const healthWarning = checkProjectHealth(projectDir);
|
|
146
|
+
if (healthWarning) outputParts.push(healthWarning);
|
|
147
|
+
|
|
148
|
+
// Session context
|
|
149
|
+
const session = getLatestSession(projectDir);
|
|
150
|
+
outputParts.push(formatSessionContext(session));
|
|
151
|
+
|
|
152
|
+
// Living docs
|
|
153
|
+
const docsContext = formatLivingDocs(projectDir);
|
|
154
|
+
if (docsContext) outputParts.push(docsContext);
|
|
155
|
+
|
|
156
|
+
console.log(outputParts.join("\n---\n"));
|
|
157
|
+
} catch (err) {
|
|
158
|
+
console.log("[Session] Hook error: " + err.message);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
main();
|