@jmylchreest/aide-plugin 0.0.22 → 0.0.24

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jmylchreest/aide-plugin",
3
- "version": "0.0.22",
3
+ "version": "0.0.24",
4
4
  "description": "aide plugin for OpenCode — multi-agent orchestration, memory, skills, and persistence",
5
5
  "type": "module",
6
6
  "main": "./src/opencode/index.ts",
package/src/cli/config.ts CHANGED
@@ -95,7 +95,7 @@ export function addAideToConfig(
95
95
  if (!mcp[MCP_SERVER_NAME]) {
96
96
  mcp[MCP_SERVER_NAME] = {
97
97
  type: "local",
98
- command: ["npx", "-y", "-p", PLUGIN_NAME, "aide-wrapper", "mcp"],
98
+ command: ["npx", "-y", PLUGIN_NAME, "mcp"],
99
99
  environment: {
100
100
  AIDE_CODE_WATCH: "1",
101
101
  AIDE_CODE_WATCH_DELAY: "30s",
package/src/cli/index.ts CHANGED
@@ -6,11 +6,13 @@
6
6
  * bunx @jmylchreest/aide-plugin install # Install globally for OpenCode
7
7
  * bunx @jmylchreest/aide-plugin uninstall # Remove from OpenCode config
8
8
  * bunx @jmylchreest/aide-plugin status # Show current installation status
9
+ * bunx @jmylchreest/aide-plugin mcp # Start MCP server (used by OpenCode)
9
10
  */
10
11
 
11
12
  import { install } from "./install.js";
12
13
  import { uninstall } from "./uninstall.js";
13
14
  import { status } from "./status.js";
15
+ import { mcp } from "./mcp.js";
14
16
 
15
17
  const args = process.argv.slice(2);
16
18
  const command = args[0];
@@ -22,6 +24,7 @@ Usage:
22
24
  aide-plugin install Install aide plugin globally for OpenCode
23
25
  aide-plugin uninstall Remove aide plugin from OpenCode config
24
26
  aide-plugin status Show current installation status
27
+ aide-plugin mcp Start MCP server (delegates to aide-wrapper)
25
28
  aide-plugin --help Show this help message
26
29
 
27
30
  Options:
@@ -49,6 +52,9 @@ async function main(): Promise<void> {
49
52
  case "status":
50
53
  await status();
51
54
  break;
55
+ case "mcp":
56
+ await mcp(args.slice(1));
57
+ break;
52
58
  case "--help":
53
59
  case "-h":
54
60
  case "help":
@@ -1,5 +1,8 @@
1
1
  /**
2
2
  * Install command — registers aide plugin and MCP server in OpenCode config.
3
+ *
4
+ * On reinstall, detects and upgrades stale MCP command configurations
5
+ * (e.g. old `aide-wrapper` commands) to the current format.
3
6
  */
4
7
 
5
8
  import {
@@ -10,6 +13,7 @@ import {
10
13
  readConfig,
11
14
  writeConfig,
12
15
  PLUGIN_NAME,
16
+ MCP_SERVER_NAME,
13
17
  } from "./config.js";
14
18
 
15
19
  export interface InstallFlags {
@@ -17,6 +21,32 @@ export interface InstallFlags {
17
21
  noMcp?: boolean;
18
22
  }
19
23
 
24
+ /**
25
+ * Check if an existing MCP config has the current expected command format.
26
+ * Returns false if the command is missing, empty, or uses an outdated format.
27
+ */
28
+ function isMcpCommandCurrent(config: ReturnType<typeof readConfig>): boolean {
29
+ const mcpConfig = config.mcp?.[MCP_SERVER_NAME];
30
+ if (!mcpConfig?.command || mcpConfig.command.length === 0) {
31
+ return false;
32
+ }
33
+
34
+ const cmd = mcpConfig.command;
35
+
36
+ // Current format: ["npx", "-y", "@jmylchreest/aide-plugin", "mcp"]
37
+ if (
38
+ cmd.length === 4 &&
39
+ cmd[0] === "npx" &&
40
+ cmd[1] === "-y" &&
41
+ cmd[2] === PLUGIN_NAME &&
42
+ cmd[3] === "mcp"
43
+ ) {
44
+ return true;
45
+ }
46
+
47
+ return false;
48
+ }
49
+
20
50
  export async function install(flags: InstallFlags): Promise<void> {
21
51
  const configPath = flags.project
22
52
  ? getProjectConfigPath()
@@ -29,7 +59,11 @@ export async function install(flags: InstallFlags): Promise<void> {
29
59
  const existing = readConfig(configPath);
30
60
  const before = isAideConfigured(existing);
31
61
 
32
- if (before.plugin && before.mcp) {
62
+ // Check if MCP command needs updating (stale format from older versions)
63
+ const mcpNeedsUpdate =
64
+ !flags.noMcp && before.mcp && !isMcpCommandCurrent(existing);
65
+
66
+ if (before.plugin && before.mcp && !mcpNeedsUpdate) {
33
67
  console.log(`aide is already configured in ${configPath}`);
34
68
  console.log(" plugin: registered");
35
69
  console.log(" mcp: registered");
@@ -37,6 +71,11 @@ export async function install(flags: InstallFlags): Promise<void> {
37
71
  return;
38
72
  }
39
73
 
74
+ // If MCP config is stale, remove it so addAideToConfig will write the current version
75
+ if (mcpNeedsUpdate && existing.mcp) {
76
+ delete existing.mcp[MCP_SERVER_NAME];
77
+ }
78
+
40
79
  // Apply changes
41
80
  const updated = addAideToConfig(existing, { noMcp: flags.noMcp });
42
81
  writeConfig(configPath, updated);
@@ -52,7 +91,9 @@ export async function install(flags: InstallFlags): Promise<void> {
52
91
  }
53
92
 
54
93
  if (!flags.noMcp) {
55
- if (!before.mcp && after.mcp) {
94
+ if (mcpNeedsUpdate) {
95
+ console.log(` ~ Updated "aide" MCP server command (was outdated)`);
96
+ } else if (!before.mcp && after.mcp) {
56
97
  console.log(` + Added "aide" MCP server`);
57
98
  } else if (before.mcp) {
58
99
  console.log(` = MCP server already registered`);
package/src/cli/mcp.ts ADDED
@@ -0,0 +1,63 @@
1
+ /**
2
+ * MCP subcommand — delegates to aide-wrapper.sh to start the MCP server.
3
+ *
4
+ * This is the entry point used by OpenCode's MCP config:
5
+ * "command": ["npx", "-y", "@jmylchreest/aide-plugin", "mcp"]
6
+ *
7
+ * The wrapper handles binary discovery/download, then exec's `aide mcp`.
8
+ */
9
+
10
+ import { execFileSync } from "child_process";
11
+ import { existsSync } from "fs";
12
+ import { dirname, join, resolve } from "path";
13
+ import { fileURLToPath } from "url";
14
+
15
+ /**
16
+ * Find the aide-wrapper.sh script relative to this CLI module.
17
+ *
18
+ * Resolution: this file lives at <plugin-root>/src/cli/mcp.ts,
19
+ * so the wrapper is at <plugin-root>/bin/aide-wrapper.sh.
20
+ */
21
+ function findWrapper(): string {
22
+ // __dirname equivalent for ESM
23
+ const thisDir = dirname(fileURLToPath(import.meta.url));
24
+ // src/cli -> src -> plugin-root
25
+ const pluginRoot = resolve(thisDir, "..", "..");
26
+ const wrapper = join(pluginRoot, "bin", "aide-wrapper.sh");
27
+
28
+ if (!existsSync(wrapper)) {
29
+ throw new Error(
30
+ `aide-wrapper.sh not found at ${wrapper}\n` +
31
+ `Expected plugin root: ${pluginRoot}\n` +
32
+ `Ensure the package is installed correctly.`,
33
+ );
34
+ }
35
+
36
+ return wrapper;
37
+ }
38
+
39
+ /**
40
+ * Start the MCP server by exec'ing aide-wrapper.sh with "mcp" + any extra args.
41
+ * This replaces the current process so stdio is inherited directly.
42
+ */
43
+ export async function mcp(extraArgs: string[]): Promise<void> {
44
+ const wrapper = findWrapper();
45
+ const args = ["mcp", ...extraArgs];
46
+
47
+ // Use execFileSync with stdio inherit so the MCP JSON-RPC protocol
48
+ // flows directly between OpenCode and the aide binary.
49
+ // The wrapper will exec() into the aide binary, replacing itself.
50
+ try {
51
+ execFileSync(wrapper, args, {
52
+ stdio: "inherit",
53
+ env: process.env,
54
+ });
55
+ } catch (err: unknown) {
56
+ // execFileSync throws on non-zero exit. If the process was killed
57
+ // by a signal (e.g. OpenCode shutting down), exit cleanly.
58
+ if (err && typeof err === "object" && "status" in err) {
59
+ process.exit((err as { status: number }).status ?? 1);
60
+ }
61
+ throw err;
62
+ }
63
+ }
@@ -0,0 +1,354 @@
1
+ /**
2
+ * Comment checker logic — platform-agnostic.
3
+ *
4
+ * Detects excessive or obvious comments in code written by AI agents.
5
+ * Injected as a warning after Write/Edit tool calls to nudge the agent
6
+ * toward cleaner, human-quality code output.
7
+ *
8
+ * Used by both Claude Code hooks (PostToolUse) and OpenCode plugin (tool.execute.after).
9
+ *
10
+ * Philosophy: LLMs over-comment because training data rewards explanation.
11
+ * The single most visible "AI slop" tell is unnecessary comments like
12
+ * "// Initialize the variable" above `let x = 0`. This checker catches
13
+ * those and nudges the agent to remove them without blocking the tool call.
14
+ */
15
+
16
+ import { debug } from "../lib/logger.js";
17
+
18
+ const SOURCE = "comment-checker";
19
+
20
+ /** File extensions we know how to check for comments */
21
+ const CHECKABLE_EXTENSIONS = new Set([
22
+ ".ts",
23
+ ".tsx",
24
+ ".js",
25
+ ".jsx",
26
+ ".go",
27
+ ".py",
28
+ ".rs",
29
+ ".java",
30
+ ".c",
31
+ ".cpp",
32
+ ".h",
33
+ ".hpp",
34
+ ".cs",
35
+ ".rb",
36
+ ".swift",
37
+ ".kt",
38
+ ".scala",
39
+ ".php",
40
+ ".vue",
41
+ ".svelte",
42
+ ]);
43
+
44
+ /** Extensions that use # for comments */
45
+ const HASH_COMMENT_EXTENSIONS = new Set([".py", ".rb", ".sh", ".yaml", ".yml"]);
46
+
47
+ /** Comment patterns to SKIP (not flag) — these are legitimate */
48
+ const LEGITIMATE_PATTERNS = [
49
+ // Directives and pragmas
50
+ /^\s*\/\/\s*(eslint|prettier|ts-ignore|ts-expect-error|ts-nocheck|@ts-|TODO|FIXME|HACK|BUG|XXX|NOTE|IMPORTANT|WARNING)/i,
51
+ /^\s*\/\/\s*noinspection/i,
52
+ /^\s*\/\*\s*(eslint|prettier|global|jshint)/i,
53
+ /^\s*#\s*(type:\s*ignore|noqa|pragma|pylint|mypy)/i,
54
+ /^\s*\/\/\s*region\b/i,
55
+ /^\s*\/\/\s*endregion\b/i,
56
+ // Shebangs
57
+ /^\s*#!/,
58
+ // License/copyright headers
59
+ /^\s*\/\/\s*(copyright|license|SPDX)/i,
60
+ /^\s*#\s*(copyright|license|SPDX)/i,
61
+ // Encoding declarations
62
+ /^\s*#.*coding[:=]/,
63
+ // BDD/test descriptions (describe, it, test blocks in comments)
64
+ /^\s*\/\/\s*(describe|it|test|expect|should|given|when|then)\b/i,
65
+ // JSDoc/docstring openings (/** and """)
66
+ /^\s*\/\*\*/,
67
+ /^\s*"""/,
68
+ /^\s*'''/,
69
+ // Go build tags
70
+ /^\s*\/\/go:build/,
71
+ /^\s*\/\/\+build/,
72
+ // Rust attributes in comments
73
+ /^\s*\/\/!/,
74
+ /^\s*\/\/\s*#\[/,
75
+ ];
76
+
77
+ /** Patterns that indicate an OBVIOUS/UNNECESSARY comment */
78
+ const OBVIOUS_PATTERNS = [
79
+ // Restating the code in English
80
+ /^\s*\/\/\s*(initialize|initialise|set|create|declare|define|assign|update|increment|decrement|return|import|export|get|fetch|call|invoke|add|remove|delete|check|validate)\s+(the\s+)?\w+/i,
81
+ /^\s*#\s*(initialize|initialise|set|create|declare|define|assign|update|increment|decrement|return|import|export|get|fetch|call|invoke|add|remove|delete|check|validate)\s+(the\s+)?\w+/i,
82
+ // Section dividers that add no info
83
+ /^\s*\/\/\s*[-=*]{3,}\s*$/,
84
+ /^\s*#\s*[-=*]{3,}\s*$/,
85
+ // Empty comments
86
+ /^\s*\/\/\s*$/,
87
+ /^\s*#\s*$/,
88
+ // Repeating the function/variable name
89
+ /^\s*\/\/\s*(function|method|class|interface|type|const|let|var|def|func)\s+/i,
90
+ /^\s*#\s*(function|method|class|interface|type|const|let|var|def|func)\s+/i,
91
+ ];
92
+
93
+ export interface CommentCheckResult {
94
+ /** Whether excessive comments were detected */
95
+ hasExcessiveComments: boolean;
96
+ /** Warning message to inject (empty if no issues) */
97
+ warning: string;
98
+ /** Number of suspicious comments found */
99
+ suspiciousCount: number;
100
+ /** Total comments found */
101
+ totalComments: number;
102
+ /** The suspicious comment lines */
103
+ examples: string[];
104
+ }
105
+
106
+ /**
107
+ * Extract the file extension from a path
108
+ */
109
+ function getExtension(filePath: string): string {
110
+ const lastDot = filePath.lastIndexOf(".");
111
+ if (lastDot === -1) return "";
112
+ return filePath.slice(lastDot).toLowerCase();
113
+ }
114
+
115
+ /**
116
+ * Check if a line is a legitimate comment (should be kept)
117
+ */
118
+ function isLegitimateComment(line: string): boolean {
119
+ return LEGITIMATE_PATTERNS.some((p) => p.test(line));
120
+ }
121
+
122
+ /**
123
+ * Check if a line is an obvious/unnecessary comment
124
+ */
125
+ function isObviousComment(line: string): boolean {
126
+ return OBVIOUS_PATTERNS.some((p) => p.test(line));
127
+ }
128
+
129
+ /**
130
+ * Extract comment lines from code content.
131
+ * Returns only single-line comments (// or #), not block comments or docstrings.
132
+ */
133
+ function extractCommentLines(
134
+ content: string,
135
+ ext: string,
136
+ ): { line: string; lineNumber: number }[] {
137
+ const lines = content.split("\n");
138
+ const comments: { line: string; lineNumber: number }[] = [];
139
+ const usesHash = HASH_COMMENT_EXTENSIONS.has(ext);
140
+ let inBlockComment = false;
141
+ let inDocstring = false;
142
+
143
+ for (let i = 0; i < lines.length; i++) {
144
+ const trimmed = lines[i].trim();
145
+
146
+ // Track block comments (/* ... */)
147
+ if (!inDocstring) {
148
+ if (trimmed.startsWith("/*")) {
149
+ inBlockComment = true;
150
+ // Single-line block comments
151
+ if (trimmed.includes("*/")) {
152
+ inBlockComment = false;
153
+ }
154
+ continue;
155
+ }
156
+ if (inBlockComment) {
157
+ if (trimmed.includes("*/")) {
158
+ inBlockComment = false;
159
+ }
160
+ continue;
161
+ }
162
+ }
163
+
164
+ // Track Python/Ruby docstrings
165
+ if (ext === ".py" || ext === ".rb") {
166
+ const tripleQuoteCount = (trimmed.match(/"""|'''/g) || []).length;
167
+ if (tripleQuoteCount === 1) {
168
+ inDocstring = !inDocstring;
169
+ continue;
170
+ }
171
+ if (inDocstring) continue;
172
+ }
173
+
174
+ // Single-line comments
175
+ if (trimmed.startsWith("//")) {
176
+ comments.push({ line: lines[i], lineNumber: i + 1 });
177
+ } else if (
178
+ usesHash &&
179
+ trimmed.startsWith("#") &&
180
+ !trimmed.startsWith("#!")
181
+ ) {
182
+ comments.push({ line: lines[i], lineNumber: i + 1 });
183
+ }
184
+ }
185
+
186
+ return comments;
187
+ }
188
+
189
+ /**
190
+ * Check code content for excessive or obvious comments.
191
+ *
192
+ * @param filePath - Path to the file being written/edited
193
+ * @param content - The full file content after the write/edit, OR the new content being written
194
+ * @param isNewContent - If true, content is only the new/changed portion (Edit newString)
195
+ */
196
+ export function checkComments(
197
+ filePath: string,
198
+ content: string,
199
+ isNewContent = false,
200
+ ): CommentCheckResult {
201
+ const ext = getExtension(filePath);
202
+
203
+ // Skip non-checkable files
204
+ if (!CHECKABLE_EXTENSIONS.has(ext)) {
205
+ return {
206
+ hasExcessiveComments: false,
207
+ warning: "",
208
+ suspiciousCount: 0,
209
+ totalComments: 0,
210
+ examples: [],
211
+ };
212
+ }
213
+
214
+ const comments = extractCommentLines(content, ext);
215
+ const suspicious: { line: string; lineNumber: number }[] = [];
216
+
217
+ for (const comment of comments) {
218
+ // Skip legitimate comments
219
+ if (isLegitimateComment(comment.line)) continue;
220
+
221
+ // Flag obvious/unnecessary comments
222
+ if (isObviousComment(comment.line)) {
223
+ suspicious.push(comment);
224
+ }
225
+ }
226
+
227
+ // For full file content: also check comment density
228
+ // (>30% comment lines in non-test files is suspicious)
229
+ const totalLines = content
230
+ .split("\n")
231
+ .filter((l) => l.trim().length > 0).length;
232
+ const isTestFile =
233
+ filePath.includes(".test.") ||
234
+ filePath.includes(".spec.") ||
235
+ filePath.includes("__tests__") ||
236
+ filePath.includes("_test.");
237
+ const commentRatio = totalLines > 0 ? comments.length / totalLines : 0;
238
+ const highDensity =
239
+ !isNewContent && !isTestFile && commentRatio > 0.3 && comments.length > 5;
240
+
241
+ const hasExcessiveComments = suspicious.length >= 2 || highDensity;
242
+
243
+ if (!hasExcessiveComments) {
244
+ return {
245
+ hasExcessiveComments: false,
246
+ warning: "",
247
+ suspiciousCount: suspicious.length,
248
+ totalComments: comments.length,
249
+ examples: [],
250
+ };
251
+ }
252
+
253
+ // Build warning message
254
+ const examples = suspicious.slice(0, 5).map((s) => s.line.trim());
255
+ const parts: string[] = [
256
+ `[aide:comment-checker] Detected ${suspicious.length} potentially unnecessary comment${suspicious.length === 1 ? "" : "s"} in ${filePath}.`,
257
+ ];
258
+
259
+ if (highDensity) {
260
+ parts.push(
261
+ `Comment density is ${Math.round(commentRatio * 100)}% (${comments.length}/${totalLines} non-empty lines).`,
262
+ );
263
+ }
264
+
265
+ if (examples.length > 0) {
266
+ parts.push("Examples:");
267
+ for (const ex of examples) {
268
+ parts.push(` ${ex}`);
269
+ }
270
+ }
271
+
272
+ parts.push("");
273
+ parts.push(
274
+ "Clean code should be self-documenting. Remove comments that merely restate the code. " +
275
+ "Keep only: TODOs, complex logic explanations, non-obvious workarounds, API docs, and regulatory/compliance notes.",
276
+ );
277
+
278
+ return {
279
+ hasExcessiveComments: true,
280
+ warning: parts.join("\n"),
281
+ suspiciousCount: suspicious.length,
282
+ totalComments: comments.length,
283
+ examples,
284
+ };
285
+ }
286
+
287
+ /**
288
+ * Determine if a tool call is a write/edit that should be checked.
289
+ * Returns the file path if checkable, null otherwise.
290
+ */
291
+ export function getCheckableFilePath(
292
+ toolName: string,
293
+ toolInput: Record<string, unknown>,
294
+ ): string | null {
295
+ const toolLower = toolName.toLowerCase();
296
+ if (
297
+ toolLower !== "write" &&
298
+ toolLower !== "edit" &&
299
+ toolLower !== "multiedit" &&
300
+ toolLower !== "notebookedit"
301
+ ) {
302
+ return null;
303
+ }
304
+
305
+ const filePath =
306
+ (toolInput.filePath as string) ||
307
+ (toolInput.file_path as string) ||
308
+ (toolInput.path as string);
309
+
310
+ if (!filePath) return null;
311
+
312
+ const ext = getExtension(filePath);
313
+ if (!CHECKABLE_EXTENSIONS.has(ext)) return null;
314
+
315
+ return filePath;
316
+ }
317
+
318
+ /**
319
+ * Get the content to check from a tool's input.
320
+ * For Write: the full content. For Edit: the newString.
321
+ * Returns [content, isNewContent] tuple.
322
+ */
323
+ export function getContentToCheck(
324
+ toolName: string,
325
+ toolInput: Record<string, unknown>,
326
+ ): [string, boolean] | null {
327
+ const toolLower = toolName.toLowerCase();
328
+
329
+ if (toolLower === "write") {
330
+ const content = toolInput.content as string;
331
+ return content ? [content, false] : null;
332
+ }
333
+
334
+ if (toolLower === "edit") {
335
+ const newString = (toolInput.newString || toolInput.new_string) as string;
336
+ return newString ? [newString, true] : null;
337
+ }
338
+
339
+ if (toolLower === "multiedit") {
340
+ // Concatenate all new strings for checking
341
+ const edits = toolInput.edits as
342
+ | Array<{ new_string?: string; newString?: string }>
343
+ | undefined;
344
+ if (!edits || edits.length === 0) return null;
345
+ const combined = edits
346
+ .map((e) => e.new_string || e.newString || "")
347
+ .join("\n");
348
+ return combined ? [combined, true] : null;
349
+ }
350
+
351
+ return null;
352
+ }
353
+
354
+ debug(SOURCE, "Comment checker core loaded");
@@ -11,6 +11,10 @@ import {
11
11
  MAX_PERSISTENCE_ITERATIONS,
12
12
  type PersistenceMode,
13
13
  } from "./types.js";
14
+ import { fetchTodosFromAide, checkTodos } from "./todo-checker.js";
15
+ import { debug } from "../lib/logger.js";
16
+
17
+ const SOURCE = "persistence-logic";
14
18
 
15
19
  /**
16
20
  * Check if a persistence mode is active
@@ -27,32 +31,49 @@ export function getActiveMode(
27
31
  }
28
32
 
29
33
  /**
30
- * Build a reinforcement message for the persistence mode
34
+ * Build a reinforcement message for the persistence mode.
35
+ * If a todo summary is provided, it's appended to give the agent
36
+ * precise visibility into which tasks are still incomplete.
31
37
  */
32
- export function buildReinforcement(mode: string, iteration: number): string {
38
+ export function buildReinforcement(
39
+ mode: string,
40
+ iteration: number,
41
+ todoSummary?: string,
42
+ ): string {
33
43
  if (iteration > MAX_PERSISTENCE_ITERATIONS) {
34
44
  return `Maximum reinforcements (${MAX_PERSISTENCE_ITERATIONS}) reached. Releasing ${mode} mode.`;
35
45
  }
36
46
 
37
- return `**${mode.toUpperCase()} MODE ACTIVE** (iteration ${iteration}/${MAX_PERSISTENCE_ITERATIONS})
38
-
39
- You attempted to stop but work may be incomplete.
47
+ const parts: string[] = [
48
+ `**${mode.toUpperCase()} MODE ACTIVE** (iteration ${iteration}/${MAX_PERSISTENCE_ITERATIONS})`,
49
+ "",
50
+ "You attempted to stop but work may be incomplete.",
51
+ ];
40
52
 
41
- Before stopping, verify:
42
- - All tasks in your todo list are marked complete
43
- - All requested functionality is implemented
44
- - Tests pass (if applicable)
45
- - No errors remain unaddressed
53
+ if (todoSummary) {
54
+ parts.push("");
55
+ parts.push(todoSummary);
56
+ } else {
57
+ parts.push("");
58
+ parts.push("Before stopping, verify:");
59
+ parts.push("- All tasks in your todo list are marked complete");
60
+ parts.push("- All requested functionality is implemented");
61
+ parts.push("- Tests pass (if applicable)");
62
+ parts.push("- No errors remain unaddressed");
63
+ }
46
64
 
47
- If ANY item is incomplete, CONTINUE WORKING.
65
+ parts.push("");
66
+ parts.push("If ANY item is incomplete, CONTINUE WORKING.");
48
67
 
49
- Use TaskList to check your progress.`;
68
+ return parts.join("\n");
50
69
  }
51
70
 
52
71
  /**
53
72
  * Check persistence and return block decision.
54
73
  *
55
74
  * Returns null if stop is allowed, or { reason } if stop should be blocked.
75
+ * When a persistence mode is active and todos exist, the reinforcement
76
+ * message includes the specific incomplete tasks.
56
77
  */
57
78
  export function checkPersistence(
58
79
  binary: string,
@@ -73,5 +94,21 @@ export function checkPersistence(
73
94
  return null;
74
95
  }
75
96
 
76
- return { reason: buildReinforcement(mode, iteration) };
97
+ // Fetch todos and build a specific continuation message if incomplete tasks exist
98
+ let todoSummary: string | undefined;
99
+ try {
100
+ const todos = fetchTodosFromAide(binary, cwd);
101
+ const todoResult = checkTodos(todos);
102
+ if (todoResult.hasIncomplete) {
103
+ todoSummary = todoResult.message;
104
+ debug(
105
+ SOURCE,
106
+ `Found ${todoResult.incompleteCount} incomplete todos for persistence reinforcement`,
107
+ );
108
+ }
109
+ } catch (err) {
110
+ debug(SOURCE, `Failed to fetch todos for persistence (non-fatal): ${err}`);
111
+ }
112
+
113
+ return { reason: buildReinforcement(mode, iteration, todoSummary) };
77
114
  }
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Todo continuation checker — platform-agnostic.
3
+ *
4
+ * Reads the agent's todo list and checks for incomplete items.
5
+ * Used to enhance persistence-logic.ts with precise todo-aware blocking:
6
+ * instead of a generic "verify your work is complete", we list the
7
+ * specific incomplete todos.
8
+ *
9
+ * For Claude Code: reads todos from the transcript (TodoWrite tool outputs)
10
+ * or from aide task list.
11
+ * For OpenCode: reads todos via client.session.todo() API.
12
+ *
13
+ * This module provides the platform-agnostic core. Platform hooks
14
+ * call it with however they obtained the todo list.
15
+ */
16
+
17
+ import { runAide } from "./aide-client.js";
18
+ import { debug } from "../lib/logger.js";
19
+
20
+ const SOURCE = "todo-checker";
21
+
22
+ export interface TodoItem {
23
+ id: string;
24
+ content: string;
25
+ status: "pending" | "in_progress" | "completed" | "cancelled";
26
+ priority?: string;
27
+ }
28
+
29
+ export interface TodoCheckResult {
30
+ /** Whether there are incomplete todos */
31
+ hasIncomplete: boolean;
32
+ /** Number of incomplete items */
33
+ incompleteCount: number;
34
+ /** Total items */
35
+ totalCount: number;
36
+ /** The incomplete items */
37
+ incompleteItems: TodoItem[];
38
+ /** Formatted message for injection */
39
+ message: string;
40
+ }
41
+
42
+ /**
43
+ * Check a list of todos for incomplete items and build a continuation message.
44
+ */
45
+ export function checkTodos(todos: TodoItem[]): TodoCheckResult {
46
+ if (!todos || todos.length === 0) {
47
+ return {
48
+ hasIncomplete: false,
49
+ incompleteCount: 0,
50
+ totalCount: 0,
51
+ incompleteItems: [],
52
+ message: "",
53
+ };
54
+ }
55
+
56
+ const incomplete = todos.filter(
57
+ (t) => t.status !== "completed" && t.status !== "cancelled",
58
+ );
59
+
60
+ if (incomplete.length === 0) {
61
+ return {
62
+ hasIncomplete: false,
63
+ incompleteCount: 0,
64
+ totalCount: todos.length,
65
+ incompleteItems: [],
66
+ message: "",
67
+ };
68
+ }
69
+
70
+ const completedCount = todos.length - incomplete.length;
71
+ const lines: string[] = [
72
+ `**TODO CONTINUATION** — ${incomplete.length} of ${todos.length} tasks incomplete (${completedCount} done)`,
73
+ "",
74
+ "Remaining tasks:",
75
+ ];
76
+
77
+ for (const item of incomplete) {
78
+ const statusIcon = item.status === "in_progress" ? ">" : " ";
79
+ lines.push(` [${statusIcon}] ${item.content}`);
80
+ }
81
+
82
+ lines.push("");
83
+ lines.push(
84
+ "You stopped but have unfinished tasks. Continue working on the next incomplete item.",
85
+ );
86
+
87
+ return {
88
+ hasIncomplete: true,
89
+ incompleteCount: incomplete.length,
90
+ totalCount: todos.length,
91
+ incompleteItems: incomplete,
92
+ message: lines.join("\n"),
93
+ };
94
+ }
95
+
96
+ /**
97
+ * Parse todo items from aide task list output.
98
+ *
99
+ * Output format from `aide task list`:
100
+ * [status] id: content
101
+ * e.g.: [pending] abc123: Implement feature X
102
+ */
103
+ export function parseTodosFromAide(output: string): TodoItem[] {
104
+ const todos: TodoItem[] = [];
105
+ const lines = output.split("\n");
106
+
107
+ for (const line of lines) {
108
+ const match = line.match(
109
+ /\[(pending|in_progress|completed|cancelled)\]\s+(\S+):\s+(.+)/,
110
+ );
111
+ if (match) {
112
+ todos.push({
113
+ status: match[1] as TodoItem["status"],
114
+ id: match[2],
115
+ content: match[3].trim(),
116
+ });
117
+ }
118
+ }
119
+
120
+ return todos;
121
+ }
122
+
123
+ /**
124
+ * Fetch todos from aide binary task list.
125
+ * Returns empty array if binary unavailable or no tasks.
126
+ */
127
+ export function fetchTodosFromAide(binary: string, cwd: string): TodoItem[] {
128
+ try {
129
+ const output = runAide(binary, cwd, ["task", "list"], { timeout: 5000 });
130
+ if (!output) return [];
131
+ return parseTodosFromAide(output);
132
+ } catch (err) {
133
+ debug(SOURCE, `Failed to fetch todos from aide: ${err}`);
134
+ return [];
135
+ }
136
+ }
137
+
138
+ debug(SOURCE, "Todo checker core loaded");
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Write-existing-file guard logic — platform-agnostic.
3
+ *
4
+ * Prevents the Write tool from being used on files that already exist.
5
+ * When a file exists, the agent should use Edit instead, which is surgical
6
+ * and preserves content the agent may have forgotten about.
7
+ *
8
+ * Using Write on existing files is one of the most common destructive
9
+ * failure modes: the agent rewrites the entire file from memory and
10
+ * drops functions, imports, or sections it forgot about.
11
+ *
12
+ * Used by both Claude Code hooks (PreToolUse) and OpenCode plugin (tool.execute.before).
13
+ */
14
+
15
+ import { existsSync } from "fs";
16
+ import { resolve, isAbsolute, normalize } from "path";
17
+ import { debug } from "../lib/logger.js";
18
+
19
+ const SOURCE = "write-guard";
20
+
21
+ /** Paths that are allowed to be overwritten via Write even if they exist */
22
+ const WRITE_ALLOWED_PATTERNS = [
23
+ // aide internal state files
24
+ /\/.aide\//,
25
+ // dotfiles that are typically fully rewritten
26
+ /\/\.[^/]+rc$/,
27
+ /\/\.env/,
28
+ /\/\.gitignore$/,
29
+ // Lock files (often fully generated)
30
+ /\/package-lock\.json$/,
31
+ /\/bun\.lock$/,
32
+ /\/yarn\.lock$/,
33
+ /\/pnpm-lock\.yaml$/,
34
+ /\/go\.sum$/,
35
+ /\/Cargo\.lock$/,
36
+ // Generated config that's typically overwritten whole
37
+ /\/tsconfig\.json$/,
38
+ ];
39
+
40
+ export interface WriteGuardResult {
41
+ /** Whether the Write should be allowed */
42
+ allowed: boolean;
43
+ /** Message explaining why the Write was blocked */
44
+ message?: string;
45
+ }
46
+
47
+ /**
48
+ * Check whether a Write tool call should be allowed.
49
+ *
50
+ * @param toolName - The tool being called (only "Write" is checked)
51
+ * @param toolInput - The tool's input arguments
52
+ * @param cwd - Working directory to resolve relative paths
53
+ * @returns Whether the write is allowed and an explanatory message if blocked
54
+ */
55
+ export function checkWriteGuard(
56
+ toolName: string,
57
+ toolInput: Record<string, unknown>,
58
+ cwd: string,
59
+ ): WriteGuardResult {
60
+ // Only guard the Write tool
61
+ if (toolName.toLowerCase() !== "write") {
62
+ return { allowed: true };
63
+ }
64
+
65
+ const filePath =
66
+ (toolInput.filePath as string) ||
67
+ (toolInput.file_path as string) ||
68
+ (toolInput.path as string);
69
+
70
+ if (!filePath) {
71
+ return { allowed: true };
72
+ }
73
+
74
+ // Resolve the full path
75
+ const resolvedPath = normalize(
76
+ isAbsolute(filePath) ? filePath : resolve(cwd, filePath),
77
+ );
78
+
79
+ // Check if file exists
80
+ if (!existsSync(resolvedPath)) {
81
+ // File doesn't exist — Write is the correct tool for new files
82
+ return { allowed: true };
83
+ }
84
+
85
+ // Check if this path is in the allowed-overwrite list
86
+ for (const pattern of WRITE_ALLOWED_PATTERNS) {
87
+ if (pattern.test(resolvedPath)) {
88
+ debug(
89
+ SOURCE,
90
+ `Allowing Write to existing file (matches allow pattern): ${filePath}`,
91
+ );
92
+ return { allowed: true };
93
+ }
94
+ }
95
+
96
+ // File exists and isn't in the allowed list — block with guidance
97
+ debug(SOURCE, `Blocking Write to existing file: ${filePath}`);
98
+ return {
99
+ allowed: false,
100
+ message:
101
+ `File "${filePath}" already exists. Use the Edit tool instead of Write to make changes to existing files. ` +
102
+ `The Edit tool makes surgical replacements and preserves content you haven't explicitly changed. ` +
103
+ `Write overwrites the entire file, which risks losing code you forgot to include.`,
104
+ };
105
+ }
@@ -45,6 +45,12 @@ import {
45
45
  import { trackToolUse, updateToolStats } from "../core/tool-tracking.js";
46
46
  import { evaluateToolUse, isToolDenied } from "../core/tool-enforcement.js";
47
47
  import { checkPersistence, getActiveMode } from "../core/persistence-logic.js";
48
+ import { checkWriteGuard } from "../core/write-guard.js";
49
+ import {
50
+ checkComments,
51
+ getCheckableFilePath,
52
+ getContentToCheck,
53
+ } from "../core/comment-checker.js";
48
54
  import { getState, setState } from "../core/aide-client.js";
49
55
  import { saveStateSnapshot } from "../core/pre-compact-logic.js";
50
56
  import { cleanupSession } from "../core/cleanup.js";
@@ -395,6 +401,28 @@ function createToolBeforeHandler(
395
401
  output: { args: Record<string, unknown> },
396
402
  ) => Promise<void> {
397
403
  return async (input, _output) => {
404
+ // Write guard: block Write tool on existing files
405
+ try {
406
+ const guardResult = checkWriteGuard(
407
+ input.tool,
408
+ _output.args || {},
409
+ state.cwd,
410
+ );
411
+ if (!guardResult.allowed) {
412
+ debug(SOURCE, `Write guard blocked: ${guardResult.message}`);
413
+ throw new Error(guardResult.message);
414
+ }
415
+ } catch (err) {
416
+ // Re-throw write guard errors (they're intentional blocks)
417
+ if (
418
+ err instanceof Error &&
419
+ err.message?.includes("already exists. Use the Edit tool")
420
+ ) {
421
+ throw err;
422
+ }
423
+ debug(SOURCE, `Write guard check failed (non-fatal): ${err}`);
424
+ }
425
+
398
426
  // Tool enforcement: check agent restrictions
399
427
  // OpenCode doesn't have named agent types like Claude Code, but
400
428
  // we still evaluate in case agent_name is passed via tool args or state
@@ -410,8 +438,6 @@ function createToolBeforeHandler(
410
438
  SOURCE,
411
439
  `Tool ${input.tool} denied for agent ${agentName}: ${enforcement.denyMessage}`,
412
440
  );
413
- // OpenCode doesn't support blocking tool execution from hooks yet,
414
- // but we log the violation for visibility
415
441
  }
416
442
  }
417
443
  } catch (err) {
@@ -443,6 +469,31 @@ function createToolAfterHandler(
443
469
  if (!state.binary) return;
444
470
 
445
471
  updateToolStats(state.binary, state.cwd, input.tool, input.sessionID);
472
+
473
+ // Comment checker: detect excessive comments in Write/Edit output
474
+ try {
475
+ const toolArgs = (_output.metadata?.args || {}) as Record<
476
+ string,
477
+ unknown
478
+ >;
479
+ const filePath = getCheckableFilePath(input.tool, toolArgs);
480
+ if (filePath) {
481
+ const contentResult = getContentToCheck(input.tool, toolArgs);
482
+ if (contentResult) {
483
+ const [content, isNewContent] = contentResult;
484
+ const result = checkComments(filePath, content, isNewContent);
485
+ if (result.hasExcessiveComments) {
486
+ debug(
487
+ SOURCE,
488
+ `Comment checker: ${result.suspiciousCount} suspicious comments in ${filePath}`,
489
+ );
490
+ _output.output += "\n\n" + result.warning;
491
+ }
492
+ }
493
+ }
494
+ } catch (err) {
495
+ debug(SOURCE, `Comment checker failed (non-fatal): ${err}`);
496
+ }
446
497
  };
447
498
  }
448
499
 
@@ -16,7 +16,7 @@
16
16
  * "mcp": {
17
17
  * "aide": {
18
18
  * "type": "local",
19
- * "command": ["npx", "-y", "-p", "@jmylchreest/aide-plugin", "aide-wrapper", "mcp"],
19
+ * "command": ["npx", "-y", "@jmylchreest/aide-plugin", "mcp"],
20
20
  * "environment": { "AIDE_CODE_WATCH": "1", "AIDE_CODE_WATCH_DELAY": "30s" },
21
21
  * "enabled": true
22
22
  * }