@oh-my-pi/pi-coding-agent 8.10.13 → 8.12.1

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.
@@ -5,43 +5,55 @@
5
5
  {{#if appendPrompt}}
6
6
  {{appendPrompt}}
7
7
  {{/if}}
8
- {{#if contextFiles.length}}
9
- # Project Context
8
+ {{#ifAny projectTree contextFiles.length git.isRepo}}
9
+ <project>
10
+ {{#if projectTree}}
11
+ ## Files
12
+ <tree>
13
+ {{projectTree}}
14
+ </tree>
15
+ {{/if}}
10
16
 
11
- <project_context_files>
17
+ {{#if contextFiles.length}}
18
+ ## Context
19
+ <instructions>
12
20
  {{#list contextFiles join="\n"}}
13
21
  <file path="{{path}}">
14
22
  {{content}}
15
23
  </file>
16
24
  {{/list}}
17
- </project_context_files>
25
+ </instructions>
18
26
  {{/if}}
27
+
19
28
  {{#if git.isRepo}}
20
- # Git Status
29
+ ## Version Control
30
+ This is a snapshot. It does not update during the conversation.
21
31
 
22
- This is the git status at the start of the conversation. Note that this status is a snapshot in time, and will not update during the conversation.
23
32
  Current branch: {{git.currentBranch}}
24
33
  Main branch: {{git.mainBranch}}
25
34
 
26
- Status:
27
35
  {{git.status}}
28
36
 
29
- Recent commits:
37
+ ### History
30
38
  {{git.commits}}
31
39
  {{/if}}
40
+ </project>
41
+ {{/ifAny}}
42
+
32
43
  {{#if skills.length}}
33
- The following skills provide specialized instructions for specific tasks.
34
- Use the read tool to load a skill's file when the task matches its description.
44
+ Skills are specialized knowledge.
45
+ They exist because someone learned the hard way.
35
46
 
36
- <available_skills>
47
+ Scan descriptions against your task domain.
48
+ If a skill covers what you're producing, read `skill://<name>` before proceeding.
49
+
50
+ <skills>
37
51
  {{#list skills join="\n"}}
38
- <skill>
39
- <name>{{escapeXml name}}</name>
40
- <description>{{escapeXml description}}</description>
41
- <location>skill://{{escapeXml name}}</location>
52
+ <skill name="{{name}}">
53
+ {{description}}
42
54
  </skill>
43
55
  {{/list}}
44
- </available_skills>
56
+ </skills>
45
57
  {{/if}}
46
58
  {{#if preloadedSkills.length}}
47
59
  The following skills are preloaded in full. Apply their instructions directly.
@@ -49,34 +61,28 @@ The following skills are preloaded in full. Apply their instructions directly.
49
61
  <preloaded_skills>
50
62
  {{#list preloadedSkills join="\n"}}
51
63
  <skill name="{{name}}">
52
- <location>skill://{{escapeXml name}}</location>
53
- <content>
54
64
  {{content}}
55
- </content>
56
65
  </skill>
57
66
  {{/list}}
58
67
  </preloaded_skills>
59
68
  {{/if}}
60
69
  {{#if rules.length}}
61
- The following rules define project-specific guidelines and constraints:
70
+ Rules are local constraints.
71
+ They exist because someone made a mistake here before.
72
+
73
+ Read `rule://<name>` when working in their domain.
62
74
 
63
75
  <rules>
64
76
  {{#list rules join="\n"}}
65
- <rule>
66
- <name>{{escapeXml name}}</name>
67
- <description>{{escapeXml description}}</description>
77
+ <rule name="{{name}}">
78
+ {{description}}
68
79
  {{#if globs.length}}
69
- <globs>
70
- {{#list globs join="\n"}}
71
- <glob>{{escapeXml this}}</glob>
72
- {{/list}}
73
- </globs>
80
+ {{#list globs join="\n"}}<glob>{{this}}</glob>{{/list}}
74
81
  {{/if}}
75
- <location>rule://{{escapeXml name}}</location>
76
82
  </rule>
77
83
  {{/list}}
78
84
  </rules>
79
85
  {{/if}}
80
86
 
81
87
  Current date and time: {{dateTime}}
82
- Current working directory: {{cwd}}
88
+ Current working directory: {{cwd}}
@@ -221,22 +221,27 @@ It lies. The code that runs is not the code that works.
221
221
  - Resolve blockers before yielding.
222
222
  </procedure>
223
223
 
224
- <context>
224
+ <project>
225
+ {{#if projectTree}}
226
+ ## Files
227
+ <tree>
228
+ {{projectTree}}
229
+ </tree>
230
+ {{/if}}
231
+
225
232
  {{#if contextFiles.length}}
226
- <project_context_files>
233
+ ## Context
234
+ <instructions>
227
235
  {{#list contextFiles join="\n"}}
228
236
  <file path="{{path}}">
229
237
  {{content}}
230
238
  </file>
231
239
  {{/list}}
232
- </project_context_files>
240
+ </instructions>
233
241
  {{/if}}
234
- </context>
235
242
 
236
243
  {{#if git.isRepo}}
237
- <vcs>
238
- # Git Status
239
-
244
+ ## Version Control
240
245
  This is a snapshot. It does not update during the conversation.
241
246
 
242
247
  Current branch: {{git.currentBranch}}
@@ -244,23 +249,22 @@ Main branch: {{git.mainBranch}}
244
249
 
245
250
  {{git.status}}
246
251
 
247
- ## History
248
-
252
+ ### History
249
253
  {{git.commits}}
250
- </vcs>
251
254
  {{/if}}
255
+ </project>
256
+
252
257
  {{#if skills.length}}
253
258
  <skills>
254
259
  Skills are specialized knowledge.
255
260
  They exist because someone learned the hard way.
256
261
 
257
262
  Scan descriptions against your task domain.
258
- If a skill covers what you're producing, read it before proceeding.
263
+ If a skill covers what you're producing, read `skill://<name>` before proceeding.
259
264
 
260
265
  {{#list skills join="\n"}}
261
266
  <skill name="{{name}}">
262
267
  {{description}}
263
- <path>skill://{{name}}</path>
264
268
  </skill>
265
269
  {{/list}}
266
270
  </skills>
@@ -271,7 +275,6 @@ The following skills are preloaded in full. Apply their instructions directly.
271
275
 
272
276
  {{#list preloadedSkills join="\n"}}
273
277
  <skill name="{{name}}">
274
- <location>skill://{{escapeXml name}}</location>
275
278
  {{content}}
276
279
  </skill>
277
280
  {{/list}}
@@ -282,12 +285,12 @@ The following skills are preloaded in full. Apply their instructions directly.
282
285
  Rules are local constraints.
283
286
  They exist because someone made a mistake here before.
284
287
 
285
- Load when working in their domain:
288
+ Read `rule://<name>` when working in their domain.
289
+
286
290
  {{#list rules join="\n"}}
287
291
  <rule name="{{name}}">
288
292
  {{description}}
289
293
  {{#list globs join="\n"}}<glob>{{this}}</glob>{{/list}}
290
- <path>rule://{{name}}</path>
291
294
  </rule>
292
295
  {{/list}}
293
296
  </rules>
@@ -17,6 +17,7 @@ Performs patch operations on a file given a diff. Primary tool for modifying exi
17
17
  **Context Lines:**
18
18
  - Include enough ` `-prefixed lines to make match unique (usually 2–8 total)
19
19
  - Must exist in the file exactly as written (preserve indentation/trailing spaces)
20
+ - When editing structured blocks (nested braces, tags, indented regions), include opening and closing lines in context so the edit stays inside the block
20
21
  </instruction>
21
22
 
22
23
  <parameters>
@@ -47,6 +48,9 @@ Returns success/failure status. On failure, returns error message indicating:
47
48
  - Always read the target file before editing
48
49
  - Copy anchors and context lines verbatim (including whitespace)
49
50
  - Never use anchors as comments (no line numbers, location labels, or placeholders like `@@ @@`)
51
+ - Do not place new lines outside the intended block unless that is the explicit goal
52
+ - If an edit fails or produces broken structure, re-read the file and produce a new patch from current content—do not retry the same diff
53
+ - If indentation is wrong after editing, run the project's formatter (if available) rather than making repeated edit attempts
50
54
  </critical>
51
55
 
52
56
  <example name="create">
@@ -69,4 +73,6 @@ edit {"path":"obsolete.txt","op":"delete"}
69
73
  - Generic anchors: `import`, `export`, `describe`, `function`, `const`
70
74
  - Anchor comments: `line 207`, `top of file`, `near imports`, `...`
71
75
  - Editing without reading the file first (causes stale context errors)
76
+ - Repeating the same addition in multiple hunks (creates duplicate blocks)
77
+ - Falling back to full-file overwrites for minor changes (acceptable for major restructures or short files)
72
78
  </avoid>
@@ -1165,6 +1165,62 @@ export class AgentSession {
1165
1165
  return;
1166
1166
  }
1167
1167
 
1168
+ const userContent: (TextContent | ImageContent)[] = [{ type: "text", text: expandedText }];
1169
+ if (options?.images) {
1170
+ userContent.push(...options.images);
1171
+ }
1172
+
1173
+ await this._promptWithMessage(
1174
+ {
1175
+ role: "user",
1176
+ content: userContent,
1177
+ synthetic: options?.synthetic,
1178
+ timestamp: Date.now(),
1179
+ },
1180
+ expandedText,
1181
+ options,
1182
+ );
1183
+ }
1184
+
1185
+ async promptCustomMessage<T = unknown>(
1186
+ message: Pick<CustomMessage<T>, "customType" | "content" | "display" | "details">,
1187
+ options?: Pick<PromptOptions, "streamingBehavior" | "toolChoice">,
1188
+ ): Promise<void> {
1189
+ const textContent =
1190
+ typeof message.content === "string"
1191
+ ? message.content
1192
+ : message.content
1193
+ .filter((content): content is TextContent => content.type === "text")
1194
+ .map(content => content.text)
1195
+ .join("");
1196
+
1197
+ if (this.isStreaming) {
1198
+ if (!options?.streamingBehavior) {
1199
+ throw new Error(
1200
+ "Agent is already processing. Specify streamingBehavior ('steer' or 'followUp') to queue the message.",
1201
+ );
1202
+ }
1203
+ await this.sendCustomMessage(message, { deliverAs: options.streamingBehavior });
1204
+ return;
1205
+ }
1206
+
1207
+ const customMessage: CustomMessage<T> = {
1208
+ role: "custom",
1209
+ customType: message.customType,
1210
+ content: message.content,
1211
+ display: message.display,
1212
+ details: message.details,
1213
+ timestamp: Date.now(),
1214
+ };
1215
+
1216
+ await this._promptWithMessage(customMessage, textContent, options);
1217
+ }
1218
+
1219
+ private async _promptWithMessage(
1220
+ message: AgentMessage,
1221
+ expandedText: string,
1222
+ options?: Pick<PromptOptions, "toolChoice" | "images">,
1223
+ ): Promise<void> {
1168
1224
  // Flush any pending bash messages before the new prompt
1169
1225
  this._flushPendingBashMessages();
1170
1226
  this._flushPendingPythonMessages();
@@ -1207,17 +1263,7 @@ export class AgentSession {
1207
1263
  messages.push(planModeMessage);
1208
1264
  }
1209
1265
 
1210
- // Add user message
1211
- const userContent: (TextContent | ImageContent)[] = [{ type: "text", text: expandedText }];
1212
- if (options?.images) {
1213
- userContent.push(...options.images);
1214
- }
1215
- messages.push({
1216
- role: "user",
1217
- content: userContent,
1218
- synthetic: options?.synthetic,
1219
- timestamp: Date.now(),
1220
- });
1266
+ messages.push(message);
1221
1267
 
1222
1268
  // Inject any pending "nextTurn" messages as context alongside the user message
1223
1269
  for (const msg of this._pendingNextTurnMessages) {
@@ -15,6 +15,15 @@ import { formatOutputNotice } from "../tools/output-meta";
15
15
  const COMPACTION_SUMMARY_TEMPLATE = compactionSummaryContextPrompt;
16
16
  const BRANCH_SUMMARY_TEMPLATE = branchSummaryContextPrompt;
17
17
 
18
+ export const SKILL_PROMPT_MESSAGE_TYPE = "skill-prompt";
19
+
20
+ export interface SkillPromptDetails {
21
+ name: string;
22
+ path: string;
23
+ args?: string;
24
+ lineCount: number;
25
+ }
26
+
18
27
  function getPrunedToolResultContent(message: ToolResultMessage): (TextContent | ImageContent)[] {
19
28
  if (message.prunedAt === undefined) {
20
29
  return message.content;
@@ -1,6 +1,8 @@
1
1
  /**
2
2
  * System prompt construction and project context loading
3
3
  */
4
+ import type * as fsTypes from "node:fs";
5
+ import * as fs from "node:fs/promises";
4
6
  import * as os from "node:os";
5
7
  import * as path from "node:path";
6
8
  import { $ } from "bun";
@@ -14,6 +16,8 @@ import { loadSkills, type Skill } from "./extensibility/skills";
14
16
  import customSystemPromptTemplate from "./prompts/system/custom-system-prompt.md" with { type: "text" };
15
17
  import systemPromptTemplate from "./prompts/system/system-prompt.md" with { type: "text" };
16
18
  import type { ToolName } from "./tools";
19
+ import { runRg } from "./tools/grep";
20
+ import { ensureTool } from "./utils/tools-manager";
17
21
 
18
22
  interface GitContext {
19
23
  isRepo: boolean;
@@ -129,6 +133,24 @@ function stripQuotes(value: string): string {
129
133
 
130
134
  const AGENTS_MD_PATTERN = "**/AGENTS.md";
131
135
  const AGENTS_MD_LIMIT = 200;
136
+ const PROJECT_TREE_LIMIT = 2000;
137
+ const PROJECT_TREE_PER_DIR_LIMIT = 10;
138
+ const PROJECT_TREE_PER_DIR_DEPTH = 2;
139
+ const PROJECT_TREE_IGNORED = new Set([
140
+ ".git",
141
+ ".hg",
142
+ ".svn",
143
+ ".next",
144
+ ".turbo",
145
+ ".cache",
146
+ ".venv",
147
+ ".idea",
148
+ ".vscode",
149
+ "build",
150
+ "dist",
151
+ "node_modules",
152
+ "target",
153
+ ]);
132
154
 
133
155
  interface AgentsMdSearch {
134
156
  scopePath: string;
@@ -166,6 +188,273 @@ function buildAgentsMdSearch(cwd: string): AgentsMdSearch {
166
188
  };
167
189
  }
168
190
 
191
+ type ProjectTreeEntry = {
192
+ name: string;
193
+ isDirectory: boolean;
194
+ path: string;
195
+ };
196
+
197
+ type ProjectTreeScan = {
198
+ children: Map<string, ProjectTreeEntry[]>;
199
+ truncated: boolean;
200
+ truncatedDirs: Set<string>;
201
+ };
202
+
203
+ const RG_TIMEOUT_MS = 5000;
204
+
205
+ /**
206
+ * Scan project tree using ripgrep to respect gitignore.
207
+ * Returns null if ripgrep is unavailable.
208
+ */
209
+ async function scanProjectTreeWithRg(root: string): Promise<ProjectTreeScan | null> {
210
+ const rgPath = await ensureTool("rg", { silent: true });
211
+ if (!rgPath) return null;
212
+
213
+ const args = ["--files", "--no-require-git", "--color=never", root];
214
+
215
+ let stdout: string;
216
+ try {
217
+ const signal = AbortSignal.timeout(RG_TIMEOUT_MS);
218
+ const result = await runRg(rgPath, args, signal);
219
+ if (result.exitCode !== 0 && result.exitCode !== 1) return null;
220
+ stdout = result.stdout;
221
+ } catch {
222
+ return null;
223
+ }
224
+
225
+ // Build directory contents map from file list
226
+ // Map<dirPath, Map<entryPath, isDirectory>>
227
+ const dirContents = new Map<string, Map<string, boolean>>();
228
+ dirContents.set(root, new Map());
229
+
230
+ for (const line of stdout.split("\n")) {
231
+ const filePath = line.trim();
232
+ if (!filePath) continue;
233
+
234
+ // Check static ignores on path components
235
+ const relative = path.relative(root, filePath);
236
+ const parts = relative.split(path.sep);
237
+ if (parts.some(p => PROJECT_TREE_IGNORED.has(p))) continue;
238
+
239
+ // Add file to its parent directory
240
+ const parent = path.dirname(filePath);
241
+ if (!dirContents.has(parent)) dirContents.set(parent, new Map());
242
+ dirContents.get(parent)!.set(filePath, false);
243
+
244
+ // Add all intermediate directories
245
+ let dir = parent;
246
+ while (dir.length >= root.length && dir !== path.dirname(dir)) {
247
+ const parentDir = path.dirname(dir);
248
+ if (!dirContents.has(parentDir)) dirContents.set(parentDir, new Map());
249
+ dirContents.get(parentDir)!.set(dir, true);
250
+ dir = parentDir;
251
+ }
252
+ }
253
+
254
+ // BFS to build the tree with limits
255
+ const children = new Map<string, ProjectTreeEntry[]>();
256
+ let entryCount = 0;
257
+ let truncated = false;
258
+ const truncatedDirs = new Set<string>();
259
+
260
+ const queue: Array<{ dirPath: string; depth: number }> = [{ dirPath: root, depth: 0 }];
261
+ let cursor = 0;
262
+
263
+ while (cursor < queue.length && !truncated) {
264
+ const { dirPath, depth } = queue[cursor];
265
+ cursor += 1;
266
+
267
+ const contents = dirContents.get(dirPath);
268
+ if (!contents || contents.size === 0) continue;
269
+
270
+ // Get stats for sorting
271
+ const entries = Array.from(contents.entries());
272
+ const withStats = await Promise.all(
273
+ entries.map(async ([entryPath, isDirectory]) => {
274
+ try {
275
+ const stats = await fs.stat(entryPath);
276
+ return { entryPath, isDirectory, mtimeMs: stats.mtimeMs };
277
+ } catch {
278
+ return { entryPath, isDirectory, mtimeMs: 0 };
279
+ }
280
+ }),
281
+ );
282
+
283
+ withStats.sort((a, b) => {
284
+ if (a.mtimeMs !== b.mtimeMs) return b.mtimeMs - a.mtimeMs;
285
+ return path.basename(a.entryPath).localeCompare(path.basename(b.entryPath));
286
+ });
287
+
288
+ const perDirLimit = depth >= PROJECT_TREE_PER_DIR_DEPTH ? PROJECT_TREE_PER_DIR_LIMIT : null;
289
+ const limited = perDirLimit === null ? withStats : withStats.slice(0, perDirLimit);
290
+ const hasMoreEntries = perDirLimit !== null && withStats.length > perDirLimit;
291
+
292
+ const mapped: ProjectTreeEntry[] = [];
293
+ for (const { entryPath, isDirectory } of limited) {
294
+ if (entryCount >= PROJECT_TREE_LIMIT) {
295
+ truncated = true;
296
+ break;
297
+ }
298
+
299
+ mapped.push({
300
+ name: path.basename(entryPath),
301
+ isDirectory,
302
+ path: entryPath,
303
+ });
304
+ entryCount += 1;
305
+
306
+ if (isDirectory) {
307
+ queue.push({ dirPath: entryPath, depth: depth + 1 });
308
+ }
309
+ }
310
+
311
+ if (!truncated && hasMoreEntries) {
312
+ truncatedDirs.add(dirPath);
313
+ }
314
+ children.set(dirPath, mapped);
315
+ }
316
+
317
+ return { children, truncated, truncatedDirs };
318
+ }
319
+
320
+ /**
321
+ * Fallback scan using readdir when ripgrep is unavailable.
322
+ */
323
+ async function scanProjectTreeFallback(root: string): Promise<ProjectTreeScan> {
324
+ const children = new Map<string, ProjectTreeEntry[]>();
325
+ let entryCount = 0;
326
+ let truncated = false;
327
+ const truncatedDirs = new Set<string>();
328
+
329
+ const queue: Array<{ dirPath: string; depth: number }> = [{ dirPath: root, depth: 0 }];
330
+ let cursor = 0;
331
+
332
+ while (cursor < queue.length && !truncated) {
333
+ const { dirPath, depth } = queue[cursor];
334
+ cursor += 1;
335
+ let entries: fsTypes.Dirent[];
336
+ try {
337
+ entries = await fs.readdir(dirPath, { withFileTypes: true });
338
+ } catch {
339
+ continue;
340
+ }
341
+
342
+ const filtered = entries.filter(entry => !PROJECT_TREE_IGNORED.has(entry.name));
343
+ const withStats = await Promise.all(
344
+ filtered.map(async entry => {
345
+ const entryPath = path.join(dirPath, entry.name);
346
+ try {
347
+ const stats = await fs.stat(entryPath);
348
+ return { entry, entryPath, mtimeMs: stats.mtimeMs };
349
+ } catch {
350
+ return { entry, entryPath, mtimeMs: 0 };
351
+ }
352
+ }),
353
+ );
354
+
355
+ withStats.sort((a, b) => {
356
+ if (a.mtimeMs !== b.mtimeMs) return b.mtimeMs - a.mtimeMs;
357
+ return a.entry.name.localeCompare(b.entry.name);
358
+ });
359
+
360
+ const perDirLimit = depth >= PROJECT_TREE_PER_DIR_DEPTH ? PROJECT_TREE_PER_DIR_LIMIT : null;
361
+ const limited = perDirLimit === null ? withStats : withStats.slice(0, perDirLimit);
362
+ const hasMoreEntries = perDirLimit !== null && withStats.length > perDirLimit;
363
+
364
+ const mapped: ProjectTreeEntry[] = [];
365
+ for (const entryWithStat of limited) {
366
+ if (entryCount >= PROJECT_TREE_LIMIT) {
367
+ truncated = true;
368
+ break;
369
+ }
370
+
371
+ mapped.push({
372
+ name: entryWithStat.entry.name,
373
+ isDirectory: entryWithStat.entry.isDirectory(),
374
+ path: entryWithStat.entryPath,
375
+ });
376
+ entryCount += 1;
377
+
378
+ if (entryWithStat.entry.isDirectory()) {
379
+ queue.push({ dirPath: entryWithStat.entryPath, depth: depth + 1 });
380
+ }
381
+ }
382
+
383
+ if (!truncated && hasMoreEntries) {
384
+ truncatedDirs.add(dirPath);
385
+ }
386
+ children.set(dirPath, mapped);
387
+ }
388
+
389
+ return { children, truncated, truncatedDirs };
390
+ }
391
+
392
+ async function scanProjectTree(root: string): Promise<ProjectTreeScan> {
393
+ const rgResult = await scanProjectTreeWithRg(root);
394
+ if (rgResult) return rgResult;
395
+ return scanProjectTreeFallback(root);
396
+ }
397
+
398
+ function renderProjectTree(scan: ProjectTreeScan, root: string): string {
399
+ const lines: string[] = [];
400
+
401
+ const collapseDir = (dirPath: string): { path: string; entries: ProjectTreeEntry[] } | null => {
402
+ let currentPath = dirPath;
403
+ while (true) {
404
+ const entries = scan.children.get(currentPath);
405
+ if (!entries || entries.length === 0) return null;
406
+ const files = entries.filter(entry => !entry.isDirectory);
407
+ const dirs = entries.filter(entry => entry.isDirectory);
408
+ if (files.length === 0 && dirs.length === 1 && !scan.truncatedDirs.has(currentPath)) {
409
+ currentPath = dirs[0].path;
410
+ continue;
411
+ }
412
+ return { path: currentPath, entries };
413
+ }
414
+ };
415
+
416
+ const renderDir = (dirPath: string, indent: string, isRoot: boolean): void => {
417
+ const collapsed = collapseDir(dirPath);
418
+ if (!collapsed) return;
419
+ const { path: collapsedPath, entries } = collapsed;
420
+
421
+ // For non-root directories, print the header and indent contents
422
+ const contentIndent = isRoot ? indent : `${indent} `;
423
+ if (!isRoot) {
424
+ const relative = path.relative(root, collapsedPath) || ".";
425
+ lines.push(`${indent}@ ${relative}`);
426
+ }
427
+
428
+ const files = entries.filter(entry => !entry.isDirectory);
429
+ const dirs = entries.filter(entry => entry.isDirectory);
430
+
431
+ for (const entry of files) {
432
+ lines.push(`${contentIndent}- ${entry.name}`);
433
+ }
434
+
435
+ if (scan.truncatedDirs.has(collapsedPath)) {
436
+ lines.push(`${contentIndent}- …`);
437
+ }
438
+
439
+ for (const entry of dirs) {
440
+ renderDir(entry.path, contentIndent, false);
441
+ }
442
+ };
443
+
444
+ renderDir(root, "", true);
445
+
446
+ if (scan.truncated) {
447
+ lines.push("…");
448
+ }
449
+
450
+ return lines.join("\n");
451
+ }
452
+
453
+ async function buildProjectTreeSnapshot(root: string): Promise<string> {
454
+ const scan = await scanProjectTree(root);
455
+ return renderProjectTree(scan, root);
456
+ }
457
+
169
458
  function getOsName(): string {
170
459
  switch (process.platform) {
171
460
  case "win32":
@@ -707,6 +996,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
707
996
  // Resolve context files: use provided or discover
708
997
  const contextFiles = providedContextFiles ?? (await loadProjectContextFiles({ cwd: resolvedCwd }));
709
998
  const agentsMdSearch = buildAgentsMdSearch(resolvedCwd);
999
+ const projectTree = await buildProjectTreeSnapshot(resolvedCwd);
710
1000
 
711
1001
  // Build tool descriptions array
712
1002
  // Priority: toolNames (explicit list) > tools (Map) > defaults
@@ -744,6 +1034,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
744
1034
  customPrompt: resolvedCustomPrompt,
745
1035
  appendPrompt: resolvedAppendPrompt ?? "",
746
1036
  contextFiles,
1037
+ projectTree,
747
1038
  agentsMdSearch,
748
1039
  git,
749
1040
  skills: filteredSkills,
@@ -759,6 +1050,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
759
1050
  environment: await getEnvironmentInfo(),
760
1051
  systemPromptCustomization: systemPromptCustomization ?? "",
761
1052
  contextFiles,
1053
+ projectTree,
762
1054
  agentsMdSearch,
763
1055
  git,
764
1056
  skills: filteredSkills,
@@ -5,6 +5,7 @@
5
5
  */
6
6
  import { renderPromptTemplate } from "../config/prompt-templates";
7
7
  import { parseAgentFields } from "../discovery/helpers";
8
+ import designerMd from "../prompts/agents/designer.md" with { type: "text" };
8
9
  import exploreMd from "../prompts/agents/explore.md" with { type: "text" };
9
10
  // Embed agent markdown files at build time
10
11
  import agentFrontmatterTemplate from "../prompts/agents/frontmatter.md" with { type: "text" };
@@ -37,6 +38,7 @@ function buildAgentContent(def: EmbeddedAgentDef): string {
37
38
  const EMBEDDED_AGENT_DEFS: EmbeddedAgentDef[] = [
38
39
  { fileName: "explore.md", template: exploreMd },
39
40
  { fileName: "plan.md", template: planMd },
41
+ { fileName: "designer.md", template: designerMd },
40
42
  { fileName: "reviewer.md", template: reviewerMd },
41
43
  {
42
44
  fileName: "task.md",
@@ -57,15 +59,6 @@ const EMBEDDED_AGENT_DEFS: EmbeddedAgentDef[] = [
57
59
  },
58
60
  template: taskMd,
59
61
  },
60
- {
61
- fileName: "deep_task.md",
62
- frontmatter: {
63
- name: "deep_task",
64
- description: "Deep task for comprehensive reasoning",
65
- model: "pi/slow",
66
- },
67
- template: taskMd,
68
- },
69
62
  ];
70
63
 
71
64
  const EMBEDDED_AGENTS: { name: string; content: string }[] = EMBEDDED_AGENT_DEFS.map(def => ({