@miphamai/cli 0.9.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/mipham +0 -0
- package/package.json +1 -1
- package/src/agent/agent-context.ts +60 -1
- package/src/agent/agent-registry.ts +1 -0
- package/src/agent/background-registry.ts +192 -0
- package/src/agent/message-bus.ts +113 -0
- package/src/agent/sub-agent.ts +101 -3
- package/src/agent/types.ts +6 -0
- package/src/agent-view/agent-view-manager.ts +10 -0
- package/src/agent-view/dashboard.tsx +20 -1
- package/src/core/engine.ts +90 -0
- package/src/core/hooks.ts +48 -0
- package/src/core/output-styles.ts +95 -0
- package/src/core/rules-loader.ts +175 -0
- package/src/mcp/client.ts +9 -0
- package/src/shared/types.ts +16 -1
- package/src/tools/agent/agent.ts +41 -2
- package/src/tools/agent/enter-plan.ts +92 -0
- package/src/tools/agent/exit-plan.ts +54 -0
- package/src/tools/agent/report-findings.ts +186 -0
- package/src/tools/agent/send-message.ts +67 -0
- package/src/tools/agent/workflow.ts +25 -5
- package/src/tools/exec/enter-worktree.ts +142 -0
- package/src/tools/exec/exit-worktree.ts +162 -0
- package/src/tools/exec/task.ts +215 -22
- package/src/tools/index.ts +14 -0
- package/src/tools/system/tool-search.ts +143 -0
- package/src/ui/app.tsx +49 -19
- package/src/ui/commands.ts +172 -55
- package/src/workflow/primitives/agent.ts +77 -15
- package/src/workflow/runtime.ts +84 -7
- package/src/workflow/schema-validator.ts +162 -0
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { ToolDefinition } from '../../shared/index.ts'
|
|
2
|
+
|
|
3
|
+
export const exitPlanModeTool: ToolDefinition = {
|
|
4
|
+
name: 'ExitPlanMode',
|
|
5
|
+
description:
|
|
6
|
+
'Exit plan mode and submit your plan for approval. ' +
|
|
7
|
+
'Set approved: true to switch to acceptEdits mode (code changes allowed). ' +
|
|
8
|
+
'Set approved: false to revert to default mode. ' +
|
|
9
|
+
'Use this after you have finished designing your approach in plan mode.',
|
|
10
|
+
category: 'agent',
|
|
11
|
+
permission: 'auto',
|
|
12
|
+
parameters: {
|
|
13
|
+
type: 'object',
|
|
14
|
+
properties: {
|
|
15
|
+
approved: {
|
|
16
|
+
type: 'boolean',
|
|
17
|
+
description:
|
|
18
|
+
'Whether the user approved the plan. true → switch to acceptEdits mode. false → revert to default.',
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
required: ['approved'],
|
|
22
|
+
},
|
|
23
|
+
async execute(params, _ctx) {
|
|
24
|
+
const approved = params.approved === true
|
|
25
|
+
|
|
26
|
+
if (approved) {
|
|
27
|
+
return {
|
|
28
|
+
success: true,
|
|
29
|
+
content: [
|
|
30
|
+
'── Plan Approved ──',
|
|
31
|
+
'',
|
|
32
|
+
'✓ Exiting plan mode.',
|
|
33
|
+
'✓ Switching to acceptEdits mode — reads and file edits are auto-approved.',
|
|
34
|
+
'',
|
|
35
|
+
'You can now implement the plan. The plan file is in .mipham/plans/.',
|
|
36
|
+
'',
|
|
37
|
+
'Use Shift+Tab to cycle permission modes if you need to change.',
|
|
38
|
+
].join('\n'),
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
success: true,
|
|
44
|
+
content: [
|
|
45
|
+
'── Plan Mode Exited ──',
|
|
46
|
+
'',
|
|
47
|
+
'✓ Returning to default permission mode.',
|
|
48
|
+
'',
|
|
49
|
+
'No code changes were made. The plan file is preserved in .mipham/plans/.',
|
|
50
|
+
'Use EnterPlanMode again when ready to resume planning.',
|
|
51
|
+
].join('\n'),
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import type { ToolDefinition } from '../../shared/index.ts'
|
|
2
|
+
|
|
3
|
+
export const reportFindingsTool: ToolDefinition = {
|
|
4
|
+
name: 'ReportFindings',
|
|
5
|
+
description:
|
|
6
|
+
'Report code-review findings as a typed list. ' +
|
|
7
|
+
'Use this to output structured review results with file, line, summary, ' +
|
|
8
|
+
'failure_scenario, and category. Findings are ranked most-severe first.',
|
|
9
|
+
category: 'agent',
|
|
10
|
+
permission: 'auto',
|
|
11
|
+
parameters: {
|
|
12
|
+
type: 'object',
|
|
13
|
+
properties: {
|
|
14
|
+
level: {
|
|
15
|
+
type: 'string',
|
|
16
|
+
enum: ['low', 'medium', 'high', 'xhigh', 'max'],
|
|
17
|
+
description: 'Effort level the review ran at.',
|
|
18
|
+
},
|
|
19
|
+
findings: {
|
|
20
|
+
type: 'array',
|
|
21
|
+
description: 'Verified findings, most-severe first; empty if none survived.',
|
|
22
|
+
minItems: 0,
|
|
23
|
+
maxItems: 32,
|
|
24
|
+
items: {
|
|
25
|
+
type: 'object',
|
|
26
|
+
properties: {
|
|
27
|
+
file: {
|
|
28
|
+
type: 'string',
|
|
29
|
+
description: 'Repo-relative path of the file the finding is in.',
|
|
30
|
+
},
|
|
31
|
+
line: {
|
|
32
|
+
type: 'integer',
|
|
33
|
+
description: '1-indexed line the finding anchors to.',
|
|
34
|
+
},
|
|
35
|
+
summary: {
|
|
36
|
+
type: 'string',
|
|
37
|
+
description: 'One-sentence statement of the defect.',
|
|
38
|
+
},
|
|
39
|
+
short_summary: {
|
|
40
|
+
type: 'string',
|
|
41
|
+
maxLength: 60,
|
|
42
|
+
description: 'Compressed label for compact UI (≤60 chars).',
|
|
43
|
+
},
|
|
44
|
+
failure_scenario: {
|
|
45
|
+
type: 'string',
|
|
46
|
+
description: 'Concrete inputs/state → wrong output/crash.',
|
|
47
|
+
},
|
|
48
|
+
category: {
|
|
49
|
+
type: 'string',
|
|
50
|
+
maxLength: 40,
|
|
51
|
+
description:
|
|
52
|
+
'Short kebab-case slug: correctness, security, performance, simplification, test-coverage, etc.',
|
|
53
|
+
},
|
|
54
|
+
verdict: {
|
|
55
|
+
type: 'string',
|
|
56
|
+
enum: ['CONFIRMED', 'PLAUSIBLE'],
|
|
57
|
+
description: 'Set when a verify pass ran; absent on inline-only reviews.',
|
|
58
|
+
},
|
|
59
|
+
outcome: {
|
|
60
|
+
type: 'string',
|
|
61
|
+
enum: ['fixed', 'skipped', 'no_change_needed'],
|
|
62
|
+
description:
|
|
63
|
+
'Set ONLY when re-reporting after applying fixes: what happened to this finding.',
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
required: ['file', 'summary', 'failure_scenario'],
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
required: ['findings'],
|
|
71
|
+
},
|
|
72
|
+
|
|
73
|
+
async execute(params, _ctx) {
|
|
74
|
+
const findings = params.findings as Array<Record<string, unknown>> | undefined
|
|
75
|
+
const level = (params.level as string) || 'medium'
|
|
76
|
+
|
|
77
|
+
if (!Array.isArray(findings)) {
|
|
78
|
+
return {
|
|
79
|
+
success: false,
|
|
80
|
+
content: '',
|
|
81
|
+
error: 'findings must be an array',
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Validate each finding has required fields
|
|
86
|
+
const errors: string[] = []
|
|
87
|
+
for (let i = 0; i < findings.length; i++) {
|
|
88
|
+
const f = findings[i]!
|
|
89
|
+
if (!f.file || typeof f.file !== 'string') {
|
|
90
|
+
errors.push(`findings[${i}]: "file" is required and must be a string`)
|
|
91
|
+
}
|
|
92
|
+
if (!f.summary || typeof f.summary !== 'string') {
|
|
93
|
+
errors.push(`findings[${i}]: "summary" is required and must be a string`)
|
|
94
|
+
}
|
|
95
|
+
if (!f.failure_scenario || typeof f.failure_scenario !== 'string') {
|
|
96
|
+
errors.push(`findings[${i}]: "failure_scenario" is required and must be a string`)
|
|
97
|
+
}
|
|
98
|
+
if (f.line !== undefined && typeof f.line !== 'number') {
|
|
99
|
+
errors.push(`findings[${i}]: "line" must be an integer if provided`)
|
|
100
|
+
}
|
|
101
|
+
if (f.verdict && !['CONFIRMED', 'PLAUSIBLE'].includes(f.verdict as string)) {
|
|
102
|
+
errors.push(`findings[${i}]: "verdict" must be CONFIRMED or PLAUSIBLE`)
|
|
103
|
+
}
|
|
104
|
+
if (f.outcome && !['fixed', 'skipped', 'no_change_needed'].includes(f.outcome as string)) {
|
|
105
|
+
errors.push(`findings[${i}]: "outcome" must be fixed, skipped, or no_change_needed`)
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (errors.length > 0) {
|
|
110
|
+
return {
|
|
111
|
+
success: false,
|
|
112
|
+
content: '',
|
|
113
|
+
error: `Validation errors:\n${errors.map((e) => ` • ${e}`).join('\n')}`,
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (findings.length === 0) {
|
|
118
|
+
return {
|
|
119
|
+
success: true,
|
|
120
|
+
content: `── Review Findings (${level} effort) ──\n\n✅ No findings to report.\n\nAll checks passed at ${level} effort level.`,
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Format findings as a structured report
|
|
125
|
+
const lines: string[] = [
|
|
126
|
+
`── Review Findings (${level} effort) ──`,
|
|
127
|
+
'',
|
|
128
|
+
`${findings.length} finding${findings.length === 1 ? '' : 's'}:`,
|
|
129
|
+
'',
|
|
130
|
+
]
|
|
131
|
+
|
|
132
|
+
// Group by category for readability
|
|
133
|
+
const byCategory = new Map<string, Array<Record<string, unknown>>>()
|
|
134
|
+
for (const f of findings) {
|
|
135
|
+
const cat = (f.category as string) || 'uncategorized'
|
|
136
|
+
const list = byCategory.get(cat) || []
|
|
137
|
+
list.push(f)
|
|
138
|
+
byCategory.set(cat, list)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const CATEGORY_ICONS: Record<string, string> = {
|
|
142
|
+
correctness: '🔴',
|
|
143
|
+
security: '🔒',
|
|
144
|
+
performance: '⚡',
|
|
145
|
+
simplification: '🧹',
|
|
146
|
+
efficiency: '⏱️',
|
|
147
|
+
'test-coverage': '🧪',
|
|
148
|
+
architecture: '🏗️',
|
|
149
|
+
maintainability: '🔧',
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
for (const [category, items] of byCategory) {
|
|
153
|
+
const icon = CATEGORY_ICONS[category] || '📌'
|
|
154
|
+
lines.push(`${icon} ${category} (${items.length}):`)
|
|
155
|
+
for (const f of items) {
|
|
156
|
+
const verdict = f.verdict ? ` [${f.verdict}]` : ''
|
|
157
|
+
const outcome = f.outcome ? ` → ${f.outcome}` : ''
|
|
158
|
+
const lineRef = f.line ? `:${f.line}` : ''
|
|
159
|
+
lines.push(` • ${f.file}${lineRef}${verdict}${outcome}`)
|
|
160
|
+
lines.push(` ${f.summary}`)
|
|
161
|
+
if (f.failure_scenario) {
|
|
162
|
+
const scenario =
|
|
163
|
+
(f.failure_scenario as string).length > 120
|
|
164
|
+
? (f.failure_scenario as string).slice(0, 120) + '...'
|
|
165
|
+
: (f.failure_scenario as string)
|
|
166
|
+
lines.push(` 💥 ${scenario}`)
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
lines.push('')
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Severity summary
|
|
173
|
+
const confirmed = findings.filter((f) => f.verdict === 'CONFIRMED').length
|
|
174
|
+
const plausible = findings.filter((f) => f.verdict === 'PLAUSIBLE').length
|
|
175
|
+
if (confirmed > 0 || plausible > 0) {
|
|
176
|
+
lines.push(
|
|
177
|
+
`📊 ${confirmed} CONFIRMED · ${plausible} PLAUSIBLE · ${findings.length - confirmed - plausible} unverified`,
|
|
178
|
+
)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return {
|
|
182
|
+
success: true,
|
|
183
|
+
content: lines.join('\n'),
|
|
184
|
+
}
|
|
185
|
+
},
|
|
186
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { ToolDefinition } from '../../shared/index.ts'
|
|
2
|
+
import { getMessageBus } from '../../agent/message-bus'
|
|
3
|
+
|
|
4
|
+
export const sendMessageTool: ToolDefinition = {
|
|
5
|
+
name: 'SendMessage',
|
|
6
|
+
description:
|
|
7
|
+
'Send a message to another agent or the main conversation. ' +
|
|
8
|
+
'Use "main" as the recipient to message the parent session, ' +
|
|
9
|
+
'or a background task ID (e.g., "bg-1-xxx") to message a specific agent. ' +
|
|
10
|
+
'Messages are stored in the AgentMessageBus and can be polled by the recipient.',
|
|
11
|
+
category: 'agent',
|
|
12
|
+
permission: 'auto',
|
|
13
|
+
parameters: {
|
|
14
|
+
type: 'object',
|
|
15
|
+
properties: {
|
|
16
|
+
to: {
|
|
17
|
+
type: 'string',
|
|
18
|
+
description:
|
|
19
|
+
'Recipient: "main" for the parent conversation, a background task ID, or an agent name.',
|
|
20
|
+
},
|
|
21
|
+
summary: {
|
|
22
|
+
type: 'string',
|
|
23
|
+
description: 'A 5-10 word summary shown as a one-line preview (max 200 chars).',
|
|
24
|
+
},
|
|
25
|
+
message: {
|
|
26
|
+
type: 'string',
|
|
27
|
+
description: 'Plain text message content.',
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
required: ['to', 'message'],
|
|
31
|
+
},
|
|
32
|
+
async execute(params, ctx) {
|
|
33
|
+
const to = params.to as string
|
|
34
|
+
const summary = (params.summary as string) || '(no subject)'
|
|
35
|
+
const message = params.message as string
|
|
36
|
+
|
|
37
|
+
// Determine sender: use sessionId or 'main'
|
|
38
|
+
const from =
|
|
39
|
+
ctx.sessionId === 'sub-agent'
|
|
40
|
+
? `sub-agent-${Date.now().toString(36)}`
|
|
41
|
+
: ctx.sessionId || 'main'
|
|
42
|
+
|
|
43
|
+
try {
|
|
44
|
+
const bus = getMessageBus()
|
|
45
|
+
const msgId = bus.post(from, to, summary, message)
|
|
46
|
+
|
|
47
|
+
const unreadForRecipient = bus.unreadCount(to)
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
success: true,
|
|
51
|
+
content:
|
|
52
|
+
`── Message Sent ──\n\n` +
|
|
53
|
+
`ID: ${msgId}\n` +
|
|
54
|
+
`From: ${from}\n` +
|
|
55
|
+
`To: ${to}\n` +
|
|
56
|
+
`Summary: ${summary.slice(0, 100)}\n\n` +
|
|
57
|
+
`The recipient has ${unreadForRecipient} unread message(s).`,
|
|
58
|
+
}
|
|
59
|
+
} catch (err) {
|
|
60
|
+
return {
|
|
61
|
+
success: false,
|
|
62
|
+
content: '',
|
|
63
|
+
error: `Failed to send message: ${String(err)}`,
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
}
|
|
@@ -5,7 +5,9 @@ import type { QueryEngine } from '../../core/engine'
|
|
|
5
5
|
export const workflowTool: ToolDefinition = {
|
|
6
6
|
name: 'Workflow',
|
|
7
7
|
description:
|
|
8
|
-
'Execute a multi-agent workflow script. The script uses agent(), parallel(), pipeline(), phase(), log(), args, budget primitives.
|
|
8
|
+
'Execute a multi-agent workflow script. The script uses agent(), parallel(), pipeline(), phase(), log(), args, budget primitives. ' +
|
|
9
|
+
'Pass resumeFromRunId to resume a prior run — cached agent() calls are replayed from the journal, ' +
|
|
10
|
+
'and only new/changed calls execute live.',
|
|
9
11
|
category: 'agent',
|
|
10
12
|
permission: 'ask',
|
|
11
13
|
parameters: {
|
|
@@ -20,12 +22,20 @@ export const workflowTool: ToolDefinition = {
|
|
|
20
22
|
type: 'object',
|
|
21
23
|
description: 'Arguments to pass to the workflow script as the `args` global',
|
|
22
24
|
},
|
|
25
|
+
resumeFromRunId: {
|
|
26
|
+
type: 'string',
|
|
27
|
+
description:
|
|
28
|
+
'Run ID of a prior Workflow invocation to resume from. ' +
|
|
29
|
+
'Completed agent() calls with unchanged (prompt, opts) return cached results; ' +
|
|
30
|
+
'only edited or new calls re-run.',
|
|
31
|
+
},
|
|
23
32
|
},
|
|
24
33
|
required: ['script'],
|
|
25
34
|
},
|
|
26
35
|
async execute(params, ctx) {
|
|
27
36
|
const script = params.script as string
|
|
28
37
|
const args = (params.args as Record<string, unknown>) || {}
|
|
38
|
+
const resumeFromRunId = params.resumeFromRunId as string | undefined
|
|
29
39
|
|
|
30
40
|
if (!ctx.registry || !ctx.toolRegistry) {
|
|
31
41
|
return {
|
|
@@ -42,11 +52,21 @@ export const workflowTool: ToolDefinition = {
|
|
|
42
52
|
} as QueryEngine
|
|
43
53
|
|
|
44
54
|
try {
|
|
45
|
-
const { runId, result } = await runWorkflow(
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
55
|
+
const { runId, result, cacheHits, cacheMisses } = await runWorkflow(
|
|
56
|
+
script,
|
|
57
|
+
engineStub,
|
|
58
|
+
args,
|
|
59
|
+
null,
|
|
60
|
+
resumeFromRunId,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
let content = `Workflow ${runId} completed.\n\n`
|
|
64
|
+
if (resumeFromRunId) {
|
|
65
|
+
content += `Cache: ${cacheHits} hits · ${cacheMisses} live\n\n`
|
|
49
66
|
}
|
|
67
|
+
content += `Result:\n${typeof result === 'string' ? result : JSON.stringify(result, null, 2)}`
|
|
68
|
+
|
|
69
|
+
return { success: true, content }
|
|
50
70
|
} catch (err) {
|
|
51
71
|
return {
|
|
52
72
|
success: false,
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import type { ToolDefinition } from '../../shared/index.ts'
|
|
2
|
+
|
|
3
|
+
export const enterWorktreeTool: ToolDefinition = {
|
|
4
|
+
name: 'EnterWorktree',
|
|
5
|
+
description:
|
|
6
|
+
'Create an isolated git worktree for parallel development. ' +
|
|
7
|
+
'Creates a new worktree at .claude/worktrees/<name> on its own branch. ' +
|
|
8
|
+
'Use this when you need to work on a separate task without affecting the main workspace. ' +
|
|
9
|
+
'Pair with ExitWorktree to clean up when done.',
|
|
10
|
+
category: 'exec',
|
|
11
|
+
permission: 'ask',
|
|
12
|
+
parameters: {
|
|
13
|
+
type: 'object',
|
|
14
|
+
properties: {
|
|
15
|
+
name: {
|
|
16
|
+
type: 'string',
|
|
17
|
+
description:
|
|
18
|
+
'Name for the new worktree. Each "/"-separated segment may contain only letters, digits, dots, underscores, and dashes; max 64 chars total.',
|
|
19
|
+
},
|
|
20
|
+
baseRef: {
|
|
21
|
+
type: 'string',
|
|
22
|
+
description:
|
|
23
|
+
'Base branch or ref to create from. Default: the current HEAD. Use "origin/main" or a branch name to start from a specific point.',
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
required: ['name'],
|
|
27
|
+
},
|
|
28
|
+
async execute(params, ctx) {
|
|
29
|
+
const name = params.name as string
|
|
30
|
+
const baseRef = (params.baseRef as string) || 'HEAD'
|
|
31
|
+
|
|
32
|
+
// Validate name
|
|
33
|
+
if (name.length > 64) {
|
|
34
|
+
return {
|
|
35
|
+
success: false,
|
|
36
|
+
content: '',
|
|
37
|
+
error: 'Worktree name must be 64 characters or fewer.',
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
if (!/^[a-zA-Z0-9._/-]+$/.test(name)) {
|
|
41
|
+
return {
|
|
42
|
+
success: false,
|
|
43
|
+
content: '',
|
|
44
|
+
error:
|
|
45
|
+
'Worktree name may only contain letters, digits, dots, underscores, dashes, and slashes.',
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const cwd = ctx.cwd
|
|
50
|
+
const worktreePath = `${cwd}/.claude/worktrees/${name}`
|
|
51
|
+
|
|
52
|
+
try {
|
|
53
|
+
// Validate we're in a git repo
|
|
54
|
+
const gitCheck = Bun.spawn(['git', 'rev-parse', '--git-dir'], {
|
|
55
|
+
cwd,
|
|
56
|
+
stdout: 'pipe',
|
|
57
|
+
stderr: 'pipe',
|
|
58
|
+
})
|
|
59
|
+
const gitCheckExit = await gitCheck.exited
|
|
60
|
+
if (gitCheckExit !== 0) {
|
|
61
|
+
return {
|
|
62
|
+
success: false,
|
|
63
|
+
content: '',
|
|
64
|
+
error: 'Not in a git repository. EnterWorktree requires a git working directory.',
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Check if worktree already exists
|
|
69
|
+
const checkProc = Bun.spawn(['git', 'worktree', 'list', '--porcelain'], {
|
|
70
|
+
cwd,
|
|
71
|
+
stdout: 'pipe',
|
|
72
|
+
stderr: 'pipe',
|
|
73
|
+
})
|
|
74
|
+
const existingWorktrees = await new Response(checkProc.stdout).text()
|
|
75
|
+
if (existingWorktrees.includes(worktreePath)) {
|
|
76
|
+
return {
|
|
77
|
+
success: true,
|
|
78
|
+
content:
|
|
79
|
+
`── Worktree Already Exists ──\n\n` +
|
|
80
|
+
`Path: ${worktreePath}\n` +
|
|
81
|
+
`Name: ${name}\n\n` +
|
|
82
|
+
`This worktree already exists. To use it:\n` +
|
|
83
|
+
` cd ${worktreePath}\n\n` +
|
|
84
|
+
`To remove it, use ExitWorktree.`,
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Determine base ref
|
|
89
|
+
let resolvedBaseRef = baseRef
|
|
90
|
+
if (baseRef === 'HEAD') {
|
|
91
|
+
try {
|
|
92
|
+
const branchProc = Bun.spawn(['git', 'rev-parse', '--abbrev-ref', 'HEAD'], {
|
|
93
|
+
cwd,
|
|
94
|
+
stdout: 'pipe',
|
|
95
|
+
stderr: 'pipe',
|
|
96
|
+
})
|
|
97
|
+
const branch = (await new Response(branchProc.stdout).text()).trim()
|
|
98
|
+
resolvedBaseRef = branch || 'HEAD'
|
|
99
|
+
} catch {
|
|
100
|
+
resolvedBaseRef = 'HEAD'
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Create worktree with new branch
|
|
105
|
+
const branchName = `worktree/${name}`
|
|
106
|
+
const proc = Bun.spawn(
|
|
107
|
+
['git', 'worktree', 'add', '-b', branchName, worktreePath, resolvedBaseRef],
|
|
108
|
+
{ cwd, stdout: 'pipe', stderr: 'pipe' },
|
|
109
|
+
)
|
|
110
|
+
const stdout = await new Response(proc.stdout).text()
|
|
111
|
+
const exitCode = await proc.exited
|
|
112
|
+
|
|
113
|
+
if (exitCode !== 0) {
|
|
114
|
+
const stderr = await new Response(proc.stderr).text()
|
|
115
|
+
return {
|
|
116
|
+
success: false,
|
|
117
|
+
content: '',
|
|
118
|
+
error: `Failed to create worktree: ${stderr.slice(0, 1000)}`,
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
success: true,
|
|
124
|
+
content:
|
|
125
|
+
`── Worktree Created ──\n\n` +
|
|
126
|
+
`Path: ${worktreePath}\n` +
|
|
127
|
+
`Branch: ${branchName}\n` +
|
|
128
|
+
`Base: ${resolvedBaseRef}\n\n` +
|
|
129
|
+
`The worktree is ready. To switch to it:\n` +
|
|
130
|
+
` cd ${worktreePath}\n\n` +
|
|
131
|
+
`Work in the worktree is isolated — changes on "${branchName}" won't affect your main branch.\n` +
|
|
132
|
+
`When done, use ExitWorktree to clean up.`,
|
|
133
|
+
}
|
|
134
|
+
} catch (err) {
|
|
135
|
+
return {
|
|
136
|
+
success: false,
|
|
137
|
+
content: '',
|
|
138
|
+
error: `Worktree creation failed: ${String(err)}`,
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
},
|
|
142
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import type { ToolDefinition } from '../../shared/index.ts'
|
|
2
|
+
|
|
3
|
+
export const exitWorktreeTool: ToolDefinition = {
|
|
4
|
+
name: 'ExitWorktree',
|
|
5
|
+
description:
|
|
6
|
+
'Remove a git worktree created by EnterWorktree. ' +
|
|
7
|
+
'Use action "remove" to delete the worktree and its branch, ' +
|
|
8
|
+
'or "keep" to leave it intact on disk.',
|
|
9
|
+
category: 'exec',
|
|
10
|
+
permission: 'ask',
|
|
11
|
+
parameters: {
|
|
12
|
+
type: 'object',
|
|
13
|
+
properties: {
|
|
14
|
+
path: {
|
|
15
|
+
type: 'string',
|
|
16
|
+
description: 'Absolute path to the worktree to exit. Must be under .claude/worktrees/.',
|
|
17
|
+
},
|
|
18
|
+
action: {
|
|
19
|
+
type: 'string',
|
|
20
|
+
enum: ['keep', 'remove'],
|
|
21
|
+
description:
|
|
22
|
+
'"keep" leaves the worktree and branch on disk. "remove" deletes both the worktree directory and its branch.',
|
|
23
|
+
},
|
|
24
|
+
discard_changes: {
|
|
25
|
+
type: 'boolean',
|
|
26
|
+
description:
|
|
27
|
+
'Required true when action is "remove" and the worktree has uncommitted files or unmerged commits. The tool will refuse and list them otherwise.',
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
required: ['path', 'action'],
|
|
31
|
+
},
|
|
32
|
+
async execute(params, ctx) {
|
|
33
|
+
const worktreePath = params.path as string
|
|
34
|
+
const action = params.action as string
|
|
35
|
+
const discardChanges = params.discard_changes === true
|
|
36
|
+
|
|
37
|
+
// Validate the path is under .claude/worktrees
|
|
38
|
+
const cwd = ctx.cwd
|
|
39
|
+
|
|
40
|
+
try {
|
|
41
|
+
// Verify the worktree exists in git's worktree list
|
|
42
|
+
const listProc = Bun.spawn(['git', 'worktree', 'list', '--porcelain'], {
|
|
43
|
+
cwd,
|
|
44
|
+
stdout: 'pipe',
|
|
45
|
+
stderr: 'pipe',
|
|
46
|
+
})
|
|
47
|
+
const listOutput = await new Response(listProc.stdout).text()
|
|
48
|
+
|
|
49
|
+
if (!listOutput.includes(worktreePath)) {
|
|
50
|
+
return {
|
|
51
|
+
success: false,
|
|
52
|
+
content: '',
|
|
53
|
+
error:
|
|
54
|
+
`Worktree not found: ${worktreePath}\n\n` +
|
|
55
|
+
`This path is not in git's worktree list. It may have already been removed, ` +
|
|
56
|
+
`or it was created outside of EnterWorktree.\n\n` +
|
|
57
|
+
`Use "git worktree list" to see active worktrees.`,
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (action === 'keep') {
|
|
62
|
+
return {
|
|
63
|
+
success: true,
|
|
64
|
+
content:
|
|
65
|
+
`── Worktree Kept ──\n\n` +
|
|
66
|
+
`Path: ${worktreePath}\n\n` +
|
|
67
|
+
`The worktree and its branch are preserved on disk.\n` +
|
|
68
|
+
`To remove later: ExitWorktree with action="remove".`,
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// action === 'remove' — check for uncommitted changes
|
|
73
|
+
if (!discardChanges) {
|
|
74
|
+
const statusProc = Bun.spawn(['git', '-C', worktreePath, 'status', '--porcelain'], {
|
|
75
|
+
cwd,
|
|
76
|
+
stdout: 'pipe',
|
|
77
|
+
stderr: 'pipe',
|
|
78
|
+
})
|
|
79
|
+
const statusOutput = await new Response(statusProc.stdout).text()
|
|
80
|
+
const changedFiles = statusOutput.trim().split('\n').filter(Boolean)
|
|
81
|
+
|
|
82
|
+
if (changedFiles.length > 0) {
|
|
83
|
+
const preview = changedFiles.slice(0, 10).join('\n')
|
|
84
|
+
const extra =
|
|
85
|
+
changedFiles.length > 10 ? `\n... and ${changedFiles.length - 10} more files` : ''
|
|
86
|
+
return {
|
|
87
|
+
success: false,
|
|
88
|
+
content: '',
|
|
89
|
+
error:
|
|
90
|
+
`Worktree has ${changedFiles.length} uncommitted file(s):\n\n` +
|
|
91
|
+
`${preview}${extra}\n\n` +
|
|
92
|
+
`Set discard_changes: true to force removal and discard these changes.\n` +
|
|
93
|
+
`Or commit the changes first, then retry.`,
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Get the branch name from worktree metadata
|
|
99
|
+
let branchName = ''
|
|
100
|
+
try {
|
|
101
|
+
const branchProc = Bun.spawn(
|
|
102
|
+
['git', '-C', worktreePath, 'rev-parse', '--abbrev-ref', 'HEAD'],
|
|
103
|
+
{ cwd, stdout: 'pipe', stderr: 'pipe' },
|
|
104
|
+
)
|
|
105
|
+
branchName = (await new Response(branchProc.stdout).text()).trim()
|
|
106
|
+
} catch {
|
|
107
|
+
// Branch detection is best-effort
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Remove the worktree
|
|
111
|
+
const removeArgs = discardChanges
|
|
112
|
+
? ['git', 'worktree', 'remove', '--force', worktreePath]
|
|
113
|
+
: ['git', 'worktree', 'remove', worktreePath]
|
|
114
|
+
|
|
115
|
+
const removeProc = Bun.spawn(removeArgs, {
|
|
116
|
+
cwd,
|
|
117
|
+
stdout: 'pipe',
|
|
118
|
+
stderr: 'pipe',
|
|
119
|
+
})
|
|
120
|
+
const removeExit = await removeProc.exited
|
|
121
|
+
|
|
122
|
+
if (removeExit !== 0) {
|
|
123
|
+
const stderr = await new Response(removeProc.stderr).text()
|
|
124
|
+
return {
|
|
125
|
+
success: false,
|
|
126
|
+
content: '',
|
|
127
|
+
error: `Failed to remove worktree: ${stderr.slice(0, 1000)}`,
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Clean up the branch if we have its name
|
|
132
|
+
if (branchName && branchName !== 'HEAD') {
|
|
133
|
+
try {
|
|
134
|
+
const branchProc = Bun.spawn(['git', 'branch', '-D', branchName], {
|
|
135
|
+
cwd,
|
|
136
|
+
stdout: 'pipe',
|
|
137
|
+
stderr: 'pipe',
|
|
138
|
+
})
|
|
139
|
+
await branchProc.exited
|
|
140
|
+
// Best-effort — branch cleanup failure is not fatal
|
|
141
|
+
} catch {
|
|
142
|
+
// Ignore branch cleanup failures
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
success: true,
|
|
148
|
+
content:
|
|
149
|
+
`── Worktree Removed ──\n\n` +
|
|
150
|
+
`Path: ${worktreePath}\n` +
|
|
151
|
+
(branchName ? `Branch: ${branchName} (deleted)\n` : '') +
|
|
152
|
+
`\nThe worktree has been removed.`,
|
|
153
|
+
}
|
|
154
|
+
} catch (err) {
|
|
155
|
+
return {
|
|
156
|
+
success: false,
|
|
157
|
+
content: '',
|
|
158
|
+
error: `Worktree removal failed: ${String(err)}`,
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
},
|
|
162
|
+
}
|