@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.
@@ -9,6 +9,8 @@ import { analyzeForMemory } from './memory/memory-writer'
9
9
  import { getMemoryManager } from './memory/memory-loader'
10
10
  import type { AgentViewManager } from '../agent-view/agent-view-manager'
11
11
  import type { SkillsLoader } from '../skills/loader'
12
+ import { getBackgroundAgentRegistry } from '../agent/background-registry'
13
+ import { RulesLoader } from './rules-loader'
12
14
 
13
15
  export class QueryEngine {
14
16
  private hookEngine?: HookEngine
@@ -70,6 +72,77 @@ export class QueryEngine {
70
72
  this.skillsLoader = loader
71
73
  }
72
74
 
75
+ /** Rules loader for path-scoped rules injection. */
76
+ private rulesLoader?: RulesLoader
77
+ /** Files touched in the current turn (for rules matching). */
78
+ private touchedFiles: Set<string> = new Set()
79
+
80
+ /** Register the rules loader. */
81
+ setRulesLoader(loader: RulesLoader): void {
82
+ this.rulesLoader = loader
83
+ this.rulesLoader.load()
84
+ }
85
+
86
+ /** Pending task notifications from background agents (cleared after draining). */
87
+ private pendingTaskNotifications: Array<StreamChunk> = []
88
+
89
+ /** Track files touched by tools for rules matching. */
90
+ private trackTouchedFile(toolName: string, params: Record<string, unknown>): void {
91
+ const fileTools = ['Read', 'Write', 'Edit', 'Glob', 'Grep']
92
+ if (!fileTools.includes(toolName)) return
93
+ const filePath = (params.file_path || params.path || params.file) as string | undefined
94
+ if (filePath && typeof filePath === 'string') {
95
+ this.touchedFiles.add(filePath)
96
+ }
97
+ }
98
+
99
+ /** Inject matching rules as context after tool execution. */
100
+ private injectRules(): void {
101
+ if (!this.rulesLoader || this.touchedFiles.size === 0) return
102
+ const files = Array.from(this.touchedFiles)
103
+ const block = this.rulesLoader.buildContextBlock(files)
104
+ if (!block) return
105
+ this.context.addMessage({ role: 'user', content: block })
106
+ this.touchedFiles.clear()
107
+ }
108
+
109
+ /**
110
+ * Drain pending background task notifications.
111
+ * Call this after tool execution to surface completed/failed background agent results.
112
+ * Returns an array of StreamChunks that can be yielded in a generator.
113
+ */
114
+ drainTaskNotifications(): StreamChunk[] {
115
+ const bgRegistry = getBackgroundAgentRegistry()
116
+ const tasks = bgRegistry.list()
117
+ const chunks: StreamChunk[] = []
118
+
119
+ for (const task of tasks) {
120
+ if (task.status === 'running') continue
121
+
122
+ // Check if we've already notified for this task
123
+ const alreadyNotified = this.pendingTaskNotifications.some(
124
+ (n) => n.taskNotification?.taskId === task.id,
125
+ )
126
+ if (alreadyNotified) continue
127
+
128
+ const chunk: StreamChunk = {
129
+ type: 'task_notification',
130
+ taskNotification: {
131
+ taskId: task.id,
132
+ status: task.status as 'completed' | 'failed',
133
+ description: task.description,
134
+ content: task.result,
135
+ error: task.error,
136
+ },
137
+ }
138
+
139
+ this.pendingTaskNotifications.push(chunk)
140
+ chunks.push(chunk)
141
+ }
142
+
143
+ return chunks
144
+ }
145
+
73
146
  /** Set the session goal for goal-driven execution. */
74
147
  setGoal(
75
148
  goal: string,
@@ -310,6 +383,14 @@ export class QueryEngine {
310
383
  })
311
384
  }
312
385
 
386
+ // Inject path-scoped rules for touched files
387
+ this.injectRules()
388
+
389
+ // Drain task notifications after tool execution
390
+ for (const chunk of this.drainTaskNotifications()) {
391
+ yield chunk
392
+ }
393
+
313
394
  // If tools were executed, recursively continue the conversation
314
395
  if (toolUses.length > 0) {
315
396
  yield* this.continueWithTools(signal)
@@ -318,6 +399,11 @@ export class QueryEngine {
318
399
 
319
400
  // Fire Stop hooks when AI finishes with no tool calls
320
401
  yield* this.checkStopHook(signal)
402
+
403
+ // Final drain of task notifications
404
+ for (const chunk of this.drainTaskNotifications()) {
405
+ yield chunk
406
+ }
321
407
  }
322
408
 
323
409
  async *processWithGoal(input: string, signal?: AbortSignal): AsyncGenerator<StreamChunk> {
@@ -579,8 +665,12 @@ export class QueryEngine {
579
665
  toolRegistry: this.tools,
580
666
  artifactServer: this.artifactServer,
581
667
  agentRegistry: this.agentRegistry,
668
+ backgroundAgentRegistry: getBackgroundAgentRegistry(),
582
669
  })
583
670
 
671
+ // Track touched files for rules matching
672
+ this.trackTouchedFile(name, effectiveParams)
673
+
584
674
  // Run PostToolUse hooks
585
675
  if (this.hookEngine) {
586
676
  await this.hookEngine.executePostToolUse(name, effectiveParams, result, 'session-1')
package/src/core/hooks.ts CHANGED
@@ -107,6 +107,54 @@ export class HookEngine {
107
107
  return this.runHooks('ConfigChange', undefined, ctx)
108
108
  }
109
109
 
110
+ /** SubagentStart: fires when a sub-agent begins execution. */
111
+ async executeSubagentStart(
112
+ agentType: string,
113
+ description: string,
114
+ sessionId: string,
115
+ ): Promise<HookResult> {
116
+ const ctx: HookContext = {
117
+ event: 'SubagentStart',
118
+ sessionId,
119
+ toolInput: { agentType, description },
120
+ }
121
+ return this.runHooks('SubagentStart', undefined, ctx)
122
+ }
123
+
124
+ /** SubagentStop: fires when a sub-agent completes (success or failure). */
125
+ async executeSubagentStop(
126
+ agentType: string,
127
+ description: string,
128
+ sessionId: string,
129
+ success: boolean,
130
+ result?: string,
131
+ ): Promise<HookResult> {
132
+ const ctx: HookContext = {
133
+ event: 'SubagentStop',
134
+ sessionId,
135
+ toolInput: { agentType, description, success },
136
+ toolResult: result ? { success, content: result.slice(0, 2000) } : undefined,
137
+ }
138
+ return this.runHooks('SubagentStop', undefined, ctx)
139
+ }
140
+
141
+ /** PostToolUseFailure: fires when a tool call fails. */
142
+ async executePostToolUseFailure(
143
+ toolName: string,
144
+ toolInput: Record<string, unknown>,
145
+ error: string,
146
+ sessionId: string,
147
+ ): Promise<HookResult> {
148
+ const ctx: HookContext = {
149
+ event: 'PostToolUseFailure',
150
+ toolName,
151
+ toolInput,
152
+ toolResult: { success: false, content: '', error },
153
+ sessionId,
154
+ }
155
+ return this.runHooks('PostToolUseFailure', toolName, ctx)
156
+ }
157
+
110
158
  // ── Core execution ──
111
159
 
112
160
  private async runHooks(
@@ -0,0 +1,95 @@
1
+ /**
2
+ * OutputStylesLoader — loads custom response persona styles from .mipham/output-styles/.
3
+ *
4
+ * Each .md file in the directory is a named style. The active style's content
5
+ * is injected into the system prompt to shape the AI's tone and communication style.
6
+ *
7
+ * Usage:
8
+ * const loader = new OutputStylesLoader(cwd)
9
+ * loader.list() // ['concise', 'academic', ...]
10
+ * loader.get('concise') // style content or undefined
11
+ * loader.setActive('concise') // switch active style
12
+ * loader.getActiveContent() // active style content (for system prompt)
13
+ */
14
+
15
+ import { readdirSync, readFileSync, existsSync } from 'node:fs'
16
+ import { join, basename, extname } from 'node:path'
17
+
18
+ export class OutputStylesLoader {
19
+ private stylesDir: string
20
+ private activeStyle: string | null = null
21
+
22
+ constructor(cwd: string) {
23
+ this.stylesDir = join(cwd, '.mipham', 'output-styles')
24
+ }
25
+
26
+ /**
27
+ * List all available style names (filename without .md extension).
28
+ */
29
+ list(): string[] {
30
+ if (!existsSync(this.stylesDir)) return []
31
+ try {
32
+ return readdirSync(this.stylesDir)
33
+ .filter((f) => f.endsWith('.md'))
34
+ .map((f) => basename(f, extname(f)))
35
+ } catch {
36
+ return []
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Get the content of a named style. Returns undefined if not found.
42
+ */
43
+ get(name: string): string | undefined {
44
+ if (!existsSync(this.stylesDir)) return undefined
45
+ const filepath = join(this.stylesDir, `${name}.md`)
46
+ if (!existsSync(filepath)) return undefined
47
+ try {
48
+ return readFileSync(filepath, 'utf-8').trim()
49
+ } catch {
50
+ return undefined
51
+ }
52
+ }
53
+
54
+ /**
55
+ * Set the active output style.
56
+ */
57
+ setActive(name: string): boolean {
58
+ const content = this.get(name)
59
+ if (!content) return false
60
+ this.activeStyle = name
61
+ return true
62
+ }
63
+
64
+ /**
65
+ * Clear the active output style (revert to default personality).
66
+ */
67
+ clearActive(): void {
68
+ this.activeStyle = null
69
+ }
70
+
71
+ /**
72
+ * Get the active style name (null if none selected).
73
+ */
74
+ getActiveName(): string | null {
75
+ return this.activeStyle
76
+ }
77
+
78
+ /**
79
+ * Get the active style content for system prompt injection.
80
+ * Returns empty string if no style is active.
81
+ */
82
+ getActiveContent(): string {
83
+ if (!this.activeStyle) return ''
84
+ const content = this.get(this.activeStyle)
85
+ if (!content) return ''
86
+ return [
87
+ `[Output Style: ${this.activeStyle}]`,
88
+ 'Adopt the following communication style in all your responses:',
89
+ '',
90
+ content,
91
+ '',
92
+ '---',
93
+ ].join('\n')
94
+ }
95
+ }
@@ -0,0 +1,175 @@
1
+ /**
2
+ * RulesLoader — path-scoped rules from .mipham/rules/.
3
+ *
4
+ * Rules are markdown files with YAML frontmatter. They are injected into
5
+ * the conversation when the AI touches matching files.
6
+ *
7
+ * Directory structure:
8
+ * .mipham/rules/
9
+ * always.md — always loaded (no paths filter)
10
+ * typescript.md — loaded when touching *.ts files
11
+ * security.md — loaded when touching auth/ or crypto/ paths
12
+ *
13
+ * Frontmatter:
14
+ * ---
15
+ * paths: "apps/cli/src/**\/*.ts"
16
+ * description: TypeScript coding standards
17
+ * ---
18
+ */
19
+
20
+ import { readdirSync, readFileSync, existsSync } from 'node:fs'
21
+ import { join, relative } from 'node:path'
22
+
23
+ interface RuleFile {
24
+ name: string
25
+ paths: string[] // glob patterns, empty = always loaded
26
+ description: string
27
+ content: string
28
+ }
29
+
30
+ export class RulesLoader {
31
+ private rules: RuleFile[] = []
32
+ private rulesDir: string
33
+
34
+ constructor(cwd: string) {
35
+ this.rulesDir = join(cwd, '.mipham', 'rules')
36
+ }
37
+
38
+ /**
39
+ * Load all rules from .mipham/rules/. Call once at startup.
40
+ */
41
+ load(): void {
42
+ this.rules = []
43
+ if (!existsSync(this.rulesDir)) return
44
+
45
+ try {
46
+ const files = readdirSync(this.rulesDir).filter((f) => f.endsWith('.md'))
47
+ for (const file of files) {
48
+ const filepath = join(this.rulesDir, file)
49
+ try {
50
+ const raw = readFileSync(filepath, 'utf-8')
51
+ const { paths, description, content } = this.parseRule(raw, file)
52
+ this.rules.push({ name: file.replace(/\.md$/, ''), paths, description, content })
53
+ } catch {
54
+ // Skip unparseable files
55
+ }
56
+ }
57
+ } catch {
58
+ // Directory read error — rules unavailable
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Get rules that match the given file paths.
64
+ * Rules with no paths filter ("always") are always included.
65
+ */
66
+ getMatchingRules(touchedFiles: string[]): RuleFile[] {
67
+ const matched: RuleFile[] = []
68
+
69
+ for (const rule of this.rules) {
70
+ // Always rules — no paths filter
71
+ if (rule.paths.length === 0) {
72
+ matched.push(rule)
73
+ continue
74
+ }
75
+
76
+ // Check if any touched file matches any rule path pattern
77
+ for (const file of touchedFiles) {
78
+ for (const pattern of rule.paths) {
79
+ if (this.matchPath(file, pattern)) {
80
+ matched.push(rule)
81
+ // Break inner loops — rule already matched
82
+ break
83
+ }
84
+ }
85
+ if (matched.includes(rule)) break
86
+ }
87
+ }
88
+
89
+ return matched
90
+ }
91
+
92
+ /**
93
+ * Build a context block to inject into the conversation.
94
+ */
95
+ buildContextBlock(touchedFiles: string[]): string {
96
+ const matched = this.getMatchingRules(touchedFiles)
97
+ if (matched.length === 0) return ''
98
+
99
+ const blocks = matched.map(
100
+ (r) => `[Rule: ${r.name}]${r.description ? ` — ${r.description}` : ''}\n${r.content}`,
101
+ )
102
+ return `\n<!-- Path-scoped rules matching: ${touchedFiles.join(', ')} -->\n${blocks.join('\n\n')}\n`
103
+ }
104
+
105
+ /**
106
+ * Count loaded rules.
107
+ */
108
+ count(): number {
109
+ return this.rules.length
110
+ }
111
+
112
+ /**
113
+ * List loaded rule names.
114
+ */
115
+ list(): string[] {
116
+ return this.rules.map((r) => r.name)
117
+ }
118
+
119
+ /**
120
+ * Simple glob matching. Supports **, *, and exact file matching.
121
+ * Returns true if file matches pattern.
122
+ */
123
+ private matchPath(file: string, pattern: string): boolean {
124
+ // Convert glob pattern to regex
125
+ let regexStr = pattern
126
+ .replace(/\./g, '\\.')
127
+ .replace(/\*\*/g, '<<GLOBSTAR>>')
128
+ .replace(/\*/g, '[^/]*')
129
+ .replace(/<<GLOBSTAR>>/g, '.*')
130
+
131
+ // If pattern doesn't start with ** or *, anchor to be a suffix match
132
+ if (!pattern.startsWith('**') && !pattern.startsWith('*')) {
133
+ regexStr = regexStr + '$'
134
+ }
135
+
136
+ try {
137
+ return new RegExp(regexStr).test(file)
138
+ } catch {
139
+ // Invalid pattern — fallback to simple includes
140
+ return file.includes(pattern.replace(/\*\*/g, '').replace(/\*/g, ''))
141
+ }
142
+ }
143
+
144
+ /**
145
+ * Parse a rule file — extract frontmatter and body.
146
+ */
147
+ private parseRule(
148
+ raw: string,
149
+ filename: string,
150
+ ): { paths: string[]; description: string; content: string } {
151
+ const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/)
152
+ if (!match) {
153
+ return { paths: [], description: '', content: raw.trim() }
154
+ }
155
+
156
+ const frontmatter = match[1] || ''
157
+ const body = (match[2] || '').trim()
158
+
159
+ const paths: string[] = []
160
+ let description = ''
161
+
162
+ for (const line of frontmatter.split('\n')) {
163
+ const pathMatch = line.match(/^paths:\s*"(.+)"$/)
164
+ if (pathMatch) {
165
+ pathMatch[1]!.split(',').forEach((p) => paths.push(p.trim()))
166
+ }
167
+ const descMatch = line.match(/^description:\s*(.+)$/)
168
+ if (descMatch) {
169
+ description = descMatch[1]!.trim()
170
+ }
171
+ }
172
+
173
+ return { paths, description, content: body }
174
+ }
175
+ }
package/src/mcp/client.ts CHANGED
@@ -151,6 +151,15 @@ export class McpClient {
151
151
  return this.connections.get(name)?.tools || []
152
152
  }
153
153
 
154
+ /** List all currently connected MCP server names. */
155
+ getConnectedServers(): string[] {
156
+ const names: string[] = []
157
+ for (const [name, conn] of this.connections) {
158
+ if (conn.status === 'connected') names.push(name)
159
+ }
160
+ return names
161
+ }
162
+
154
163
  async callTool(
155
164
  serverName: string,
156
165
  toolName: string,
@@ -80,6 +80,7 @@ export interface ToolContext {
80
80
  toolRegistry?: Map<string, ToolDefinition>
81
81
  artifactServer?: import('../artifacts/server').ArtifactServer
82
82
  agentRegistry?: import('../agent/agent-registry').AgentRegistry
83
+ backgroundAgentRegistry?: import('../agent/background-registry').BackgroundAgentRegistry
83
84
  }
84
85
 
85
86
  // ── Artifact Types ──
@@ -116,9 +117,18 @@ export interface ToolDefinition {
116
117
  execute: (params: Record<string, unknown>, ctx: ToolContext) => Promise<ToolResult>
117
118
  }
118
119
 
120
+ // ── Task Notification Types ──
121
+ export interface TaskNotification {
122
+ taskId: string
123
+ status: 'started' | 'completed' | 'failed'
124
+ description: string
125
+ content?: string
126
+ error?: string
127
+ }
128
+
119
129
  // ── Stream Types ──
120
130
  export interface StreamChunk {
121
- type: 'text' | 'tool_use' | 'tool_result' | 'thinking' | 'stop' | 'error'
131
+ type: 'text' | 'tool_use' | 'tool_result' | 'thinking' | 'stop' | 'error' | 'task_notification'
122
132
  content?: string
123
133
  toolUse?: ToolUseContent
124
134
  tool_use_id?: string
@@ -127,6 +137,8 @@ export interface StreamChunk {
127
137
  reasoning_content?: string
128
138
  /** Anthropic thinking block content (DeepSeek Anthropic endpoint). */
129
139
  thinking?: string
140
+ /** Background task notification payload (type: 'task_notification'). */
141
+ taskNotification?: TaskNotification
130
142
  }
131
143
 
132
144
  // ── Config Types ──
@@ -173,6 +185,7 @@ export interface SkillDefinition {
173
185
  export type HookEvent =
174
186
  | 'PreToolUse'
175
187
  | 'PostToolUse'
188
+ | 'PostToolUseFailure'
176
189
  | 'SessionStart'
177
190
  | 'SessionEnd'
178
191
  | 'Notification'
@@ -181,6 +194,8 @@ export type HookEvent =
181
194
  | 'PreCompact'
182
195
  | 'PostCompact'
183
196
  | 'ConfigChange'
197
+ | 'SubagentStart'
198
+ | 'SubagentStop'
184
199
 
185
200
  export type HookType = 'command' | 'http' | 'code' | 'mcp_tool'
186
201
 
@@ -1,13 +1,17 @@
1
1
  import type { ToolDefinition } from '../../shared/index.ts'
2
2
  import { SubAgent } from '../../agent/sub-agent'
3
3
  import type { SubAgentType } from '../../agent/types'
4
+ import { getBackgroundAgentRegistry } from '../../agent/background-registry'
4
5
 
5
6
  const VALID_TYPES: SubAgentType[] = ['general', 'explore', 'plan', 'code-review']
6
7
 
7
8
  export const agentTool: ToolDefinition = {
8
9
  name: 'Agent',
9
10
  description:
10
- 'Launch a sub-agent to handle complex, multi-step tasks independently. Available types: general, explore, plan, code-review.',
11
+ 'Launch a sub-agent to handle complex, multi-step tasks independently. ' +
12
+ 'Available types: general (default), explore (code search), plan (design), code-review. ' +
13
+ 'Set run_in_background: true to execute asynchronously — returns a task ID immediately; ' +
14
+ 'results are retrievable via the Task tool (output action) or Agent View.',
11
15
  category: 'agent',
12
16
  permission: 'ask',
13
17
  parameters: {
@@ -19,6 +23,11 @@ export const agentTool: ToolDefinition = {
19
23
  type: 'string',
20
24
  description: 'Type: general (default), explore (code search), plan (design), code-review',
21
25
  },
26
+ run_in_background: {
27
+ type: 'boolean',
28
+ description:
29
+ 'When true, execute asynchronously and return a task ID immediately. Use Task output to retrieve results. Default: false.',
30
+ },
22
31
  },
23
32
  required: ['description', 'prompt'],
24
33
  },
@@ -26,6 +35,7 @@ export const agentTool: ToolDefinition = {
26
35
  const description = params.description as string
27
36
  const prompt = params.prompt as string
28
37
  const agentType = (params.subagent_type as SubAgentType) || 'general'
38
+ const runInBackground = params.run_in_background === true
29
39
 
30
40
  if (!VALID_TYPES.includes(agentType)) {
31
41
  return {
@@ -51,7 +61,36 @@ export const agentTool: ToolDefinition = {
51
61
 
52
62
  try {
53
63
  const sub = new SubAgent(registry, toolRegistry)
54
- const result = await sub.execute(prompt, description, { type: agentType, agentDef })
64
+ const result = await sub.execute(prompt, description, {
65
+ type: agentType,
66
+ agentDef,
67
+ runInBackground,
68
+ })
69
+
70
+ // If background execution, also register in the task system for Task tool integration
71
+ if (runInBackground) {
72
+ const bgMatch = result.match(/\[background-task:(.+?)\]/)
73
+ if (bgMatch) {
74
+ const bgTaskId = bgMatch[1]!
75
+ const bgRegistry = getBackgroundAgentRegistry()
76
+ const bgTask = bgRegistry.get(bgTaskId)
77
+
78
+ return {
79
+ success: true,
80
+ content:
81
+ `── Background Agent Started ──\n\n` +
82
+ `Task ID: ${bgTaskId}\n` +
83
+ `Type: ${agentType}\n` +
84
+ `Task: ${description}\n` +
85
+ `Status: ${bgTask?.status || 'running'}\n\n` +
86
+ `The agent is running in the background. You can continue working.\n` +
87
+ `Use Task output taskId="${bgTaskId}" to check results.\n` +
88
+ `Use Task stop taskId="${bgTaskId}" to cancel.\n` +
89
+ `Use /agents to view in Agent View dashboard.`,
90
+ }
91
+ }
92
+ }
93
+
55
94
  return { success: true, content: result }
56
95
  } catch (err) {
57
96
  return { success: false, content: '', error: String(err) }
@@ -0,0 +1,92 @@
1
+ import { mkdirSync, writeFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import type { ToolDefinition } from '../../shared/index.ts'
4
+
5
+ export const enterPlanModeTool: ToolDefinition = {
6
+ name: 'EnterPlanMode',
7
+ description:
8
+ 'Enter plan mode — a read-only mode for analysis and design. ' +
9
+ 'In plan mode, only Read/Grep/Glob tools are auto-approved; all other tools require confirmation. ' +
10
+ 'Use this before writing code to design an implementation approach, ' +
11
+ 'explore the codebase, and get user approval before executing changes.',
12
+ category: 'agent',
13
+ permission: 'auto',
14
+ parameters: {
15
+ type: 'object',
16
+ properties: {
17
+ description: {
18
+ type: 'string',
19
+ description:
20
+ 'Brief description of what you are planning (e.g., "Add user authentication flow")',
21
+ },
22
+ },
23
+ required: [],
24
+ },
25
+ async execute(params, ctx) {
26
+ const description = (params.description as string) || 'Implementation Plan'
27
+
28
+ // Create plan file
29
+ const planDir = join(ctx.cwd, '.mipham', 'plans')
30
+ mkdirSync(planDir, { recursive: true })
31
+
32
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)
33
+ const filename = `plan-${timestamp}.md`
34
+ const filepath = join(planDir, filename)
35
+
36
+ const content = [
37
+ `# ${description}`,
38
+ '',
39
+ `> Generated: ${new Date().toISOString()}`,
40
+ `> Status: Draft`,
41
+ '',
42
+ '## Overview',
43
+ '',
44
+ '[Describe what this plan aims to achieve]',
45
+ '',
46
+ '## Files to Modify',
47
+ '',
48
+ '| File | Change | Reason |',
49
+ '|------|--------|--------|',
50
+ '| | | |',
51
+ '',
52
+ '## Implementation Steps',
53
+ '',
54
+ '1. ',
55
+ '2. ',
56
+ '3. ',
57
+ '',
58
+ '## Verification',
59
+ '',
60
+ '- [ ] Tests pass',
61
+ '- [ ] Typecheck passes',
62
+ '- [ ] Manual verification',
63
+ '',
64
+ '## Notes',
65
+ '',
66
+ '[Any additional context, risks, or dependencies]',
67
+ ].join('\n')
68
+
69
+ writeFileSync(filepath, content, 'utf-8')
70
+
71
+ return {
72
+ success: true,
73
+ content: [
74
+ '── Plan Mode Activated ──',
75
+ '',
76
+ `Plan file: ${filepath}`,
77
+ '',
78
+ 'Plan mode is READ-ONLY:',
79
+ ' ✅ Read, Grep, Glob — auto-approved',
80
+ ' ⚠️ All other tools — require confirmation',
81
+ '',
82
+ 'Design your approach, explore the codebase, then:',
83
+ ' • Use ExitPlanMode to submit your plan for approval',
84
+ ' • The user will review and approve before code changes begin',
85
+ '',
86
+ 'ExitPlanMode parameters:',
87
+ ' approved: true → switch to acceptEdits mode (code changes allowed)',
88
+ ' approved: false → revert to default mode',
89
+ ].join('\n'),
90
+ }
91
+ },
92
+ }