opencode-mad 0.2.0 → 0.3.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.
@@ -1,8 +1,8 @@
1
- import type { Plugin } from "@opencode-ai/plugin"
2
- import { tool } from "@opencode-ai/plugin"
3
- import { readFileSync, existsSync, readdirSync, statSync, mkdirSync, writeFileSync, unlinkSync, appendFileSync } from "fs"
4
- import { join, basename } from "path"
5
- import { execSync } from "child_process"
1
+ import type { Plugin } from "@opencode-ai/plugin"
2
+ import { tool } from "@opencode-ai/plugin"
3
+ import { readFileSync, existsSync, readdirSync, statSync, mkdirSync, writeFileSync, unlinkSync, appendFileSync } from "fs"
4
+ import { join, basename } from "path"
5
+ import { execSync } from "child_process"
6
6
 
7
7
  /**
8
8
  * MAD - Multi-Agent Dev Plugin for OpenCode
@@ -11,67 +11,103 @@ import { execSync } from "child_process"
11
11
  * The orchestrator agent decomposes tasks and delegates to developer subagents
12
12
  * running in parallel via OpenCode's Task tool.
13
13
  */
14
- export const MADPlugin: Plugin = async ({ project, client, $, directory, worktree }) => {
15
-
16
- /**
17
- * Helper to run shell commands with proper error handling (cross-platform)
18
- */
19
- const runCommand = (cmd: string, cwd?: string): { success: boolean; output: string; error?: string } => {
20
- try {
21
- const output = execSync(cmd, {
22
- encoding: "utf-8",
23
- cwd: cwd || process.cwd(),
24
- stdio: ["pipe", "pipe", "pipe"]
25
- })
26
- return { success: true, output: output.trim() }
27
- } catch (e: any) {
28
- return {
29
- success: false,
30
- output: "",
31
- error: e.stderr?.toString() || e.message || "Unknown error"
32
- }
33
- }
34
- }
35
-
36
- /**
37
- * Helper to get git root with error handling
38
- */
39
- const getGitRoot = (): string => {
40
- const result = runCommand("git rev-parse --show-toplevel")
41
- if (!result.success) {
42
- throw new Error(`Not a git repository or git not found: ${result.error}`)
43
- }
44
- return result.output.replace(/\\/g, "/")
45
- }
46
-
47
- /**
48
- * Helper to get current branch with fallback
49
- */
50
- const getCurrentBranch = (): string => {
51
- const result = runCommand("git symbolic-ref --short HEAD")
52
- return result.success ? result.output : "main"
53
- }
54
-
55
- /**
56
- * Helper to log MAD events
57
- */
58
- const logEvent = (level: "info" | "warn" | "error" | "debug", message: string, context?: any) => {
59
- try {
60
- const gitRoot = getGitRoot()
61
- const logFile = join(gitRoot, ".mad-logs.jsonl")
62
- const logEntry = JSON.stringify({
63
- timestamp: new Date().toISOString(),
64
- level,
65
- message,
66
- context
67
- }) + "\n"
68
-
69
- appendFileSync(logFile, logEntry)
70
- } catch (e) {
71
- // Silent fail for logging - don't break the workflow
72
- console.error("Failed to write log:", e)
73
- }
74
- }
14
+
15
+ // Current version of opencode-mad
16
+ const CURRENT_VERSION = "0.3.0"
17
+
18
+ export const MADPlugin: Plugin = async ({ project, client, $, directory, worktree }) => {
19
+
20
+ /**
21
+ * Helper to run shell commands with proper error handling (cross-platform)
22
+ */
23
+ const runCommand = (cmd: string, cwd?: string): { success: boolean; output: string; error?: string } => {
24
+ try {
25
+ const output = execSync(cmd, {
26
+ encoding: "utf-8",
27
+ cwd: cwd || process.cwd(),
28
+ stdio: ["pipe", "pipe", "pipe"]
29
+ })
30
+ return { success: true, output: output.trim() }
31
+ } catch (e: any) {
32
+ return {
33
+ success: false,
34
+ output: "",
35
+ error: e.stderr?.toString() || e.message || "Unknown error"
36
+ }
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Helper to get git root with error handling
42
+ */
43
+ const getGitRoot = (): string => {
44
+ const result = runCommand("git rev-parse --show-toplevel")
45
+ if (!result.success) {
46
+ throw new Error(`Not a git repository or git not found: ${result.error}`)
47
+ }
48
+ return result.output.replace(/\\/g, "/")
49
+ }
50
+
51
+ /**
52
+ * Helper to get current branch with fallback
53
+ */
54
+ const getCurrentBranch = (): string => {
55
+ const result = runCommand("git symbolic-ref --short HEAD")
56
+ return result.success ? result.output : "main"
57
+ }
58
+
59
+ /**
60
+ * Helper to log MAD events
61
+ */
62
+ const logEvent = (level: "info" | "warn" | "error" | "debug", message: string, context?: any) => {
63
+ try {
64
+ const gitRoot = getGitRoot()
65
+ const logFile = join(gitRoot, ".mad-logs.jsonl")
66
+ const logEntry = JSON.stringify({
67
+ timestamp: new Date().toISOString(),
68
+ level,
69
+ message,
70
+ context
71
+ }) + "\n"
72
+
73
+ appendFileSync(logFile, logEntry)
74
+ } catch (e) {
75
+ // Silent fail for logging - don't break the workflow
76
+ console.error("Failed to write log:", e)
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Check for updates from npm registry
82
+ */
83
+ const checkForUpdates = async (): Promise<{ hasUpdate: boolean; current: string; latest: string }> => {
84
+ try {
85
+ const result = runCommand("npm view opencode-mad version")
86
+ if (result.success) {
87
+ const latestVersion = result.output.trim()
88
+ return {
89
+ hasUpdate: latestVersion !== CURRENT_VERSION,
90
+ current: CURRENT_VERSION,
91
+ latest: latestVersion
92
+ }
93
+ }
94
+ } catch (e) {
95
+ // Silent fail - don't break startup if npm check fails
96
+ }
97
+ return { hasUpdate: false, current: CURRENT_VERSION, latest: CURRENT_VERSION }
98
+ }
99
+
100
+ // Check for updates at plugin initialization
101
+ try {
102
+ const updateInfo = await checkForUpdates()
103
+ if (updateInfo.hasUpdate) {
104
+ console.log(`\n🔄 opencode-mad update available: ${updateInfo.current} → ${updateInfo.latest}`)
105
+ console.log(` Run: npx opencode-mad install -g\n`)
106
+ logEvent("info", "Update available", { current: updateInfo.current, latest: updateInfo.latest })
107
+ }
108
+ } catch (e) {
109
+ // Silent fail - don't break plugin initialization
110
+ }
75
111
 
76
112
  return {
77
113
  // Custom tools for MAD workflow
@@ -80,322 +116,322 @@ export const MADPlugin: Plugin = async ({ project, client, $, directory, worktre
80
116
  /**
81
117
  * Create a git worktree for parallel development
82
118
  */
83
- mad_worktree_create: tool({
84
- description: `Create a new git worktree branch for parallel development.
85
- Use this to set up isolated development environments for subtasks.
86
- Each worktree has its own branch and working directory.`,
87
- args: {
88
- branch: tool.schema.string().describe("Branch name for the worktree (e.g., 'feat/auth-login')"),
89
- task: tool.schema.string().describe("Description of the task to be done in this worktree"),
90
- },
91
- async execute(args, context) {
92
- try {
93
- const { branch, task } = args
94
-
95
- // Validate inputs
96
- if (!branch || branch.trim() === "") {
97
- logEvent("error", "mad_worktree_create failed: empty branch name")
98
- return "❌ Error: Branch name cannot be empty"
99
- }
100
-
101
- if (!task || task.trim() === "") {
102
- logEvent("error", "mad_worktree_create failed: empty task description")
103
- return "❌ Error: Task description cannot be empty"
104
- }
105
-
106
- const gitRoot = getGitRoot()
107
- const baseBranch = getCurrentBranch()
108
- const sessionName = branch.replace(/\//g, "-")
109
- const worktreeDir = join(gitRoot, "worktrees")
110
- const worktreePath = join(worktreeDir, sessionName)
111
-
112
- // Check if worktree already exists
113
- if (existsSync(worktreePath)) {
114
- logEvent("warn", "Worktree already exists", { branch, path: worktreePath })
115
- return `⚠️ Worktree already exists at ${worktreePath}\nUse a different branch name or clean up with mad_cleanup.`
116
- }
117
-
118
- logEvent("info", "Creating worktree", { branch, baseBranch })
119
-
120
- // Ensure .agent-* files are in .gitignore
121
- const gitignorePath = join(gitRoot, ".gitignore")
122
- let gitignoreContent = existsSync(gitignorePath) ? readFileSync(gitignorePath, "utf-8") : ""
123
- if (!gitignoreContent.includes(".agent-")) {
124
- const additions = `
125
- # MAD agent files (never commit)
126
- .agent-task
127
- .agent-done
128
- .agent-blocked
129
- .agent-error
130
- .mad-logs.jsonl
131
-
132
- # Worktrees directory
133
- worktrees/
134
- `
135
- appendFileSync(gitignorePath, additions)
136
- runCommand(`git add "${gitignorePath}" && git commit -m "chore: add MAD agent files to gitignore"`, gitRoot)
137
- }
138
-
139
- // Create worktree directory using Node.js
140
- try {
141
- mkdirSync(worktreeDir, { recursive: true })
142
- } catch (e: any) {
143
- logEvent("error", "Failed to create worktree directory", { error: e.message })
144
- return `❌ Error creating worktree directory: ${e.message}`
145
- }
146
-
147
- // Check if branch exists
148
- const branchCheckResult = runCommand(`git rev-parse --verify ${branch}`)
149
- const branchExists = branchCheckResult.success
150
-
151
- // Create worktree
152
- const worktreeCmd = branchExists
153
- ? `git worktree add "${worktreePath}" ${branch}`
154
- : `git worktree add -b ${branch} "${worktreePath}" ${baseBranch}`
155
-
156
- const worktreeResult = runCommand(worktreeCmd, gitRoot)
157
- if (!worktreeResult.success) {
158
- logEvent("error", "Failed to create git worktree", {
159
- branch,
160
- command: worktreeCmd,
161
- error: worktreeResult.error
162
- })
163
- return `❌ Error creating git worktree: ${worktreeResult.error}`
164
- }
165
-
166
- // Write task file using Node.js
167
- const taskContent = `# Agent Task
168
- # Branch: ${branch}
169
- # Created: ${new Date().toISOString()}
170
- # Base: ${baseBranch}
171
-
172
- ${task}
173
- `
174
- try {
175
- writeFileSync(join(worktreePath, ".agent-task"), taskContent)
176
- } catch (e: any) {
177
- logEvent("warn", "Failed to write task file", { error: e.message })
178
- }
179
-
180
- logEvent("info", "Worktree created successfully", { branch, path: worktreePath })
181
-
182
- return `✅ Worktree created successfully!
183
- - Path: ${worktreePath}
184
- - Branch: ${branch}
185
- - Base: ${baseBranch}
186
- - Task: ${task.substring(0, 100)}${task.length > 100 ? "..." : ""}
187
-
188
- The developer subagent can now work in this worktree using the Task tool.`
189
- } catch (e: any) {
190
- logEvent("error", "mad_worktree_create exception", { error: e.message, stack: e.stack })
191
- return `❌ Unexpected error creating worktree: ${e.message}`
192
- }
193
- },
194
- }),
119
+ mad_worktree_create: tool({
120
+ description: `Create a new git worktree branch for parallel development.
121
+ Use this to set up isolated development environments for subtasks.
122
+ Each worktree has its own branch and working directory.`,
123
+ args: {
124
+ branch: tool.schema.string().describe("Branch name for the worktree (e.g., 'feat/auth-login')"),
125
+ task: tool.schema.string().describe("Description of the task to be done in this worktree"),
126
+ },
127
+ async execute(args, context) {
128
+ try {
129
+ const { branch, task } = args
130
+
131
+ // Validate inputs
132
+ if (!branch || branch.trim() === "") {
133
+ logEvent("error", "mad_worktree_create failed: empty branch name")
134
+ return "❌ Error: Branch name cannot be empty"
135
+ }
136
+
137
+ if (!task || task.trim() === "") {
138
+ logEvent("error", "mad_worktree_create failed: empty task description")
139
+ return "❌ Error: Task description cannot be empty"
140
+ }
141
+
142
+ const gitRoot = getGitRoot()
143
+ const baseBranch = getCurrentBranch()
144
+ const sessionName = branch.replace(/\//g, "-")
145
+ const worktreeDir = join(gitRoot, "worktrees")
146
+ const worktreePath = join(worktreeDir, sessionName)
147
+
148
+ // Check if worktree already exists
149
+ if (existsSync(worktreePath)) {
150
+ logEvent("warn", "Worktree already exists", { branch, path: worktreePath })
151
+ return `⚠️ Worktree already exists at ${worktreePath}\nUse a different branch name or clean up with mad_cleanup.`
152
+ }
153
+
154
+ logEvent("info", "Creating worktree", { branch, baseBranch })
155
+
156
+ // Ensure .agent-* files are in .gitignore
157
+ const gitignorePath = join(gitRoot, ".gitignore")
158
+ let gitignoreContent = existsSync(gitignorePath) ? readFileSync(gitignorePath, "utf-8") : ""
159
+ if (!gitignoreContent.includes(".agent-")) {
160
+ const additions = `
161
+ # MAD agent files (never commit)
162
+ .agent-task
163
+ .agent-done
164
+ .agent-blocked
165
+ .agent-error
166
+ .mad-logs.jsonl
167
+
168
+ # Worktrees directory
169
+ worktrees/
170
+ `
171
+ appendFileSync(gitignorePath, additions)
172
+ runCommand(`git add "${gitignorePath}" && git commit -m "chore: add MAD agent files to gitignore"`, gitRoot)
173
+ }
174
+
175
+ // Create worktree directory using Node.js
176
+ try {
177
+ mkdirSync(worktreeDir, { recursive: true })
178
+ } catch (e: any) {
179
+ logEvent("error", "Failed to create worktree directory", { error: e.message })
180
+ return `❌ Error creating worktree directory: ${e.message}`
181
+ }
182
+
183
+ // Check if branch exists
184
+ const branchCheckResult = runCommand(`git rev-parse --verify ${branch}`)
185
+ const branchExists = branchCheckResult.success
186
+
187
+ // Create worktree
188
+ const worktreeCmd = branchExists
189
+ ? `git worktree add "${worktreePath}" ${branch}`
190
+ : `git worktree add -b ${branch} "${worktreePath}" ${baseBranch}`
191
+
192
+ const worktreeResult = runCommand(worktreeCmd, gitRoot)
193
+ if (!worktreeResult.success) {
194
+ logEvent("error", "Failed to create git worktree", {
195
+ branch,
196
+ command: worktreeCmd,
197
+ error: worktreeResult.error
198
+ })
199
+ return `❌ Error creating git worktree: ${worktreeResult.error}`
200
+ }
201
+
202
+ // Write task file using Node.js
203
+ const taskContent = `# Agent Task
204
+ # Branch: ${branch}
205
+ # Created: ${new Date().toISOString()}
206
+ # Base: ${baseBranch}
207
+
208
+ ${task}
209
+ `
210
+ try {
211
+ writeFileSync(join(worktreePath, ".agent-task"), taskContent)
212
+ } catch (e: any) {
213
+ logEvent("warn", "Failed to write task file", { error: e.message })
214
+ }
215
+
216
+ logEvent("info", "Worktree created successfully", { branch, path: worktreePath })
217
+
218
+ return `✅ Worktree created successfully!
219
+ - Path: ${worktreePath}
220
+ - Branch: ${branch}
221
+ - Base: ${baseBranch}
222
+ - Task: ${task.substring(0, 100)}${task.length > 100 ? "..." : ""}
223
+
224
+ The developer subagent can now work in this worktree using the Task tool.`
225
+ } catch (e: any) {
226
+ logEvent("error", "mad_worktree_create exception", { error: e.message, stack: e.stack })
227
+ return `❌ Unexpected error creating worktree: ${e.message}`
228
+ }
229
+ },
230
+ }),
195
231
 
196
232
  /**
197
233
  * Get status of all active worktrees/agents
198
234
  */
199
- mad_status: tool({
200
- description: `Get the status of all MAD worktrees and their development progress.
201
- Shows which tasks are done, in progress, blocked, or have errors.`,
202
- args: {},
203
- async execute(args, context) {
204
- const gitRoot = getGitRoot()
205
- const worktreeDir = join(gitRoot, "worktrees")
206
-
207
- if (!existsSync(worktreeDir)) {
208
- return "No active MAD worktrees. Use mad_worktree_create to create one."
209
- }
210
-
211
- const entries = readdirSync(worktreeDir)
212
- if (entries.length === 0) {
213
- return "No active MAD worktrees. Use mad_worktree_create to create one."
214
- }
215
-
216
- let status = "# MAD Status Dashboard\n\n"
217
- let total = 0, done = 0, blocked = 0, errors = 0, wip = 0
218
-
219
- for (const entry of entries) {
220
- const wpath = join(worktreeDir, entry)
221
- if (!statSync(wpath).isDirectory()) continue
222
- total++
223
-
224
- const taskFile = join(wpath, ".agent-task")
225
- const doneFile = join(wpath, ".agent-done")
226
- const blockedFile = join(wpath, ".agent-blocked")
227
- const errorFile = join(wpath, ".agent-error")
228
-
229
- let statusIcon = "⏳"
230
- let statusText = "IN PROGRESS"
231
- let detail = ""
232
-
233
- if (existsSync(doneFile)) {
234
- statusIcon = "✅"
235
- statusText = "DONE"
236
- detail = readFileSync(doneFile, "utf-8").split("\n")[0]
237
- done++
238
- } else if (existsSync(blockedFile)) {
239
- statusIcon = "🚫"
240
- statusText = "BLOCKED"
241
- detail = readFileSync(blockedFile, "utf-8").split("\n")[0]
242
- blocked++
243
- } else if (existsSync(errorFile)) {
244
- statusIcon = "❌"
245
- statusText = "ERROR"
246
- detail = readFileSync(errorFile, "utf-8").split("\n")[0]
247
- errors++
248
- } else {
249
- wip++
250
- }
251
-
252
- // Get task description
253
- const task = existsSync(taskFile)
254
- ? readFileSync(taskFile, "utf-8").split("\n").filter(l => !l.startsWith("#") && l.trim()).join(" ").slice(0, 60)
255
- : "No task file"
256
-
257
- // Get commit count
258
- let commits = "0"
259
- try {
260
- const baseBranch = getCurrentBranch()
261
- const result = runCommand(`git log --oneline ${baseBranch}..HEAD`, wpath)
262
- if (result.success) {
263
- commits = result.output.split("\n").filter(l => l.trim()).length.toString()
264
- }
265
- } catch {}
266
-
267
- status += `## ${statusIcon} ${entry}\n`
268
- status += `- **Status:** ${statusText}\n`
269
- status += `- **Task:** ${task}\n`
270
- status += `- **Commits:** ${commits}\n`
271
- if (detail) status += `- **Detail:** ${detail}\n`
272
- status += `\n`
273
- }
274
-
275
- status += `---\n`
276
- status += `**Total:** ${total} | **Done:** ${done} | **WIP:** ${wip} | **Blocked:** ${blocked} | **Errors:** ${errors}\n`
277
-
278
- return status
279
- },
280
- }),
235
+ mad_status: tool({
236
+ description: `Get the status of all MAD worktrees and their development progress.
237
+ Shows which tasks are done, in progress, blocked, or have errors.`,
238
+ args: {},
239
+ async execute(args, context) {
240
+ const gitRoot = getGitRoot()
241
+ const worktreeDir = join(gitRoot, "worktrees")
242
+
243
+ if (!existsSync(worktreeDir)) {
244
+ return "No active MAD worktrees. Use mad_worktree_create to create one."
245
+ }
246
+
247
+ const entries = readdirSync(worktreeDir)
248
+ if (entries.length === 0) {
249
+ return "No active MAD worktrees. Use mad_worktree_create to create one."
250
+ }
251
+
252
+ let status = "# MAD Status Dashboard\n\n"
253
+ let total = 0, done = 0, blocked = 0, errors = 0, wip = 0
254
+
255
+ for (const entry of entries) {
256
+ const wpath = join(worktreeDir, entry)
257
+ if (!statSync(wpath).isDirectory()) continue
258
+ total++
259
+
260
+ const taskFile = join(wpath, ".agent-task")
261
+ const doneFile = join(wpath, ".agent-done")
262
+ const blockedFile = join(wpath, ".agent-blocked")
263
+ const errorFile = join(wpath, ".agent-error")
264
+
265
+ let statusIcon = "⏳"
266
+ let statusText = "IN PROGRESS"
267
+ let detail = ""
268
+
269
+ if (existsSync(doneFile)) {
270
+ statusIcon = "✅"
271
+ statusText = "DONE"
272
+ detail = readFileSync(doneFile, "utf-8").split("\n")[0]
273
+ done++
274
+ } else if (existsSync(blockedFile)) {
275
+ statusIcon = "🚫"
276
+ statusText = "BLOCKED"
277
+ detail = readFileSync(blockedFile, "utf-8").split("\n")[0]
278
+ blocked++
279
+ } else if (existsSync(errorFile)) {
280
+ statusIcon = "❌"
281
+ statusText = "ERROR"
282
+ detail = readFileSync(errorFile, "utf-8").split("\n")[0]
283
+ errors++
284
+ } else {
285
+ wip++
286
+ }
287
+
288
+ // Get task description
289
+ const task = existsSync(taskFile)
290
+ ? readFileSync(taskFile, "utf-8").split("\n").filter(l => !l.startsWith("#") && l.trim()).join(" ").slice(0, 60)
291
+ : "No task file"
292
+
293
+ // Get commit count
294
+ let commits = "0"
295
+ try {
296
+ const baseBranch = getCurrentBranch()
297
+ const result = runCommand(`git log --oneline ${baseBranch}..HEAD`, wpath)
298
+ if (result.success) {
299
+ commits = result.output.split("\n").filter(l => l.trim()).length.toString()
300
+ }
301
+ } catch {}
302
+
303
+ status += `## ${statusIcon} ${entry}\n`
304
+ status += `- **Status:** ${statusText}\n`
305
+ status += `- **Task:** ${task}\n`
306
+ status += `- **Commits:** ${commits}\n`
307
+ if (detail) status += `- **Detail:** ${detail}\n`
308
+ status += `\n`
309
+ }
310
+
311
+ status += `---\n`
312
+ status += `**Total:** ${total} | **Done:** ${done} | **WIP:** ${wip} | **Blocked:** ${blocked} | **Errors:** ${errors}\n`
313
+
314
+ return status
315
+ },
316
+ }),
281
317
 
282
318
  /**
283
319
  * Run tests on a worktree
284
320
  */
285
- mad_test: tool({
286
- description: `Run tests/build/lint on a MAD worktree to verify the code is working.
287
- Automatically detects the project type (Node.js, Python, Go, Rust) and runs appropriate checks.
288
- Returns the results and creates an error file if tests fail.`,
289
- args: {
290
- worktree: tool.schema.string().describe("Worktree session name (e.g., 'feat-auth-login')"),
291
- },
292
- async execute(args, context) {
293
- const gitRoot = getGitRoot()
294
- const worktreePath = join(gitRoot, "worktrees", args.worktree)
295
-
296
- if (!existsSync(worktreePath)) {
297
- return `Worktree not found: ${worktreePath}`
298
- }
299
-
300
- let results = `# Test Results for ${args.worktree}\n\n`
301
- let hasError = false
302
- let errorMessages = ""
303
-
304
- // Helper to run a check
305
- const doCheck = (label: string, cmd: string) => {
306
- results += `## ${label}\n`
307
- const result = runCommand(cmd, worktreePath)
308
- if (result.success) {
309
- results += `✅ Passed\n\`\`\`\n${result.output.slice(0, 500)}\n\`\`\`\n\n`
310
- } else {
311
- hasError = true
312
- const output = result.error || "Unknown error"
313
- results += `❌ Failed\n\`\`\`\n${output.slice(0, 1000)}\n\`\`\`\n\n`
314
- errorMessages += `${label} FAILED:\n${output}\n\n`
315
- }
316
- }
317
-
318
- // Detect project type and run checks
319
- const packageJson = join(worktreePath, "package.json")
320
- const goMod = join(worktreePath, "go.mod")
321
- const cargoToml = join(worktreePath, "Cargo.toml")
322
- const pyProject = join(worktreePath, "pyproject.toml")
323
- const requirements = join(worktreePath, "requirements.txt")
324
-
325
- if (existsSync(packageJson)) {
326
- const pkg = JSON.parse(readFileSync(packageJson, "utf-8"))
327
- if (pkg.scripts?.lint) doCheck("Lint", "npm run lint")
328
- if (pkg.scripts?.build) doCheck("Build", "npm run build")
329
- if (pkg.scripts?.test) doCheck("Test", "npm test")
330
- }
331
-
332
- if (existsSync(goMod)) {
333
- doCheck("Go Build", "go build ./...")
334
- doCheck("Go Test", "go test ./...")
335
- }
336
-
337
- if (existsSync(cargoToml)) {
338
- doCheck("Cargo Check", "cargo check")
339
- doCheck("Cargo Test", "cargo test")
340
- }
341
-
342
- if (existsSync(pyProject) || existsSync(requirements)) {
343
- doCheck("Pytest", "pytest")
344
- }
345
-
346
- // Write error file if tests failed
347
- if (hasError) {
348
- writeFileSync(join(worktreePath, ".agent-error"), errorMessages)
349
- // Remove .agent-done since code is broken
350
- const doneFile = join(worktreePath, ".agent-done")
351
- if (existsSync(doneFile)) {
352
- unlinkSync(doneFile)
353
- }
354
- results += `\n---\n⚠️ Tests failed. Error details written to .agent-error. Use the fixer agent to resolve.`
355
- } else {
356
- results += `\n---\n✅ All checks passed!`
357
- }
358
-
359
- return results
360
- },
361
- }),
321
+ mad_test: tool({
322
+ description: `Run tests/build/lint on a MAD worktree to verify the code is working.
323
+ Automatically detects the project type (Node.js, Python, Go, Rust) and runs appropriate checks.
324
+ Returns the results and creates an error file if tests fail.`,
325
+ args: {
326
+ worktree: tool.schema.string().describe("Worktree session name (e.g., 'feat-auth-login')"),
327
+ },
328
+ async execute(args, context) {
329
+ const gitRoot = getGitRoot()
330
+ const worktreePath = join(gitRoot, "worktrees", args.worktree)
331
+
332
+ if (!existsSync(worktreePath)) {
333
+ return `Worktree not found: ${worktreePath}`
334
+ }
335
+
336
+ let results = `# Test Results for ${args.worktree}\n\n`
337
+ let hasError = false
338
+ let errorMessages = ""
339
+
340
+ // Helper to run a check
341
+ const doCheck = (label: string, cmd: string) => {
342
+ results += `## ${label}\n`
343
+ const result = runCommand(cmd, worktreePath)
344
+ if (result.success) {
345
+ results += `✅ Passed\n\`\`\`\n${result.output.slice(0, 500)}\n\`\`\`\n\n`
346
+ } else {
347
+ hasError = true
348
+ const output = result.error || "Unknown error"
349
+ results += `❌ Failed\n\`\`\`\n${output.slice(0, 1000)}\n\`\`\`\n\n`
350
+ errorMessages += `${label} FAILED:\n${output}\n\n`
351
+ }
352
+ }
353
+
354
+ // Detect project type and run checks
355
+ const packageJson = join(worktreePath, "package.json")
356
+ const goMod = join(worktreePath, "go.mod")
357
+ const cargoToml = join(worktreePath, "Cargo.toml")
358
+ const pyProject = join(worktreePath, "pyproject.toml")
359
+ const requirements = join(worktreePath, "requirements.txt")
360
+
361
+ if (existsSync(packageJson)) {
362
+ const pkg = JSON.parse(readFileSync(packageJson, "utf-8"))
363
+ if (pkg.scripts?.lint) doCheck("Lint", "npm run lint")
364
+ if (pkg.scripts?.build) doCheck("Build", "npm run build")
365
+ if (pkg.scripts?.test) doCheck("Test", "npm test")
366
+ }
367
+
368
+ if (existsSync(goMod)) {
369
+ doCheck("Go Build", "go build ./...")
370
+ doCheck("Go Test", "go test ./...")
371
+ }
372
+
373
+ if (existsSync(cargoToml)) {
374
+ doCheck("Cargo Check", "cargo check")
375
+ doCheck("Cargo Test", "cargo test")
376
+ }
377
+
378
+ if (existsSync(pyProject) || existsSync(requirements)) {
379
+ doCheck("Pytest", "pytest")
380
+ }
381
+
382
+ // Write error file if tests failed
383
+ if (hasError) {
384
+ writeFileSync(join(worktreePath, ".agent-error"), errorMessages)
385
+ // Remove .agent-done since code is broken
386
+ const doneFile = join(worktreePath, ".agent-done")
387
+ if (existsSync(doneFile)) {
388
+ unlinkSync(doneFile)
389
+ }
390
+ results += `\n---\n⚠️ Tests failed. Error details written to .agent-error. Use the fixer agent to resolve.`
391
+ } else {
392
+ results += `\n---\n✅ All checks passed!`
393
+ }
394
+
395
+ return results
396
+ },
397
+ }),
362
398
 
363
399
  /**
364
400
  * Merge completed worktrees
365
401
  */
366
- mad_merge: tool({
367
- description: `Merge a completed worktree branch back into the current branch.
368
- Only merges if the worktree is marked as done (.agent-done exists).
369
- Handles merge conflicts by reporting them.`,
370
- args: {
371
- worktree: tool.schema.string().describe("Worktree session name to merge (e.g., 'feat-auth-login')"),
372
- },
373
- async execute(args, context) {
374
- const gitRoot = getGitRoot()
375
- const worktreePath = join(gitRoot, "worktrees", args.worktree)
376
- const doneFile = join(worktreePath, ".agent-done")
377
- const branch = args.worktree.replace(/-/g, "/")
378
-
379
- if (!existsSync(worktreePath)) {
380
- return `Worktree not found: ${worktreePath}`
381
- }
382
-
383
- if (!existsSync(doneFile)) {
384
- return `Cannot merge: worktree ${args.worktree} is not marked as done. Complete the task first.`
385
- }
386
-
387
- const result = runCommand(`git merge ${branch} --no-edit`, gitRoot)
388
- if (result.success) {
389
- return `✅ Successfully merged ${branch}!\n\n${result.output}`
390
- } else {
391
- const output = result.error || "Unknown error"
392
- if (output.includes("CONFLICT")) {
393
- return `⚠️ Merge conflict detected!\n\n${output}\n\nResolve conflicts manually or use the fixer agent.`
394
- }
395
- return `❌ Merge failed:\n${output}`
396
- }
397
- },
398
- }),
402
+ mad_merge: tool({
403
+ description: `Merge a completed worktree branch back into the current branch.
404
+ Only merges if the worktree is marked as done (.agent-done exists).
405
+ Handles merge conflicts by reporting them.`,
406
+ args: {
407
+ worktree: tool.schema.string().describe("Worktree session name to merge (e.g., 'feat-auth-login')"),
408
+ },
409
+ async execute(args, context) {
410
+ const gitRoot = getGitRoot()
411
+ const worktreePath = join(gitRoot, "worktrees", args.worktree)
412
+ const doneFile = join(worktreePath, ".agent-done")
413
+ const branch = args.worktree.replace(/-/g, "/")
414
+
415
+ if (!existsSync(worktreePath)) {
416
+ return `Worktree not found: ${worktreePath}`
417
+ }
418
+
419
+ if (!existsSync(doneFile)) {
420
+ return `Cannot merge: worktree ${args.worktree} is not marked as done. Complete the task first.`
421
+ }
422
+
423
+ const result = runCommand(`git merge ${branch} --no-edit`, gitRoot)
424
+ if (result.success) {
425
+ return `✅ Successfully merged ${branch}!\n\n${result.output}`
426
+ } else {
427
+ const output = result.error || "Unknown error"
428
+ if (output.includes("CONFLICT")) {
429
+ return `⚠️ Merge conflict detected!\n\n${output}\n\nResolve conflicts manually or use the fixer agent.`
430
+ }
431
+ return `❌ Merge failed:\n${output}`
432
+ }
433
+ },
434
+ }),
399
435
 
400
436
  /**
401
437
  * Cleanup finished worktrees
@@ -480,174 +516,205 @@ Use this when you cannot proceed due to missing information or dependencies.`,
480
516
  },
481
517
  }),
482
518
 
483
- /**
484
- * Read task from a worktree
485
- */
486
- mad_read_task: tool({
487
- description: `Read the task description from a worktree's .agent-task file.
488
- Use this to understand what needs to be done in a specific worktree.`,
489
- args: {
490
- worktree: tool.schema.string().describe("Worktree session name"),
491
- },
492
- async execute(args, context) {
493
- const gitRoot = await getGitRoot()
494
- const taskFile = join(gitRoot, "worktrees", args.worktree, ".agent-task")
495
-
496
- if (!existsSync(taskFile)) {
497
- return `Task file not found: ${taskFile}`
498
- }
499
-
500
- return readFileSync(taskFile, "utf-8")
501
- },
502
- }),
503
-
504
- /**
505
- * Log MAD orchestration events
506
- */
507
- mad_log: tool({
508
- description: `Log MAD orchestration events for debugging and monitoring.
509
- Creates structured logs in .mad-logs.jsonl for tracking the workflow.`,
510
- args: {
511
- level: tool.schema.enum(["info", "warn", "error", "debug"]).describe("Log level"),
512
- message: tool.schema.string().describe("Log message"),
513
- context: tool.schema.object({}).optional().describe("Additional context data")
514
- },
515
- async execute(args, context) {
516
- try {
517
- await logEvent(args.level as "info" | "warn" | "error" | "debug", args.message, args.context)
518
- return `📝 Logged [${args.level.toUpperCase()}]: ${args.message}`
519
- } catch (e: any) {
520
- return `⚠️ Failed to write log: ${e.message}`
521
- }
522
- },
523
- }),
524
-
525
- /**
526
- * Visualize MAD workflow with ASCII art
527
- */
528
- mad_visualize: tool({
529
- description: `Generate an ASCII art visualization of the MAD orchestration status.
530
- Shows progress, worktree statuses, timeline, and statistics in a beautiful dashboard.`,
531
- args: {},
532
- async execute(args, context) {
533
- try {
534
- const gitRoot = await getGitRoot()
535
- const worktreeDir = join(gitRoot, "worktrees")
536
-
537
- if (!existsSync(worktreeDir)) {
538
- return "No active MAD worktrees. Use mad_worktree_create to create one."
539
- }
540
-
541
- const entries = readdirSync(worktreeDir)
542
- if (entries.length === 0) {
543
- return "No active MAD worktrees. Use mad_worktree_create to create one."
544
- }
545
-
546
- let total = 0, done = 0, blocked = 0, errors = 0, wip = 0
547
- const worktrees: any[] = []
548
-
549
- for (const entry of entries) {
550
- const wpath = join(worktreeDir, entry)
551
- if (!statSync(wpath).isDirectory()) continue
552
- total++
553
-
554
- const taskFile = join(wpath, ".agent-task")
555
- const doneFile = join(wpath, ".agent-done")
556
- const blockedFile = join(wpath, ".agent-blocked")
557
- const errorFile = join(wpath, ".agent-error")
558
-
559
- let status = "IN PROGRESS"
560
- let icon = "⏳"
561
- let detail = ""
562
-
563
- if (existsSync(doneFile)) {
564
- icon = "✅"
565
- status = "DONE"
566
- detail = readFileSync(doneFile, "utf-8").split("\n")[0]
567
- done++
568
- } else if (existsSync(blockedFile)) {
569
- icon = "🚫"
570
- status = "BLOCKED"
571
- detail = readFileSync(blockedFile, "utf-8").split("\n")[0]
572
- blocked++
573
- } else if (existsSync(errorFile)) {
574
- icon = "❌"
575
- status = "ERROR"
576
- detail = readFileSync(errorFile, "utf-8").split("\n")[0]
577
- errors++
578
- } else {
579
- wip++
580
- }
581
-
582
- const task = existsSync(taskFile)
583
- ? readFileSync(taskFile, "utf-8").split("\n").filter(l => !l.startsWith("#") && l.trim()).join(" ").slice(0, 50)
584
- : "No task file"
585
-
586
- // Get commit count
587
- const branch = entry.replace(/-/g, "/")
588
- let commits = "0"
589
- try {
590
- const baseBranch = await getCurrentBranch()
591
- const result = await runCommand(`git -C "${wpath}" log --oneline ${baseBranch}..HEAD 2>/dev/null | wc -l`)
592
- commits = result.output.trim() || "0"
593
- } catch {}
594
-
595
- worktrees.push({ name: entry, status, icon, detail, task, commits })
596
- }
597
-
598
- // Calculate progress
599
- const progress = total > 0 ? Math.round((done / total) * 100) : 0
600
- const progressBar = "█".repeat(Math.floor(progress / 5)) + "░".repeat(20 - Math.floor(progress / 5))
601
-
602
- // Build visualization
603
- let output = `
604
- ┌────────────────────────────────────────────────────────────────┐
605
- │ MAD ORCHESTRATION DASHBOARD │
606
- └────────────────────────────────────────────────────────────────┘
607
-
608
- 📊 Progress: [${progressBar}] ${progress}% (${done}/${total} tasks complete)
609
-
610
- ┌─ Worktree Status ─────────────────────────────────────────────┐
611
- │ │
612
- `
613
-
614
- for (const wt of worktrees) {
615
- const statusPadded = wt.status.padEnd(15)
616
- output += `│ ${wt.icon} ${wt.name.padEnd(35)} [${statusPadded}] │\n`
617
- output += `│ └─ ${wt.commits} commits │ ${wt.task.padEnd(38)} │\n`
618
- if (wt.detail) {
619
- output += `│ └─ ${wt.detail.slice(0, 50).padEnd(50)} │\n`
620
- }
621
- output += `│ │\n`
622
- }
623
-
624
- output += `└────────────────────────────────────────────────────────────────┘
625
-
626
- ┌─ Statistics ──────────────────────────────────────────────────┐
627
- │ │
628
- │ Total Worktrees: ${total.toString().padEnd(40)} │
629
- │ ✅ Completed: ${done} (${Math.round(done/total*100)}%)${' '.repeat(40 - done.toString().length - 7)} │
630
- │ ⏳ In Progress: ${wip} (${Math.round(wip/total*100)}%)${' '.repeat(40 - wip.toString().length - 7)} │
631
- │ 🚫 Blocked: ${blocked} (${Math.round(blocked/total*100)}%)${' '.repeat(40 - blocked.toString().length - 7)} │
632
- │ ❌ Errors: ${errors} (${Math.round(errors/total*100)}%)${' '.repeat(40 - errors.toString().length - 7)} │
633
- │ │
634
- └────────────────────────────────────────────────────────────────┘
635
- `
636
-
637
- if (blocked > 0 || errors > 0) {
638
- output += `\n💡 Next Actions:\n`
639
- if (errors > 0) output += ` • Fix ${errors} errored worktree(s) (check .agent-error files)\n`
640
- if (blocked > 0) output += ` • Unblock ${blocked} blocked worktree(s)\n`
641
- if (done > 0) output += ` • Ready to merge: ${worktrees.filter(w => w.status === "DONE").map(w => w.name).join(", ")}\n`
642
- }
643
-
644
- return output
645
- } catch (e: any) {
646
- return `❌ Error generating visualization: ${e.message}`
647
- }
648
- },
649
- }),
650
- },
519
+ /**
520
+ * Read task from a worktree
521
+ */
522
+ mad_read_task: tool({
523
+ description: `Read the task description from a worktree's .agent-task file.
524
+ Use this to understand what needs to be done in a specific worktree.`,
525
+ args: {
526
+ worktree: tool.schema.string().describe("Worktree session name"),
527
+ },
528
+ async execute(args, context) {
529
+ const gitRoot = await getGitRoot()
530
+ const taskFile = join(gitRoot, "worktrees", args.worktree, ".agent-task")
531
+
532
+ if (!existsSync(taskFile)) {
533
+ return `Task file not found: ${taskFile}`
534
+ }
535
+
536
+ return readFileSync(taskFile, "utf-8")
537
+ },
538
+ }),
539
+
540
+ /**
541
+ * Log MAD orchestration events
542
+ */
543
+ mad_log: tool({
544
+ description: `Log MAD orchestration events for debugging and monitoring.
545
+ Creates structured logs in .mad-logs.jsonl for tracking the workflow.`,
546
+ args: {
547
+ level: tool.schema.enum(["info", "warn", "error", "debug"]).describe("Log level"),
548
+ message: tool.schema.string().describe("Log message"),
549
+ context: tool.schema.object({}).optional().describe("Additional context data")
550
+ },
551
+ async execute(args, context) {
552
+ try {
553
+ await logEvent(args.level as "info" | "warn" | "error" | "debug", args.message, args.context)
554
+ return `📝 Logged [${args.level.toUpperCase()}]: ${args.message}`
555
+ } catch (e: any) {
556
+ return `⚠️ Failed to write log: ${e.message}`
557
+ }
558
+ },
559
+ }),
560
+
561
+ /**
562
+ * Visualize MAD workflow with ASCII art
563
+ */
564
+ mad_visualize: tool({
565
+ description: `Generate an ASCII art visualization of the MAD orchestration status.
566
+ Shows progress, worktree statuses, timeline, and statistics in a beautiful dashboard.`,
567
+ args: {},
568
+ async execute(args, context) {
569
+ try {
570
+ const gitRoot = await getGitRoot()
571
+ const worktreeDir = join(gitRoot, "worktrees")
572
+
573
+ if (!existsSync(worktreeDir)) {
574
+ return "No active MAD worktrees. Use mad_worktree_create to create one."
575
+ }
576
+
577
+ const entries = readdirSync(worktreeDir)
578
+ if (entries.length === 0) {
579
+ return "No active MAD worktrees. Use mad_worktree_create to create one."
580
+ }
581
+
582
+ let total = 0, done = 0, blocked = 0, errors = 0, wip = 0
583
+ const worktrees: any[] = []
584
+
585
+ for (const entry of entries) {
586
+ const wpath = join(worktreeDir, entry)
587
+ if (!statSync(wpath).isDirectory()) continue
588
+ total++
589
+
590
+ const taskFile = join(wpath, ".agent-task")
591
+ const doneFile = join(wpath, ".agent-done")
592
+ const blockedFile = join(wpath, ".agent-blocked")
593
+ const errorFile = join(wpath, ".agent-error")
594
+
595
+ let status = "IN PROGRESS"
596
+ let icon = "⏳"
597
+ let detail = ""
598
+
599
+ if (existsSync(doneFile)) {
600
+ icon = "✅"
601
+ status = "DONE"
602
+ detail = readFileSync(doneFile, "utf-8").split("\n")[0]
603
+ done++
604
+ } else if (existsSync(blockedFile)) {
605
+ icon = "🚫"
606
+ status = "BLOCKED"
607
+ detail = readFileSync(blockedFile, "utf-8").split("\n")[0]
608
+ blocked++
609
+ } else if (existsSync(errorFile)) {
610
+ icon = "❌"
611
+ status = "ERROR"
612
+ detail = readFileSync(errorFile, "utf-8").split("\n")[0]
613
+ errors++
614
+ } else {
615
+ wip++
616
+ }
617
+
618
+ const task = existsSync(taskFile)
619
+ ? readFileSync(taskFile, "utf-8").split("\n").filter(l => !l.startsWith("#") && l.trim()).join(" ").slice(0, 50)
620
+ : "No task file"
621
+
622
+ // Get commit count
623
+ const branch = entry.replace(/-/g, "/")
624
+ let commits = "0"
625
+ try {
626
+ const baseBranch = await getCurrentBranch()
627
+ const result = await runCommand(`git -C "${wpath}" log --oneline ${baseBranch}..HEAD 2>/dev/null | wc -l`)
628
+ commits = result.output.trim() || "0"
629
+ } catch {}
630
+
631
+ worktrees.push({ name: entry, status, icon, detail, task, commits })
632
+ }
633
+
634
+ // Calculate progress
635
+ const progress = total > 0 ? Math.round((done / total) * 100) : 0
636
+ const progressBar = "█".repeat(Math.floor(progress / 5)) + "░".repeat(20 - Math.floor(progress / 5))
637
+
638
+ // Build visualization
639
+ let output = `
640
+ ┌────────────────────────────────────────────────────────────────┐
641
+ │ MAD ORCHESTRATION DASHBOARD │
642
+ └────────────────────────────────────────────────────────────────┘
643
+
644
+ 📊 Progress: [${progressBar}] ${progress}% (${done}/${total} tasks complete)
645
+
646
+ ┌─ Worktree Status ─────────────────────────────────────────────┐
647
+ │ │
648
+ `
649
+
650
+ for (const wt of worktrees) {
651
+ const statusPadded = wt.status.padEnd(15)
652
+ output += `│ ${wt.icon} ${wt.name.padEnd(35)} [${statusPadded}] │\n`
653
+ output += `│ └─ ${wt.commits} commits │ ${wt.task.padEnd(38)} │\n`
654
+ if (wt.detail) {
655
+ output += `│ └─ ${wt.detail.slice(0, 50).padEnd(50)} │\n`
656
+ }
657
+ output += `│ │\n`
658
+ }
659
+
660
+ output += `└────────────────────────────────────────────────────────────────┘
661
+
662
+ ┌─ Statistics ──────────────────────────────────────────────────┐
663
+ │ │
664
+ │ Total Worktrees: ${total.toString().padEnd(40)} │
665
+ │ ✅ Completed: ${done} (${Math.round(done/total*100)}%)${' '.repeat(40 - done.toString().length - 7)} │
666
+ │ ⏳ In Progress: ${wip} (${Math.round(wip/total*100)}%)${' '.repeat(40 - wip.toString().length - 7)} │
667
+ │ 🚫 Blocked: ${blocked} (${Math.round(blocked/total*100)}%)${' '.repeat(40 - blocked.toString().length - 7)} │
668
+ │ ❌ Errors: ${errors} (${Math.round(errors/total*100)}%)${' '.repeat(40 - errors.toString().length - 7)} │
669
+ │ │
670
+ └────────────────────────────────────────────────────────────────┘
671
+ `
672
+
673
+ if (blocked > 0 || errors > 0) {
674
+ output += `\n💡 Next Actions:\n`
675
+ if (errors > 0) output += ` • Fix ${errors} errored worktree(s) (check .agent-error files)\n`
676
+ if (blocked > 0) output += ` • Unblock ${blocked} blocked worktree(s)\n`
677
+ if (done > 0) output += ` • Ready to merge: ${worktrees.filter(w => w.status === "DONE").map(w => w.name).join(", ")}\n`
678
+ }
679
+
680
+ return output
681
+ } catch (e: any) {
682
+ return `❌ Error generating visualization: ${e.message}`
683
+ }
684
+ },
685
+ }),
686
+
687
+ /**
688
+ * Check for opencode-mad updates
689
+ */
690
+ mad_check_update: tool({
691
+ description: `Check if a newer version of opencode-mad is available on npm.
692
+ Returns the current version, latest version, and whether an update is available.`,
693
+ args: {},
694
+ async execute(args, context) {
695
+ try {
696
+ const updateInfo = await checkForUpdates()
697
+
698
+ if (updateInfo.hasUpdate) {
699
+ return `🔄 Update available!
700
+
701
+ Current version: ${updateInfo.current}
702
+ Latest version: ${updateInfo.latest}
703
+
704
+ To update, run:
705
+ npx opencode-mad install -g`
706
+ } else {
707
+ return `✅ You're up to date!
708
+
709
+ Current version: ${updateInfo.current}
710
+ Latest version: ${updateInfo.latest}`
711
+ }
712
+ } catch (e: any) {
713
+ return `❌ Failed to check for updates: ${e.message}`
714
+ }
715
+ },
716
+ }),
717
+ },
651
718
 
652
719
  // Event hooks
653
720
  event: async ({ event }) => {