ashlrcode 1.0.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.
Files changed (133) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +295 -0
  3. package/package.json +46 -0
  4. package/src/__tests__/branded-types.test.ts +47 -0
  5. package/src/__tests__/context.test.ts +163 -0
  6. package/src/__tests__/cost-tracker.test.ts +274 -0
  7. package/src/__tests__/cron.test.ts +197 -0
  8. package/src/__tests__/dream.test.ts +204 -0
  9. package/src/__tests__/error-handler.test.ts +192 -0
  10. package/src/__tests__/features.test.ts +69 -0
  11. package/src/__tests__/file-history.test.ts +177 -0
  12. package/src/__tests__/hooks.test.ts +145 -0
  13. package/src/__tests__/keybindings.test.ts +159 -0
  14. package/src/__tests__/model-patches.test.ts +82 -0
  15. package/src/__tests__/permissions-rules.test.ts +121 -0
  16. package/src/__tests__/permissions.test.ts +108 -0
  17. package/src/__tests__/project-config.test.ts +63 -0
  18. package/src/__tests__/retry.test.ts +321 -0
  19. package/src/__tests__/router.test.ts +158 -0
  20. package/src/__tests__/session-compact.test.ts +191 -0
  21. package/src/__tests__/session.test.ts +145 -0
  22. package/src/__tests__/skill-registry.test.ts +130 -0
  23. package/src/__tests__/speculation.test.ts +196 -0
  24. package/src/__tests__/tasks-v2.test.ts +267 -0
  25. package/src/__tests__/telemetry.test.ts +149 -0
  26. package/src/__tests__/tool-executor.test.ts +141 -0
  27. package/src/__tests__/tool-registry.test.ts +166 -0
  28. package/src/__tests__/undercover.test.ts +93 -0
  29. package/src/__tests__/workflow.test.ts +195 -0
  30. package/src/agent/async-context.ts +64 -0
  31. package/src/agent/context.ts +245 -0
  32. package/src/agent/cron.ts +189 -0
  33. package/src/agent/dream.ts +165 -0
  34. package/src/agent/error-handler.ts +108 -0
  35. package/src/agent/ipc.ts +256 -0
  36. package/src/agent/kairos.ts +207 -0
  37. package/src/agent/loop.ts +314 -0
  38. package/src/agent/model-patches.ts +68 -0
  39. package/src/agent/speculation.ts +219 -0
  40. package/src/agent/sub-agent.ts +125 -0
  41. package/src/agent/system-prompt.ts +231 -0
  42. package/src/agent/team.ts +220 -0
  43. package/src/agent/tool-executor.ts +162 -0
  44. package/src/agent/workflow.ts +189 -0
  45. package/src/agent/worktree-manager.ts +86 -0
  46. package/src/autopilot/queue.ts +186 -0
  47. package/src/autopilot/scanner.ts +245 -0
  48. package/src/autopilot/types.ts +58 -0
  49. package/src/bridge/bridge-client.ts +57 -0
  50. package/src/bridge/bridge-server.ts +81 -0
  51. package/src/cli.ts +1120 -0
  52. package/src/config/features.ts +51 -0
  53. package/src/config/git.ts +137 -0
  54. package/src/config/hooks.ts +201 -0
  55. package/src/config/permissions.ts +251 -0
  56. package/src/config/project-config.ts +63 -0
  57. package/src/config/remote-settings.ts +163 -0
  58. package/src/config/settings-sync.ts +170 -0
  59. package/src/config/settings.ts +113 -0
  60. package/src/config/undercover.ts +76 -0
  61. package/src/config/upgrade-notice.ts +65 -0
  62. package/src/mcp/client.ts +197 -0
  63. package/src/mcp/manager.ts +125 -0
  64. package/src/mcp/oauth.ts +252 -0
  65. package/src/mcp/types.ts +61 -0
  66. package/src/persistence/memory.ts +129 -0
  67. package/src/persistence/session.ts +289 -0
  68. package/src/planning/plan-mode.ts +128 -0
  69. package/src/planning/plan-tools.ts +138 -0
  70. package/src/providers/anthropic.ts +177 -0
  71. package/src/providers/cost-tracker.ts +184 -0
  72. package/src/providers/retry.ts +264 -0
  73. package/src/providers/router.ts +159 -0
  74. package/src/providers/types.ts +79 -0
  75. package/src/providers/xai.ts +217 -0
  76. package/src/repl.tsx +1384 -0
  77. package/src/setup.ts +119 -0
  78. package/src/skills/loader.ts +78 -0
  79. package/src/skills/registry.ts +78 -0
  80. package/src/skills/types.ts +11 -0
  81. package/src/state/file-history.ts +264 -0
  82. package/src/telemetry/event-log.ts +116 -0
  83. package/src/tools/agent.ts +133 -0
  84. package/src/tools/ask-user.ts +229 -0
  85. package/src/tools/bash.ts +146 -0
  86. package/src/tools/config.ts +147 -0
  87. package/src/tools/diff.ts +137 -0
  88. package/src/tools/file-edit.ts +123 -0
  89. package/src/tools/file-read.ts +82 -0
  90. package/src/tools/file-write.ts +82 -0
  91. package/src/tools/glob.ts +76 -0
  92. package/src/tools/grep.ts +187 -0
  93. package/src/tools/ls.ts +77 -0
  94. package/src/tools/lsp.ts +375 -0
  95. package/src/tools/mcp-resources.ts +83 -0
  96. package/src/tools/mcp-tool.ts +47 -0
  97. package/src/tools/memory.ts +148 -0
  98. package/src/tools/notebook-edit.ts +133 -0
  99. package/src/tools/peers.ts +113 -0
  100. package/src/tools/powershell.ts +83 -0
  101. package/src/tools/registry.ts +114 -0
  102. package/src/tools/send-message.ts +75 -0
  103. package/src/tools/sleep.ts +50 -0
  104. package/src/tools/snip.ts +143 -0
  105. package/src/tools/tasks.ts +349 -0
  106. package/src/tools/team.ts +309 -0
  107. package/src/tools/todo-write.ts +93 -0
  108. package/src/tools/tool-search.ts +83 -0
  109. package/src/tools/types.ts +52 -0
  110. package/src/tools/web-browser.ts +263 -0
  111. package/src/tools/web-fetch.ts +118 -0
  112. package/src/tools/web-search.ts +107 -0
  113. package/src/tools/workflow.ts +188 -0
  114. package/src/tools/worktree.ts +143 -0
  115. package/src/types/branded.ts +22 -0
  116. package/src/ui/App.tsx +184 -0
  117. package/src/ui/BuddyPanel.tsx +52 -0
  118. package/src/ui/PermissionPrompt.tsx +29 -0
  119. package/src/ui/banner.ts +217 -0
  120. package/src/ui/buddy-ai.ts +108 -0
  121. package/src/ui/buddy.ts +466 -0
  122. package/src/ui/context-bar.ts +60 -0
  123. package/src/ui/effort.ts +65 -0
  124. package/src/ui/keybindings.ts +143 -0
  125. package/src/ui/markdown.ts +271 -0
  126. package/src/ui/message-renderer.ts +73 -0
  127. package/src/ui/mode.ts +80 -0
  128. package/src/ui/notifications.ts +57 -0
  129. package/src/ui/speech-bubble.ts +95 -0
  130. package/src/ui/spinner.ts +116 -0
  131. package/src/ui/theme.ts +98 -0
  132. package/src/version.ts +5 -0
  133. package/src/voice/voice-mode.ts +169 -0
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Memory system — cross-session persistent context.
3
+ *
4
+ * Per-project memory stored at ~/.ashlrcode/memory/<project-hash>/
5
+ * Memory types: user, feedback, project, reference (same as Claude Code).
6
+ */
7
+
8
+ import { existsSync } from "fs";
9
+ import { readFile, writeFile, readdir, mkdir, unlink } from "fs/promises";
10
+ import { join } from "path";
11
+ import { createHash } from "crypto";
12
+ import { getConfigDir } from "../config/settings.ts";
13
+
14
+ export interface MemoryEntry {
15
+ name: string;
16
+ description: string;
17
+ type: "user" | "feedback" | "project" | "reference";
18
+ content: string;
19
+ filePath: string;
20
+ }
21
+
22
+ function getProjectHash(cwd: string): string {
23
+ return createHash("sha256").update(cwd).digest("hex").slice(0, 12);
24
+ }
25
+
26
+ function getMemoryDir(cwd: string): string {
27
+ return join(getConfigDir(), "memory", getProjectHash(cwd));
28
+ }
29
+
30
+ /**
31
+ * Load all memory entries for the current project.
32
+ */
33
+ export async function loadMemories(cwd: string): Promise<MemoryEntry[]> {
34
+ const memDir = getMemoryDir(cwd);
35
+ if (!existsSync(memDir)) return [];
36
+
37
+ const files = await readdir(memDir);
38
+ const memories: MemoryEntry[] = [];
39
+
40
+ for (const file of files) {
41
+ if (!file.endsWith(".md")) continue;
42
+ const filePath = join(memDir, file);
43
+ const content = await readFile(filePath, "utf-8");
44
+ const parsed = parseMemoryFile(content, filePath);
45
+ if (parsed) memories.push(parsed);
46
+ }
47
+
48
+ return memories;
49
+ }
50
+
51
+ /**
52
+ * Save a memory entry.
53
+ */
54
+ export async function saveMemory(
55
+ cwd: string,
56
+ entry: Omit<MemoryEntry, "filePath">
57
+ ): Promise<string> {
58
+ const memDir = getMemoryDir(cwd);
59
+ await mkdir(memDir, { recursive: true });
60
+
61
+ const slug = entry.name
62
+ .toLowerCase()
63
+ .replace(/[^a-z0-9]+/g, "_")
64
+ .slice(0, 50);
65
+ const filePath = join(memDir, `${slug}.md`);
66
+
67
+ const fileContent = `---
68
+ name: ${entry.name}
69
+ description: ${entry.description}
70
+ type: ${entry.type}
71
+ ---
72
+
73
+ ${entry.content}
74
+ `;
75
+
76
+ await writeFile(filePath, fileContent, "utf-8");
77
+ return filePath;
78
+ }
79
+
80
+ /**
81
+ * Delete a memory by name.
82
+ */
83
+ export async function deleteMemory(cwd: string, name: string): Promise<boolean> {
84
+ const memories = await loadMemories(cwd);
85
+ const match = memories.find(
86
+ (m) => m.name.toLowerCase() === name.toLowerCase()
87
+ );
88
+ if (!match) return false;
89
+
90
+ await unlink(match.filePath);
91
+ return true;
92
+ }
93
+
94
+ /**
95
+ * Format memories for inclusion in the system prompt.
96
+ */
97
+ export function formatMemoriesForPrompt(memories: MemoryEntry[]): string {
98
+ if (memories.length === 0) return "";
99
+
100
+ const sections = memories.map(
101
+ (m) => `### ${m.name} (${m.type})\n${m.content}`
102
+ );
103
+
104
+ return `\n\n# Project Memory\n\n${sections.join("\n\n")}`;
105
+ }
106
+
107
+ function parseMemoryFile(
108
+ content: string,
109
+ filePath: string
110
+ ): MemoryEntry | null {
111
+ const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
112
+ if (!frontmatterMatch) return null;
113
+
114
+ const frontmatter = frontmatterMatch[1]!;
115
+ const body = frontmatterMatch[2]!.trim();
116
+
117
+ const name = extractField(frontmatter, "name");
118
+ const description = extractField(frontmatter, "description");
119
+ const type = extractField(frontmatter, "type") as MemoryEntry["type"];
120
+
121
+ if (!name || !type) return null;
122
+
123
+ return { name, description: description ?? "", type, content: body, filePath };
124
+ }
125
+
126
+ function extractField(frontmatter: string, field: string): string | null {
127
+ const match = frontmatter.match(new RegExp(`^${field}:\\s*(.+)$`, "m"));
128
+ return match?.[1]?.trim() ?? null;
129
+ }
@@ -0,0 +1,289 @@
1
+ /**
2
+ * Session persistence — JSONL append-only logs.
3
+ *
4
+ * Pattern from Claude Code: each session is a JSONL file at
5
+ * ~/.ashlrcode/sessions/<session-id>.jsonl
6
+ *
7
+ * Each line is a JSON object representing a message or event.
8
+ */
9
+
10
+ import { existsSync } from "fs";
11
+ import { readFile, writeFile, appendFile, mkdir, readdir } from "fs/promises";
12
+ import { join } from "path";
13
+ import { randomUUID } from "crypto";
14
+ import { getConfigDir } from "../config/settings.ts";
15
+ import type { Message } from "../providers/types.ts";
16
+
17
+ export interface SessionMetadata {
18
+ id: string;
19
+ cwd: string;
20
+ createdAt: string;
21
+ updatedAt: string;
22
+ provider: string;
23
+ model: string;
24
+ messageCount: number;
25
+ title?: string;
26
+ }
27
+
28
+ interface SessionEntry {
29
+ type: "message" | "metadata" | "compact";
30
+ timestamp: string;
31
+ data: Message | SessionMetadata | { summary: string; messageCountBefore: number };
32
+ }
33
+
34
+ function getSessionsDir(): string {
35
+ return join(getConfigDir(), "sessions");
36
+ }
37
+
38
+ export class Session {
39
+ readonly id: string;
40
+ private filePath: string;
41
+ private metadata: SessionMetadata;
42
+
43
+ constructor(id?: string) {
44
+ this.id = id ?? randomUUID().slice(0, 8);
45
+ this.filePath = join(getSessionsDir(), `${this.id}.jsonl`);
46
+ this.metadata = {
47
+ id: this.id,
48
+ cwd: process.cwd(),
49
+ createdAt: new Date().toISOString(),
50
+ updatedAt: new Date().toISOString(),
51
+ provider: "",
52
+ model: "",
53
+ messageCount: 0,
54
+ };
55
+ }
56
+
57
+ async init(provider: string, model: string): Promise<void> {
58
+ await mkdir(getSessionsDir(), { recursive: true });
59
+ this.metadata.provider = provider;
60
+ this.metadata.model = model;
61
+ await this.appendEntry({
62
+ type: "metadata",
63
+ timestamp: new Date().toISOString(),
64
+ data: this.metadata,
65
+ });
66
+ }
67
+
68
+ async appendMessage(message: Message): Promise<void> {
69
+ this.metadata.messageCount++;
70
+ this.metadata.updatedAt = new Date().toISOString();
71
+ await this.appendEntry({
72
+ type: "message",
73
+ timestamp: new Date().toISOString(),
74
+ data: message,
75
+ });
76
+ }
77
+
78
+ async appendMessages(messages: Message[]): Promise<void> {
79
+ for (const msg of messages) {
80
+ await this.appendMessage(msg);
81
+ }
82
+ }
83
+
84
+ async insertCompactBoundary(summary: string, messageCountBefore: number): Promise<void> {
85
+ await this.appendEntry({
86
+ type: "compact",
87
+ timestamp: new Date().toISOString(),
88
+ data: { summary, messageCountBefore },
89
+ });
90
+ }
91
+
92
+ /**
93
+ * Load ALL messages, ignoring compact boundaries.
94
+ * Used for forking and compaction where full history is needed.
95
+ */
96
+ async loadAllMessages(): Promise<Message[]> {
97
+ if (!existsSync(this.filePath)) return [];
98
+
99
+ const content = await readFile(this.filePath, "utf-8");
100
+ const lines = content.trim().split("\n").filter(Boolean);
101
+ const messages: Message[] = [];
102
+
103
+ for (const line of lines) {
104
+ try {
105
+ const entry = JSON.parse(line) as SessionEntry;
106
+ if (entry.type === "message") {
107
+ messages.push(entry.data as Message);
108
+ }
109
+ } catch {
110
+ // Skip malformed lines
111
+ }
112
+ }
113
+
114
+ return messages;
115
+ }
116
+
117
+ /**
118
+ * Load messages from the last compact boundary forward.
119
+ * If a compact boundary exists, only messages after it are returned,
120
+ * with a synthetic assistant message prepended containing the summary.
121
+ */
122
+ async loadMessages(): Promise<Message[]> {
123
+ if (!existsSync(this.filePath)) return [];
124
+
125
+ const content = await readFile(this.filePath, "utf-8");
126
+ const lines = content.trim().split("\n").filter(Boolean);
127
+
128
+ // Scan in reverse to find last compact boundary
129
+ let lastCompactIndex = -1;
130
+ let compactSummary = "";
131
+ for (let i = lines.length - 1; i >= 0; i--) {
132
+ try {
133
+ const entry = JSON.parse(lines[i]!) as SessionEntry;
134
+ if (entry.type === "compact") {
135
+ lastCompactIndex = i;
136
+ compactSummary = (entry.data as { summary: string; messageCountBefore: number }).summary;
137
+ break;
138
+ }
139
+ } catch {
140
+ // Skip malformed lines
141
+ }
142
+ }
143
+
144
+ // Load messages after the boundary (or all if no boundary)
145
+ const startIndex = lastCompactIndex >= 0 ? lastCompactIndex + 1 : 0;
146
+ const messages: Message[] = [];
147
+
148
+ for (let i = startIndex; i < lines.length; i++) {
149
+ try {
150
+ const entry = JSON.parse(lines[i]!) as SessionEntry;
151
+ if (entry.type === "message") {
152
+ messages.push(entry.data as Message);
153
+ }
154
+ } catch {
155
+ // Skip malformed lines
156
+ }
157
+ }
158
+
159
+ // Prepend synthetic summary message if we found a compact boundary
160
+ if (lastCompactIndex >= 0) {
161
+ messages.unshift({
162
+ role: "user",
163
+ content: "[Previous session context]\n" + compactSummary,
164
+ });
165
+ }
166
+
167
+ return messages;
168
+ }
169
+
170
+ async setTitle(title: string): Promise<void> {
171
+ this.metadata.title = title;
172
+ // Persist the title update
173
+ await this.appendEntry({
174
+ type: "metadata",
175
+ timestamp: new Date().toISOString(),
176
+ data: { ...this.metadata, title },
177
+ });
178
+ }
179
+
180
+ private async appendEntry(entry: SessionEntry): Promise<void> {
181
+ await mkdir(getSessionsDir(), { recursive: true });
182
+ await appendFile(this.filePath, JSON.stringify(entry) + "\n", "utf-8");
183
+ }
184
+ }
185
+
186
+ /**
187
+ * List recent sessions.
188
+ */
189
+ export async function listSessions(limit = 10): Promise<SessionMetadata[]> {
190
+ const sessionsDir = getSessionsDir();
191
+ if (!existsSync(sessionsDir)) return [];
192
+
193
+ const files = await readdir(sessionsDir);
194
+ const sessions: SessionMetadata[] = [];
195
+
196
+ for (const file of files) {
197
+ if (!file.endsWith(".jsonl")) continue;
198
+
199
+ const content = await readFile(join(sessionsDir, file), "utf-8");
200
+ const lines = content.split("\n").filter(Boolean);
201
+ if (lines.length === 0) continue;
202
+
203
+ try {
204
+ // Find the LAST metadata entry (most recent, includes title updates)
205
+ let latestMetadata: SessionMetadata | null = null;
206
+ for (const line of lines) {
207
+ try {
208
+ const entry = JSON.parse(line) as SessionEntry;
209
+ if (entry.type === "metadata") {
210
+ latestMetadata = entry.data as SessionMetadata;
211
+ }
212
+ } catch { /* skip malformed lines */ }
213
+ }
214
+ if (latestMetadata) {
215
+ sessions.push(latestMetadata);
216
+ }
217
+ } catch {
218
+ // Skip malformed files
219
+ }
220
+ }
221
+
222
+ // Sort by most recent first
223
+ sessions.sort(
224
+ (a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
225
+ );
226
+
227
+ return sessions.slice(0, limit);
228
+ }
229
+
230
+ /**
231
+ * Resume a session by ID.
232
+ */
233
+ export async function resumeSession(id: string): Promise<{
234
+ session: Session;
235
+ messages: Message[];
236
+ } | null> {
237
+ const session = new Session(id);
238
+ const messages = await session.loadMessages();
239
+ if (messages.length === 0) return null;
240
+ return { session, messages };
241
+ }
242
+
243
+ /**
244
+ * Get the most recent session for a given working directory.
245
+ */
246
+ export async function getLastSessionForCwd(cwd: string): Promise<string | null> {
247
+ const sessions = await listSessions(50);
248
+ const match = sessions.find((s) => s.cwd === cwd);
249
+ return match?.id ?? null;
250
+ }
251
+
252
+ /**
253
+ * Fork a session — create a new session with copied message history.
254
+ */
255
+ export async function forkSession(
256
+ sourceId: string,
257
+ provider: string,
258
+ model: string
259
+ ): Promise<{ session: Session; messages: Message[] } | null> {
260
+ const source = new Session(sourceId);
261
+ const messages = await source.loadAllMessages();
262
+ if (messages.length === 0) return null;
263
+
264
+ const forked = new Session();
265
+ await forked.init(provider, model);
266
+ await forked.appendMessages(messages);
267
+
268
+ return { session: forked, messages };
269
+ }
270
+
271
+ /**
272
+ * Compact a session — insert a boundary marker with a summary of recent messages.
273
+ * Messages before the boundary are excluded from loadMessages() but preserved on disk.
274
+ */
275
+ export async function compactSession(id: string): Promise<{ messagesBefore: number; summary: string }> {
276
+ const session = new Session(id);
277
+ const allMessages = await session.loadAllMessages();
278
+ const messagesBefore = allMessages.length;
279
+
280
+ // Generate summary from recent messages
281
+ const recentText = allMessages.slice(-10).map(m => {
282
+ const content = typeof m.content === "string" ? m.content : JSON.stringify(m.content);
283
+ return `${m.role}: ${content.slice(0, 200)}`;
284
+ }).join("\n");
285
+
286
+ const summary = `Session context (${messagesBefore} messages):\n${recentText}`;
287
+ await session.insertCompactBoundary(summary, messagesBefore);
288
+ return { messagesBefore, summary };
289
+ }
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Plan Mode — read-only exploration → strategic questions → plan → execute.
3
+ *
4
+ * When plan mode is active:
5
+ * - Only read-only tools are available (Read, Glob, Grep, WebFetch, AskUser)
6
+ * - Write/Edit/Bash are blocked
7
+ * - The model writes a plan to a .md file
8
+ * - User approves → plan mode exits → execution begins
9
+ */
10
+
11
+ import { writeFile, readFile, mkdir } from "fs/promises";
12
+ import { existsSync } from "fs";
13
+ import { join } from "path";
14
+ import { getConfigDir } from "../config/settings.ts";
15
+
16
+ export interface PlanState {
17
+ active: boolean;
18
+ planFilePath: string | null;
19
+ startedAt: string | null;
20
+ }
21
+
22
+ function getPlansDir(): string {
23
+ return join(getConfigDir(), "plans");
24
+ }
25
+
26
+ let state: PlanState = {
27
+ active: false,
28
+ planFilePath: null,
29
+ startedAt: null,
30
+ };
31
+
32
+ /**
33
+ * Generate a memorable plan name (adjective-noun format).
34
+ */
35
+ function generatePlanName(): string {
36
+ const adjectives = [
37
+ "swift", "bold", "calm", "deep", "keen", "bright", "clear", "sharp",
38
+ "steady", "agile", "precise", "focused", "direct", "elegant", "robust",
39
+ "clean", "solid", "smart", "lean", "fluid",
40
+ ];
41
+ const nouns = [
42
+ "falcon", "compass", "anchor", "bridge", "prism", "beacon", "forge",
43
+ "summit", "atlas", "cipher", "nexus", "pulse", "spark", "vector",
44
+ "zenith", "orbit", "ridge", "delta", "apex", "crest",
45
+ ];
46
+
47
+ const adj = adjectives[Math.floor(Math.random() * adjectives.length)]!;
48
+ const noun = nouns[Math.floor(Math.random() * nouns.length)]!;
49
+ return `${adj}-${noun}`;
50
+ }
51
+
52
+ export async function enterPlanMode(): Promise<string> {
53
+ const plansDir = getPlansDir();
54
+ await mkdir(plansDir, { recursive: true });
55
+
56
+ const planName = generatePlanName();
57
+ const planFile = join(plansDir, `${planName}.md`);
58
+
59
+ state = {
60
+ active: true,
61
+ planFilePath: planFile,
62
+ startedAt: new Date().toISOString(),
63
+ };
64
+
65
+ return planFile;
66
+ }
67
+
68
+ export function exitPlanMode(): void {
69
+ state = {
70
+ active: false,
71
+ planFilePath: null,
72
+ startedAt: null,
73
+ };
74
+ }
75
+
76
+ export function isPlanMode(): boolean {
77
+ return state.active;
78
+ }
79
+
80
+ export function getPlanFilePath(): string | null {
81
+ return state.planFilePath;
82
+ }
83
+
84
+ export function getPlanState(): PlanState {
85
+ return { ...state };
86
+ }
87
+
88
+ export async function writePlan(content: string): Promise<void> {
89
+ if (!state.planFilePath) throw new Error("No active plan");
90
+ await writeFile(state.planFilePath, content, "utf-8");
91
+ }
92
+
93
+ export async function readPlan(): Promise<string | null> {
94
+ if (!state.planFilePath || !existsSync(state.planFilePath)) return null;
95
+ return await readFile(state.planFilePath, "utf-8");
96
+ }
97
+
98
+ /**
99
+ * Get the plan mode system prompt addition.
100
+ */
101
+ export function getPlanModePrompt(): string {
102
+ if (!state.active) return "";
103
+
104
+ return `
105
+ ## PLAN MODE ACTIVE
106
+
107
+ You are in plan mode. You MUST follow these rules:
108
+ - You MUST NOT make any edits to files (no Write, Edit, or Bash commands that modify files)
109
+ - You CAN use read-only tools: Read, Glob, Grep, WebFetch, AskUser
110
+ - You SHOULD explore the codebase to understand the problem
111
+ - You SHOULD ask strategic questions using AskUser before proposing a plan
112
+ - You MUST write your plan to the plan file: ${state.planFilePath}
113
+ - Questions should emerge from actual code exploration, not be generic
114
+
115
+ ### Plan Workflow:
116
+ 1. Explore the codebase with read-only tools
117
+ 2. Ask 1-4 strategic questions using AskUser
118
+ 3. Write a detailed plan to the plan file using PlanWrite
119
+ 4. Call ExitPlan when the plan is ready for user approval
120
+
121
+ ### Plan File Format:
122
+ - Start with a Context section explaining why the change is needed
123
+ - Include specific file paths to modify
124
+ - Reference existing functions/utilities to reuse
125
+ - Include a verification section (how to test)
126
+ - Be concise but detailed enough to execute
127
+ `;
128
+ }
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Plan mode tools — EnterPlan, ExitPlan, PlanWrite.
3
+ */
4
+
5
+ import type { Tool, ToolContext } from "../tools/types.ts";
6
+ import {
7
+ enterPlanMode,
8
+ exitPlanMode,
9
+ isPlanMode,
10
+ getPlanFilePath,
11
+ writePlan,
12
+ readPlan,
13
+ } from "./plan-mode.ts";
14
+
15
+ export const enterPlanTool: Tool = {
16
+ name: "EnterPlan",
17
+
18
+ prompt() {
19
+ return "Enter plan mode. In plan mode, only read-only tools are available. Use this when you need to explore a codebase and design an approach before making changes. Creates a plan file for writing your plan.";
20
+ },
21
+
22
+ inputSchema() {
23
+ return {
24
+ type: "object",
25
+ properties: {},
26
+ required: [],
27
+ };
28
+ },
29
+
30
+ isReadOnly() {
31
+ return true;
32
+ },
33
+ isDestructive() {
34
+ return false;
35
+ },
36
+ isConcurrencySafe() {
37
+ return false;
38
+ },
39
+
40
+ validateInput() {
41
+ if (isPlanMode()) return "Already in plan mode";
42
+ return null;
43
+ },
44
+
45
+ async call(_input, _context) {
46
+ const planFile = await enterPlanMode();
47
+ return `Plan mode activated. Write your plan to: ${planFile}\n\nAvailable tools: Read, Glob, Grep, WebFetch, AskUser, PlanWrite, ExitPlan\nBlocked tools: Write, Edit, Bash (anything that modifies files)`;
48
+ },
49
+ };
50
+
51
+ export const exitPlanTool: Tool = {
52
+ name: "ExitPlan",
53
+
54
+ prompt() {
55
+ return "Exit plan mode. Call this when your plan is complete and ready for user approval. The plan file will be presented to the user for review.";
56
+ },
57
+
58
+ inputSchema() {
59
+ return {
60
+ type: "object",
61
+ properties: {},
62
+ required: [],
63
+ };
64
+ },
65
+
66
+ isReadOnly() {
67
+ return true;
68
+ },
69
+ isDestructive() {
70
+ return false;
71
+ },
72
+ isConcurrencySafe() {
73
+ return false;
74
+ },
75
+
76
+ validateInput() {
77
+ if (!isPlanMode()) return "Not in plan mode";
78
+ return null;
79
+ },
80
+
81
+ async call(_input, _context) {
82
+ const plan = await readPlan();
83
+ const planFile = getPlanFilePath();
84
+ exitPlanMode();
85
+
86
+ if (!plan) {
87
+ return "Plan mode exited. Warning: No plan was written to the plan file.";
88
+ }
89
+
90
+ return `Plan mode exited. Plan saved to: ${planFile}\n\n--- Plan Preview ---\n${plan.slice(0, 2000)}${plan.length > 2000 ? "\n\n[... truncated ...]" : ""}`;
91
+ },
92
+ };
93
+
94
+ export const planWriteTool: Tool = {
95
+ name: "PlanWrite",
96
+
97
+ prompt() {
98
+ return "Write content to the plan file. Use this in plan mode to record your implementation plan. Can be called multiple times to build the plan incrementally.";
99
+ },
100
+
101
+ inputSchema() {
102
+ return {
103
+ type: "object",
104
+ properties: {
105
+ content: {
106
+ type: "string",
107
+ description: "The plan content (markdown format)",
108
+ },
109
+ },
110
+ required: ["content"],
111
+ };
112
+ },
113
+
114
+ isReadOnly() {
115
+ return true; // Writing to the plan file is allowed in plan mode
116
+ },
117
+ isDestructive() {
118
+ return false;
119
+ },
120
+ isConcurrencySafe() {
121
+ return false;
122
+ },
123
+
124
+ validateInput(input) {
125
+ if (!isPlanMode()) return "Not in plan mode. Call EnterPlan first.";
126
+ if (!input.content || typeof input.content !== "string") {
127
+ return "content is required";
128
+ }
129
+ return null;
130
+ },
131
+
132
+ async call(input, _context) {
133
+ const content = input.content as string;
134
+ await writePlan(content);
135
+ const lines = content.split("\n").length;
136
+ return `Plan updated (${lines} lines). Call ExitPlan when ready for user review.`;
137
+ },
138
+ };