@jmylchreest/aide-plugin 0.0.57 → 0.0.58
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/package.json +5 -2
- package/src/cli/codex-config.ts +428 -0
- package/src/cli/hook.ts +85 -0
- package/src/cli/index.ts +49 -12
- package/src/cli/install.ts +52 -25
- package/src/cli/status.ts +50 -17
- package/src/cli/uninstall.ts +29 -8
- package/src/core/mcp-sync.ts +124 -11
- package/src/core/types.ts +2 -2
- package/src/hooks/agent-cleanup.ts +91 -0
- package/src/hooks/comment-checker.ts +115 -0
- package/src/hooks/context-guard.ts +115 -0
- package/src/hooks/context-pruning.ts +216 -0
- package/src/hooks/hud-updater.ts +180 -0
- package/src/hooks/permission-handler.ts +173 -0
- package/src/hooks/persistence.ts +93 -0
- package/src/hooks/pre-compact.ts +127 -0
- package/src/hooks/pre-tool-enforcer.ts +120 -0
- package/src/hooks/session-end.ts +148 -0
- package/src/hooks/session-start.ts +488 -0
- package/src/hooks/session-summary.ts +147 -0
- package/src/hooks/skill-injector.ts +235 -0
- package/src/hooks/subagent-tracker.ts +525 -0
- package/src/hooks/task-completed.ts +445 -0
- package/src/hooks/tool-tracker.ts +89 -0
- package/src/hooks/write-guard.ts +95 -0
- package/src/lib/hook-utils.ts +53 -1
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Task Completed Hook (TaskCompleted)
|
|
4
|
+
*
|
|
5
|
+
* OPT-IN: This hook is NOT registered in plugin.json by default.
|
|
6
|
+
* To enable, add a TaskCompleted entry to .claude-plugin/plugin.json.
|
|
7
|
+
* Not available in OpenCode (no equivalent event).
|
|
8
|
+
*
|
|
9
|
+
* Validates SDLC stage completion before allowing tasks to be marked complete.
|
|
10
|
+
* Parses task subject for [story-id][STAGE] pattern and runs stage-specific checks.
|
|
11
|
+
*
|
|
12
|
+
* Stage validations:
|
|
13
|
+
* - DESIGN: Check for design output (decisions, interfaces)
|
|
14
|
+
* - TEST: Check that test files exist
|
|
15
|
+
* - DEV: Check that tests pass
|
|
16
|
+
* - VERIFY: Full suite green, lint clean
|
|
17
|
+
* - DOCS: Check that docs were updated
|
|
18
|
+
*
|
|
19
|
+
* Exit codes:
|
|
20
|
+
* - 0: Allow completion
|
|
21
|
+
* - 2: Block completion (stderr fed back as feedback)
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import spawn from "cross-spawn";
|
|
25
|
+
import { existsSync, readFileSync } from "fs";
|
|
26
|
+
import { join } from "path";
|
|
27
|
+
import which from "which";
|
|
28
|
+
import { debug, setDebugCwd } from "../lib/logger.js";
|
|
29
|
+
import { readStdin } from "../lib/hook-utils.js";
|
|
30
|
+
|
|
31
|
+
const SOURCE = "task-completed";
|
|
32
|
+
|
|
33
|
+
// Safety limit for regex parsing
|
|
34
|
+
const MAX_SUBJECT_LENGTH = 1000;
|
|
35
|
+
|
|
36
|
+
interface HookInput {
|
|
37
|
+
hook_event_name: "TaskCompleted";
|
|
38
|
+
session_id: string;
|
|
39
|
+
cwd: string;
|
|
40
|
+
task_id: string;
|
|
41
|
+
task_subject: string;
|
|
42
|
+
task_description?: string;
|
|
43
|
+
teammate_name?: string;
|
|
44
|
+
team_name?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface StageInfo {
|
|
48
|
+
storyId: string;
|
|
49
|
+
stage: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Parse task subject for SDLC stage pattern
|
|
54
|
+
* Expected: [story-id][STAGE] Description
|
|
55
|
+
*/
|
|
56
|
+
function parseStageFromSubject(subject: string): StageInfo | null {
|
|
57
|
+
// Safety check for regex
|
|
58
|
+
if (subject.length > MAX_SUBJECT_LENGTH) {
|
|
59
|
+
debug(SOURCE, `Subject too long for parsing (${subject.length} chars)`);
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Match patterns like:
|
|
64
|
+
// [story-auth][DESIGN] Design auth module
|
|
65
|
+
// [Story-1][DEV] Implement feature
|
|
66
|
+
const match = subject.match(/\[([^\]]+)\]\[([A-Z]+)\]/i);
|
|
67
|
+
if (!match) return null;
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
storyId: match[1],
|
|
71
|
+
stage: match[2].toUpperCase(),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Split a shell-style command string into [binary, ...args] for execFileSync.
|
|
77
|
+
* Handles simple cases like "npm test", "go test ./...", "npx tsc --noEmit".
|
|
78
|
+
*/
|
|
79
|
+
function splitCommand(cmd: string): [string, string[]] {
|
|
80
|
+
const parts = cmd.split(/\s+/).filter(Boolean);
|
|
81
|
+
return [parts[0], parts.slice(1)];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Check if a command succeeds (cross-platform via cross-spawn)
|
|
86
|
+
*/
|
|
87
|
+
function commandSucceeds(cmd: string, cwd: string): boolean {
|
|
88
|
+
const [bin, args] = splitCommand(cmd);
|
|
89
|
+
if (!which.sync(bin, { nothrow: true })) {
|
|
90
|
+
debug(SOURCE, `Binary not found in PATH: ${bin}`);
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
const result = spawn.sync(bin, args, { cwd, stdio: "pipe", timeout: 60000 });
|
|
94
|
+
if (result.status !== 0) {
|
|
95
|
+
debug(SOURCE, `Command failed: ${cmd}: exit ${result.status}`);
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
return true;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Get command output or null on failure (cross-platform via cross-spawn)
|
|
103
|
+
*/
|
|
104
|
+
function getCommandOutput(cmd: string, cwd: string): string | null {
|
|
105
|
+
const [bin, args] = splitCommand(cmd);
|
|
106
|
+
if (!which.sync(bin, { nothrow: true })) {
|
|
107
|
+
debug(SOURCE, `Binary not found in PATH: ${bin}`);
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
const result = spawn.sync(bin, args, { cwd, stdio: "pipe", timeout: 30000 });
|
|
111
|
+
if (result.status !== 0 || !result.stdout) {
|
|
112
|
+
debug(SOURCE, `getCommandOutput failed for: ${cmd}: exit ${result.status}`);
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
return result.stdout.toString().trim();
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Detect project type (typescript, go, python)
|
|
120
|
+
*/
|
|
121
|
+
function detectProjectType(
|
|
122
|
+
cwd: string,
|
|
123
|
+
): "typescript" | "go" | "python" | "unknown" {
|
|
124
|
+
if (existsSync(join(cwd, "package.json"))) return "typescript";
|
|
125
|
+
if (existsSync(join(cwd, "go.mod"))) return "go";
|
|
126
|
+
if (
|
|
127
|
+
existsSync(join(cwd, "pyproject.toml")) ||
|
|
128
|
+
existsSync(join(cwd, "setup.py"))
|
|
129
|
+
)
|
|
130
|
+
return "python";
|
|
131
|
+
return "unknown";
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Validate DESIGN stage completion
|
|
136
|
+
*/
|
|
137
|
+
function validateDesign(
|
|
138
|
+
cwd: string,
|
|
139
|
+
storyId: string,
|
|
140
|
+
): { ok: boolean; reason?: string } {
|
|
141
|
+
// Check if any decisions were recorded for this story
|
|
142
|
+
// This is a soft check - design output is hard to validate programmatically
|
|
143
|
+
debug(SOURCE, `Validating DESIGN for ${storyId}`);
|
|
144
|
+
|
|
145
|
+
// For now, just pass - design validation is subjective
|
|
146
|
+
// Could be enhanced to check for design doc files or decisions
|
|
147
|
+
return { ok: true };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Validate TEST stage completion
|
|
152
|
+
*/
|
|
153
|
+
function validateTest(
|
|
154
|
+
cwd: string,
|
|
155
|
+
storyId: string,
|
|
156
|
+
): { ok: boolean; reason?: string } {
|
|
157
|
+
debug(SOURCE, `Validating TEST for ${storyId}`);
|
|
158
|
+
|
|
159
|
+
const projectType = detectProjectType(cwd);
|
|
160
|
+
|
|
161
|
+
// Check if test files exist (recently modified)
|
|
162
|
+
const testPatterns: Record<string, string[]> = {
|
|
163
|
+
typescript: [
|
|
164
|
+
"**/*.test.ts",
|
|
165
|
+
"**/*.spec.ts",
|
|
166
|
+
"**/*.test.tsx",
|
|
167
|
+
"**/*.spec.tsx",
|
|
168
|
+
],
|
|
169
|
+
go: ["**/*_test.go"],
|
|
170
|
+
python: ["**/test_*.py", "**/*_test.py"],
|
|
171
|
+
unknown: [],
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
// For now, just pass - test file existence is hard to validate without grep
|
|
175
|
+
// The real validation happens in DEV stage (tests must pass)
|
|
176
|
+
return { ok: true };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Validate DEV stage completion
|
|
181
|
+
*/
|
|
182
|
+
function validateDev(
|
|
183
|
+
cwd: string,
|
|
184
|
+
storyId: string,
|
|
185
|
+
): { ok: boolean; reason?: string } {
|
|
186
|
+
debug(SOURCE, `Validating DEV for ${storyId}`);
|
|
187
|
+
|
|
188
|
+
const projectType = detectProjectType(cwd);
|
|
189
|
+
|
|
190
|
+
// Run tests based on project type
|
|
191
|
+
const testCommands: Record<string, string> = {
|
|
192
|
+
typescript: "npm test",
|
|
193
|
+
go: "go test ./...",
|
|
194
|
+
python: "pytest",
|
|
195
|
+
unknown: "",
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
const testCmd = testCommands[projectType];
|
|
199
|
+
if (!testCmd) {
|
|
200
|
+
debug(SOURCE, "Unknown project type, skipping test validation");
|
|
201
|
+
return { ok: true };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Check if test command exists in package.json scripts
|
|
205
|
+
if (projectType === "typescript") {
|
|
206
|
+
try {
|
|
207
|
+
const pkgPath = join(cwd, "package.json");
|
|
208
|
+
if (!existsSync(pkgPath)) return { ok: true };
|
|
209
|
+
const pkgJson = JSON.parse(readFileSync(pkgPath, "utf-8"));
|
|
210
|
+
if (!pkgJson?.scripts?.test) {
|
|
211
|
+
debug(SOURCE, "No test script defined, skipping");
|
|
212
|
+
return { ok: true };
|
|
213
|
+
}
|
|
214
|
+
} catch (err) {
|
|
215
|
+
debug(SOURCE, `Failed to read package.json: ${err}`);
|
|
216
|
+
return { ok: true };
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (!commandSucceeds(testCmd, cwd)) {
|
|
221
|
+
return {
|
|
222
|
+
ok: false,
|
|
223
|
+
reason: `Tests are failing. Run \`${testCmd}\` and fix failures before completing DEV stage.`,
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
return { ok: true };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Run a validation command, checking package.json for script existence when
|
|
232
|
+
* the project is TypeScript. Returns a failure message or null if the check
|
|
233
|
+
* passed (or was skipped because the script doesn't exist).
|
|
234
|
+
*/
|
|
235
|
+
function runValidationStep(
|
|
236
|
+
label: string,
|
|
237
|
+
cmd: string,
|
|
238
|
+
scriptName: string | null,
|
|
239
|
+
projectType: string,
|
|
240
|
+
cwd: string,
|
|
241
|
+
): string | null {
|
|
242
|
+
if (!cmd) return null;
|
|
243
|
+
|
|
244
|
+
if (projectType === "typescript" && scriptName) {
|
|
245
|
+
try {
|
|
246
|
+
const pkgPath = join(cwd, "package.json");
|
|
247
|
+
if (!existsSync(pkgPath)) return null;
|
|
248
|
+
const pkgJson = JSON.parse(readFileSync(pkgPath, "utf-8"));
|
|
249
|
+
if (!pkgJson.scripts?.[scriptName]) return null;
|
|
250
|
+
} catch (err) {
|
|
251
|
+
debug(
|
|
252
|
+
SOURCE,
|
|
253
|
+
`Failed to check ${scriptName} script in package.json: ${err}`,
|
|
254
|
+
);
|
|
255
|
+
return null;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (!commandSucceeds(cmd, cwd)) {
|
|
260
|
+
return `${label}: run \`${cmd}\``;
|
|
261
|
+
}
|
|
262
|
+
return null;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Validate VERIFY stage completion
|
|
267
|
+
*/
|
|
268
|
+
function validateVerify(
|
|
269
|
+
cwd: string,
|
|
270
|
+
storyId: string,
|
|
271
|
+
): { ok: boolean; reason?: string } {
|
|
272
|
+
debug(SOURCE, `Validating VERIFY for ${storyId}`);
|
|
273
|
+
|
|
274
|
+
const projectType = detectProjectType(cwd);
|
|
275
|
+
const failures: string[] = [];
|
|
276
|
+
|
|
277
|
+
// Define validation steps per project type: [label, commands-by-type, package.json script name]
|
|
278
|
+
const steps: Array<{
|
|
279
|
+
label: string;
|
|
280
|
+
commands: Record<string, string>;
|
|
281
|
+
scriptName: string | null;
|
|
282
|
+
}> = [
|
|
283
|
+
{
|
|
284
|
+
label: "Tests failing",
|
|
285
|
+
commands: {
|
|
286
|
+
typescript: "npm test",
|
|
287
|
+
go: "go test ./...",
|
|
288
|
+
python: "pytest",
|
|
289
|
+
unknown: "",
|
|
290
|
+
},
|
|
291
|
+
scriptName: null, // always run if command exists
|
|
292
|
+
},
|
|
293
|
+
{
|
|
294
|
+
label: "Lint errors",
|
|
295
|
+
commands: {
|
|
296
|
+
typescript: "npm run lint",
|
|
297
|
+
go: "go vet ./...",
|
|
298
|
+
python: "ruff check .",
|
|
299
|
+
unknown: "",
|
|
300
|
+
},
|
|
301
|
+
scriptName: "lint",
|
|
302
|
+
},
|
|
303
|
+
{
|
|
304
|
+
label: "Type errors",
|
|
305
|
+
commands: {
|
|
306
|
+
typescript: "npx tsc --noEmit",
|
|
307
|
+
go: "",
|
|
308
|
+
python: "",
|
|
309
|
+
unknown: "",
|
|
310
|
+
},
|
|
311
|
+
scriptName: null,
|
|
312
|
+
},
|
|
313
|
+
{
|
|
314
|
+
label: "Build failing",
|
|
315
|
+
commands: {
|
|
316
|
+
typescript: "npm run build",
|
|
317
|
+
go: "go build ./...",
|
|
318
|
+
python: "",
|
|
319
|
+
unknown: "",
|
|
320
|
+
},
|
|
321
|
+
scriptName: "build",
|
|
322
|
+
},
|
|
323
|
+
];
|
|
324
|
+
|
|
325
|
+
for (const step of steps) {
|
|
326
|
+
const cmd = step.commands[projectType] || "";
|
|
327
|
+
const failure = runValidationStep(
|
|
328
|
+
step.label,
|
|
329
|
+
cmd,
|
|
330
|
+
step.scriptName,
|
|
331
|
+
projectType,
|
|
332
|
+
cwd,
|
|
333
|
+
);
|
|
334
|
+
if (failure) failures.push(failure);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
if (failures.length > 0) {
|
|
338
|
+
return {
|
|
339
|
+
ok: false,
|
|
340
|
+
reason: `VERIFY stage incomplete:\n${failures.map((f) => `- ${f}`).join("\n")}`,
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
return { ok: true };
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Validate DOCS stage completion
|
|
349
|
+
*/
|
|
350
|
+
function validateDocs(
|
|
351
|
+
cwd: string,
|
|
352
|
+
storyId: string,
|
|
353
|
+
): { ok: boolean; reason?: string } {
|
|
354
|
+
debug(SOURCE, `Validating DOCS for ${storyId}`);
|
|
355
|
+
|
|
356
|
+
// Documentation validation is subjective
|
|
357
|
+
// Could check for recently modified .md files or doc comments
|
|
358
|
+
// For now, just pass
|
|
359
|
+
return { ok: true };
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Main validation dispatcher
|
|
364
|
+
*/
|
|
365
|
+
function validateStage(
|
|
366
|
+
cwd: string,
|
|
367
|
+
stage: string,
|
|
368
|
+
storyId: string,
|
|
369
|
+
): { ok: boolean; reason?: string } {
|
|
370
|
+
switch (stage) {
|
|
371
|
+
case "DESIGN":
|
|
372
|
+
return validateDesign(cwd, storyId);
|
|
373
|
+
case "TEST":
|
|
374
|
+
return validateTest(cwd, storyId);
|
|
375
|
+
case "DEV":
|
|
376
|
+
return validateDev(cwd, storyId);
|
|
377
|
+
case "VERIFY":
|
|
378
|
+
return validateVerify(cwd, storyId);
|
|
379
|
+
case "DOCS":
|
|
380
|
+
return validateDocs(cwd, storyId);
|
|
381
|
+
case "FIX":
|
|
382
|
+
// FIX stage just needs to pass - it's a remediation stage
|
|
383
|
+
return { ok: true };
|
|
384
|
+
default:
|
|
385
|
+
debug(SOURCE, `Unknown stage: ${stage}, allowing completion`);
|
|
386
|
+
return { ok: true };
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
async function main(): Promise<void> {
|
|
391
|
+
try {
|
|
392
|
+
const input = await readStdin();
|
|
393
|
+
if (!input.trim()) {
|
|
394
|
+
process.exit(0);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
const data: HookInput = JSON.parse(input);
|
|
398
|
+
const cwd = data.cwd || process.cwd();
|
|
399
|
+
|
|
400
|
+
setDebugCwd(cwd);
|
|
401
|
+
debug(SOURCE, `TaskCompleted: ${data.task_subject}`);
|
|
402
|
+
|
|
403
|
+
// Parse stage from task subject
|
|
404
|
+
const stageInfo = parseStageFromSubject(data.task_subject);
|
|
405
|
+
|
|
406
|
+
if (!stageInfo) {
|
|
407
|
+
// Not an SDLC task, allow completion
|
|
408
|
+
debug(SOURCE, "Not an SDLC task, allowing completion");
|
|
409
|
+
process.exit(0);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
debug(
|
|
413
|
+
SOURCE,
|
|
414
|
+
`SDLC task: story=${stageInfo.storyId}, stage=${stageInfo.stage}`,
|
|
415
|
+
);
|
|
416
|
+
|
|
417
|
+
// Validate the stage
|
|
418
|
+
const result = validateStage(cwd, stageInfo.stage, stageInfo.storyId);
|
|
419
|
+
|
|
420
|
+
if (!result.ok) {
|
|
421
|
+
// Block completion - stderr is fed back to the agent
|
|
422
|
+
console.error(result.reason);
|
|
423
|
+
process.exit(2);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// Allow completion
|
|
427
|
+
debug(SOURCE, `Stage ${stageInfo.stage} validation passed`);
|
|
428
|
+
process.exit(0);
|
|
429
|
+
} catch (err) {
|
|
430
|
+
debug(SOURCE, `Error: ${err}`);
|
|
431
|
+
// On error, allow completion (don't block on hook failures)
|
|
432
|
+
process.exit(0);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
process.on("uncaughtException", (err) => {
|
|
437
|
+
debug(SOURCE, `UNCAUGHT EXCEPTION: ${err}`);
|
|
438
|
+
process.exit(0);
|
|
439
|
+
});
|
|
440
|
+
process.on("unhandledRejection", (reason) => {
|
|
441
|
+
debug(SOURCE, `UNHANDLED REJECTION: ${reason}`);
|
|
442
|
+
process.exit(0);
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
main();
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Tool Tracker Hook (PreToolUse)
|
|
4
|
+
*
|
|
5
|
+
* Tracks the currently running tool per agent for HUD display.
|
|
6
|
+
* Sets currentTool in aide-memory before tool execution.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { readStdin } from "../lib/hook-utils.js";
|
|
10
|
+
import { trackToolUse, formatToolDescription } from "../core/tool-tracking.js";
|
|
11
|
+
import { findAideBinary } from "../core/aide-client.js";
|
|
12
|
+
import { debug } from "../lib/logger.js";
|
|
13
|
+
|
|
14
|
+
const SOURCE = "tool-tracker";
|
|
15
|
+
|
|
16
|
+
interface HookInput {
|
|
17
|
+
hook_event_name: string;
|
|
18
|
+
session_id: string;
|
|
19
|
+
cwd: string;
|
|
20
|
+
tool_name?: string;
|
|
21
|
+
agent_id?: string;
|
|
22
|
+
tool_input?: {
|
|
23
|
+
command?: string;
|
|
24
|
+
description?: string;
|
|
25
|
+
prompt?: string;
|
|
26
|
+
file_path?: string;
|
|
27
|
+
model?: string;
|
|
28
|
+
subagent_type?: string;
|
|
29
|
+
};
|
|
30
|
+
transcript_path?: string;
|
|
31
|
+
permission_mode?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function main(): Promise<void> {
|
|
35
|
+
try {
|
|
36
|
+
const input = await readStdin();
|
|
37
|
+
if (!input.trim()) {
|
|
38
|
+
console.log(JSON.stringify({ continue: true }));
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const data: HookInput = JSON.parse(input);
|
|
43
|
+
const cwd = data.cwd || process.cwd();
|
|
44
|
+
const agentId = data.agent_id || data.session_id;
|
|
45
|
+
const toolName = data.tool_name || "";
|
|
46
|
+
|
|
47
|
+
if (agentId && toolName) {
|
|
48
|
+
const binary = findAideBinary({
|
|
49
|
+
cwd,
|
|
50
|
+
pluginRoot:
|
|
51
|
+
process.env.AIDE_PLUGIN_ROOT || process.env.CLAUDE_PLUGIN_ROOT,
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
if (binary) {
|
|
55
|
+
trackToolUse(binary, cwd, {
|
|
56
|
+
toolName,
|
|
57
|
+
agentId,
|
|
58
|
+
toolInput: data.tool_input,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
console.log(JSON.stringify({ continue: true }));
|
|
64
|
+
} catch (error) {
|
|
65
|
+
debug(SOURCE, `Hook error: ${error}`);
|
|
66
|
+
console.log(JSON.stringify({ continue: true }));
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
process.on("uncaughtException", (err) => {
|
|
71
|
+
debug(SOURCE, `UNCAUGHT EXCEPTION: ${err}`);
|
|
72
|
+
try {
|
|
73
|
+
console.log(JSON.stringify({ continue: true }));
|
|
74
|
+
} catch {
|
|
75
|
+
console.log('{"continue":true}');
|
|
76
|
+
}
|
|
77
|
+
process.exit(0);
|
|
78
|
+
});
|
|
79
|
+
process.on("unhandledRejection", (reason) => {
|
|
80
|
+
debug(SOURCE, `UNHANDLED REJECTION: ${reason}`);
|
|
81
|
+
try {
|
|
82
|
+
console.log(JSON.stringify({ continue: true }));
|
|
83
|
+
} catch {
|
|
84
|
+
console.log('{"continue":true}');
|
|
85
|
+
}
|
|
86
|
+
process.exit(0);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
main();
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Write Guard Hook (PreToolUse)
|
|
4
|
+
*
|
|
5
|
+
* Advises the agent to use Edit instead of Write on existing files.
|
|
6
|
+
* Injects advisory context (soft warning) rather than blocking,
|
|
7
|
+
* preventing excessive permission prompts in Claude Code.
|
|
8
|
+
*
|
|
9
|
+
* Core logic is in src/core/write-guard.ts for cross-platform reuse.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { readStdin } from "../lib/hook-utils.js";
|
|
13
|
+
import { debug } from "../lib/logger.js";
|
|
14
|
+
import { checkWriteGuard } from "../core/write-guard.js";
|
|
15
|
+
|
|
16
|
+
const SOURCE = "write-guard";
|
|
17
|
+
|
|
18
|
+
interface HookInput {
|
|
19
|
+
hook_event_name: string;
|
|
20
|
+
session_id: string;
|
|
21
|
+
cwd: string;
|
|
22
|
+
tool_name?: string;
|
|
23
|
+
agent_name?: string;
|
|
24
|
+
agent_id?: string;
|
|
25
|
+
tool_input?: Record<string, unknown>;
|
|
26
|
+
transcript_path?: string;
|
|
27
|
+
permission_mode?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface HookOutput {
|
|
31
|
+
continue: boolean;
|
|
32
|
+
message?: string;
|
|
33
|
+
hookSpecificOutput?: {
|
|
34
|
+
hookEventName: string;
|
|
35
|
+
additionalContext?: string;
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function main(): Promise<void> {
|
|
40
|
+
try {
|
|
41
|
+
const input = await readStdin();
|
|
42
|
+
if (!input.trim()) {
|
|
43
|
+
console.log(JSON.stringify({ continue: true }));
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const data: HookInput = JSON.parse(input);
|
|
48
|
+
const toolName = data.tool_name || "";
|
|
49
|
+
const toolInput = data.tool_input || {};
|
|
50
|
+
const cwd = data.cwd || process.cwd();
|
|
51
|
+
|
|
52
|
+
const result = checkWriteGuard(toolName, toolInput, cwd);
|
|
53
|
+
|
|
54
|
+
if (!result.allowed) {
|
|
55
|
+
debug(
|
|
56
|
+
SOURCE,
|
|
57
|
+
`Advisory: Write to existing file: ${toolInput.file_path || toolInput.filePath || toolInput.path}`,
|
|
58
|
+
);
|
|
59
|
+
const output: HookOutput = {
|
|
60
|
+
continue: true,
|
|
61
|
+
hookSpecificOutput: {
|
|
62
|
+
hookEventName: data.hook_event_name || "PreToolUse",
|
|
63
|
+
additionalContext: result.message,
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
console.log(JSON.stringify(output));
|
|
67
|
+
} else {
|
|
68
|
+
console.log(JSON.stringify({ continue: true }));
|
|
69
|
+
}
|
|
70
|
+
} catch (error) {
|
|
71
|
+
debug(SOURCE, `Hook error: ${error}`);
|
|
72
|
+
console.log(JSON.stringify({ continue: true }));
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
process.on("uncaughtException", (err) => {
|
|
77
|
+
debug(SOURCE, `UNCAUGHT EXCEPTION: ${err}`);
|
|
78
|
+
try {
|
|
79
|
+
console.log(JSON.stringify({ continue: true }));
|
|
80
|
+
} catch {
|
|
81
|
+
console.log('{"continue":true}');
|
|
82
|
+
}
|
|
83
|
+
process.exit(0);
|
|
84
|
+
});
|
|
85
|
+
process.on("unhandledRejection", (reason) => {
|
|
86
|
+
debug(SOURCE, `UNHANDLED REJECTION: ${reason}`);
|
|
87
|
+
try {
|
|
88
|
+
console.log(JSON.stringify({ continue: true }));
|
|
89
|
+
} catch {
|
|
90
|
+
console.log('{"continue":true}');
|
|
91
|
+
}
|
|
92
|
+
process.exit(0);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
main();
|
package/src/lib/hook-utils.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Shared utilities for Claude Code hooks.
|
|
2
|
+
* Shared utilities for Claude Code and Codex CLI hooks.
|
|
3
3
|
*
|
|
4
4
|
* readStdin() is the only unique implementation here. All other functions
|
|
5
5
|
* are convenience wrappers around src/core/aide-client.ts that resolve the
|
|
@@ -41,6 +41,58 @@ export async function readStdin(): Promise<string> {
|
|
|
41
41
|
return Buffer.concat(chunks).toString("utf-8");
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
/**
|
|
45
|
+
* Normalize hook input JSON from different platforms (Claude Code, Codex CLI).
|
|
46
|
+
*
|
|
47
|
+
* Both platforms use command-type hooks with JSON stdin, but field names may
|
|
48
|
+
* differ between versions. This function maps known alternative names to the
|
|
49
|
+
* canonical snake_case format used by aide hook scripts.
|
|
50
|
+
*
|
|
51
|
+
* Returns the normalized JSON string (or the original if no changes needed).
|
|
52
|
+
*/
|
|
53
|
+
export function normalizeHookInput(raw: string): string {
|
|
54
|
+
try {
|
|
55
|
+
const data = JSON.parse(raw) as Record<string, unknown>;
|
|
56
|
+
|
|
57
|
+
// Map known alternative field names → canonical snake_case
|
|
58
|
+
const aliases: Record<string, string> = {
|
|
59
|
+
hookEventName: "hook_event_name",
|
|
60
|
+
sessionId: "session_id",
|
|
61
|
+
toolName: "tool_name",
|
|
62
|
+
agentId: "agent_id",
|
|
63
|
+
agentName: "agent_name",
|
|
64
|
+
toolInput: "tool_input",
|
|
65
|
+
permissionMode: "permission_mode",
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
let changed = false;
|
|
69
|
+
for (const [alt, canonical] of Object.entries(aliases)) {
|
|
70
|
+
if (alt in data && !(canonical in data)) {
|
|
71
|
+
data[canonical] = data[alt];
|
|
72
|
+
delete data[alt];
|
|
73
|
+
changed = true;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return changed ? JSON.stringify(data) : raw;
|
|
78
|
+
} catch {
|
|
79
|
+
return raw;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Detect which AI assistant harness is running these hooks.
|
|
85
|
+
*
|
|
86
|
+
* - Codex CLI: hook dispatcher sets AIDE_PLATFORM=codex
|
|
87
|
+
* - Claude Code: sets CLAUDE_PLUGIN_ROOT
|
|
88
|
+
* - OpenCode uses a separate code path (src/opencode/hooks.ts), so hooks
|
|
89
|
+
* in src/hooks/ are only invoked by Claude Code or Codex.
|
|
90
|
+
*/
|
|
91
|
+
export function detectPlatform(): "claude-code" | "codex" {
|
|
92
|
+
if (process.env.AIDE_PLATFORM === "codex") return "codex";
|
|
93
|
+
return "claude-code";
|
|
94
|
+
}
|
|
95
|
+
|
|
44
96
|
/**
|
|
45
97
|
* Get the plugin root directory from environment variables.
|
|
46
98
|
*/
|