@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.
@@ -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();
@@ -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
  */