diff --git a/src/cli.tsx b/src/cli.tsx index 26e383c..daa4783 100644 --- a/src/cli.tsx +++ b/src/cli.tsx @@ -170,6 +170,14 @@ const promptHistory = await store.loadHistory(); const installedPlugins = has('--no-plugins') ? { plugins: [], errors: [] } : await registry.loadInstalledPlugins(); +// Full ignore-aware file list injected into the system prompt at boot. +// Same walk as `@` completion (5000 cap, .gitignore + .shiroignore, skips .git/node_modules), +// so a filtered repo stays small even though it's "full". +let workspaceFiles: string[] = []; +try { + for await (const rel of walk({ limit: 5000 })) workspaceFiles.push(rel); +} catch {} + /** Installed entries, as `kind:name`, so the registry list can mark what is already here. */ async function installedNames(): Promise> { const names = new Set(); @@ -321,6 +329,7 @@ const session = new Session({ skills, plugins, agent: agentVariant, + ...(workspaceFiles.length > 0 ? { workspaceFiles } : {}), ...(cfg.toolSets ? { toolSets: cfg.toolSets } : {}), ...(cfg.permission ? { permissions: cfg.permission } : {}), // Headless has no one to answer, so the tool is withheld rather than left to hang. @@ -343,6 +352,7 @@ const session = new Session({ model: languageModel ?? unconfiguredModel, subagentModel, subagentModelId: cfg.subagentModel, + ...(workspaceFiles.length > 0 ? { workspaceFiles } : {}), onUsage: (u) => recordSubagent(u), ...(headless ? {} : { report: subagents.emit }), // A worker's writes go through the parent's rules and the parent's diff --git a/src/prompt.ts b/src/prompt.ts index 1e9ba23..cb946d7 100644 --- a/src/prompt.ts +++ b/src/prompt.ts @@ -20,6 +20,8 @@ export type PromptParts = { canAsk?: boolean; /** MCP server names — listed by name only so their schemas cost nothing until mcp_call. */ mcpServers?: readonly string[]; + /** Ignore-aware workspace file list injected at boot (gitignore-respected, capped). */ + workspaceFiles?: readonly string[]; }; type ToolDoc = { name: string; line: string }; @@ -215,7 +217,7 @@ export function systemPrompt(parts: PromptParts): string { Environment - Workspace root: ${cwd} - Platform: ${process.platform} -- Paths are resolved inside the workspace. Anything outside it is refused. +- Paths are resolved inside the workspace. Anything outside it is refused.${parts.workspaceFiles && parts.workspaceFiles.length > 0 ? `\n- Workspace files (${parts.workspaceFiles.length}, gitignore-respected, capped 5000):\n${parts.workspaceFiles.join('\n')}` : ''} Tools available to you now ${renderTools(toolNames)}${mcpServers.length > 0 ? `\n\nMCP servers (${mcpServers.length}): ${mcpServers.join(', ')} — tools are NOT in the prompt. Use mcp_list to see what each exposes, mcp_inspect for a tool\'s schema, then mcp_call to run it. Each mcp_call needs approval like a built-in.` : ''} diff --git a/src/session.ts b/src/session.ts index 0aa2dab..492b63b 100644 --- a/src/session.ts +++ b/src/session.ts @@ -98,6 +98,8 @@ export type SessionOptions = { learnerModel?: LanguageModel; /** Emit learner notices to the UI. */ onNotice?: (text: string) => void; + /** Ignore-aware file list injected into the system prompt at boot; gitignore-respected. */ + workspaceFiles?: readonly string[]; /** Disable background auto-learn (tests). */ disableAutoLearn?: boolean; }; @@ -487,6 +489,7 @@ export class Session { availableTools: this.activeTools(), canAsk: this.opts.ask !== undefined && this.activeTools().includes('ask'), ...(this.mcpServerNamesForPrompt() ? { mcpServers: this.mcpServerNamesForPrompt() } : {}), + ...(this.opts.workspaceFiles && this.opts.workspaceFiles.length > 0 ? { workspaceFiles: this.opts.workspaceFiles } : {}), }); } diff --git a/src/subagent.ts b/src/subagent.ts index 1ee87e9..c66c4db 100644 --- a/src/subagent.ts +++ b/src/subagent.ts @@ -103,6 +103,11 @@ Rules: it said, and anything you could not finish. The parent cannot see your transcript.`, }; +function withWorkspaceFiles(base: string, workspaceFiles?: readonly string[]): string { + if (!workspaceFiles || workspaceFiles.length === 0) return base; + return `${base}\n\nWorkspace files (${workspaceFiles.length}, gitignore-respected, capped 5000):\n${workspaceFiles.join('\n')}`; +} + /** One line of detail for the panel: the argument that identifies the call. */ const summarize = (input: unknown): string => { if (input === null || typeof input !== 'object') return String(input); @@ -148,6 +153,7 @@ async function runOne( model: LanguageModel; subagentModel?: LanguageModel; cwd?: string; + workspaceFiles?: readonly string[]; maxSteps?: number; report?: SubagentReporter; approve?: SubagentApproval; @@ -170,7 +176,7 @@ async function runOne( const model = flavour === 'explore' ? (opts.subagentModel ?? opts.model) : opts.model; const result = streamText({ model, - system: PROMPTS[flavour](opts.cwd ?? process.cwd()), + system: withWorkspaceFiles(PROMPTS[flavour](opts.cwd ?? process.cwd()), opts.workspaceFiles), messages: [{ role: 'user', content: spec.prompt }], tools: TOOLS[flavour], stopWhen: isStepCount(opts.maxSteps ?? 20), @@ -236,6 +242,7 @@ export function createTaskTool(opts: { /** Its id, so the parent can price the subagent's spend separately. */ subagentModelId?: string; cwd?: string; + workspaceFiles?: readonly string[]; maxSteps?: number; report?: SubagentReporter; /** Parent-owned approval for a worker's gated calls. Omit to disable `worker`. */ @@ -304,6 +311,7 @@ export function createTaskTool(opts: { model: opts.model, subagentModel: opts.subagentModel, cwd: opts.cwd, + workspaceFiles: opts.workspaceFiles, maxSteps: opts.maxSteps, report: opts.report, approve: opts.approve, diff --git a/test/prompt-workspace.test.ts b/test/prompt-workspace.test.ts new file mode 100644 index 0000000..26ac393 --- /dev/null +++ b/test/prompt-workspace.test.ts @@ -0,0 +1,19 @@ +import { expect, test } from 'bun:test'; +import { systemPrompt } from '../src/prompt'; + +test('workspaceFiles are injected gitignore-style when provided', () => { + const prompt = systemPrompt({ cwd: '/repo', workspaceFiles: ['src/app.ts', 'README.md'] }); + expect(prompt).toContain('Workspace files (2, gitignore-respected'); + expect(prompt).toContain('src/app.ts'); + expect(prompt).toContain('README.md'); +}); + +test('workspaceFiles omitted when empty', () => { + const prompt = systemPrompt({ cwd: '/repo', workspaceFiles: [] }); + expect(prompt).not.toContain('Workspace files'); +}); + +test('workspaceFiles undefined leaves prompt unchanged', () => { + const prompt = systemPrompt({ cwd: '/repo' }); + expect(prompt).not.toContain('Workspace files'); +});