diff --git a/packages/coding-agent/src/core/package-manager.ts b/packages/coding-agent/src/core/package-manager.ts index e8c9f45..26410de 100644 --- a/packages/coding-agent/src/core/package-manager.ts +++ b/packages/coding-agent/src/core/package-manager.ts @@ -368,6 +368,47 @@ function collectAncestorAgentsSkillDirs(startDir: string): string[] { return skillDirs; } +function collectCompanionWorkspaceSkillDirs( + startDir: string, + agentDir: string, +): string[] { + const skillDirs: string[] = []; + const seen = new Set(); + const configDir = dirname(resolve(agentDir)); + const defaultWorkspaceDir = join(configDir, "workspace"); + + const addDir = (dir: string): void => { + const skillDir = join(resolve(dir), ".agents", "skills"); + if (seen.has(skillDir)) { + return; + } + skillDirs.push(skillDir); + seen.add(skillDir); + }; + + if (existsSync(defaultWorkspaceDir)) { + addDir(defaultWorkspaceDir); + } + + let dir = resolve(startDir); + while (true) { + if (dirname(dir) === configDir) { + const dirName = basename(dir); + if (dirName === "workspace" || dirName.startsWith("workspace-")) { + addDir(dir); + } + } + + const parent = dirname(dir); + if (parent === dir) { + break; + } + dir = parent; + } + + return skillDirs; +} + function collectAutoPromptEntries(dir: string): string[] { const entries: string[] = []; if (!existsSync(dir)) return entries; @@ -1897,6 +1938,10 @@ export class DefaultPackageManager implements PackageManager { }; const userAgentsSkillsDir = join(homedir(), ".agents", "skills"); const projectAgentsSkillDirs = collectAncestorAgentsSkillDirs(this.cwd); + const companionWorkspaceSkillDirs = collectCompanionWorkspaceSkillDirs( + this.cwd, + this.agentDir, + ); const addResources = ( resourceType: ResourceType, @@ -1923,6 +1968,9 @@ export class DefaultPackageManager implements PackageManager { "skills", [ ...collectAutoSkillEntries(projectDirs.skills), + ...companionWorkspaceSkillDirs.flatMap((dir) => + collectAutoSkillEntries(dir), + ), ...projectAgentsSkillDirs.flatMap((dir) => collectAutoSkillEntries(dir), ), diff --git a/packages/coding-agent/src/core/resource-loader.ts b/packages/coding-agent/src/core/resource-loader.ts index 938d3c6..13e5fdd 100644 --- a/packages/coding-agent/src/core/resource-loader.ts +++ b/packages/coding-agent/src/core/resource-loader.ts @@ -1,6 +1,6 @@ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; import { homedir } from "node:os"; -import { join, resolve, sep } from "node:path"; +import { basename, dirname, join, resolve, sep } from "node:path"; import chalk from "chalk"; import { CONFIG_DIR_NAME, getAgentDir } from "../config.js"; import { @@ -120,6 +120,78 @@ function loadNamedContextFileFromDir( } } +const companionContextFilenames = [ + "IDENTITY.md", + "SOUL.md", + "USER.md", + "TOOLS.md", + "HEARTBEAT.md", + "MEMORY.md", + "BOOTSTRAP.md", +]; + +function loadNamedContextFilesFromDir( + dir: string, + filenames: string[], +): Array<{ path: string; content: string }> { + const files: Array<{ path: string; content: string }> = []; + for (const filename of filenames) { + const file = loadNamedContextFileFromDir(dir, filename); + if (file) { + files.push(file); + } + } + return files; +} + +function addContextFile( + contextFiles: Array<{ path: string; content: string }>, + seenPaths: Set, + file: { path: string; content: string } | null, +): void { + if (!file || seenPaths.has(file.path)) { + return; + } + contextFiles.push(file); + seenPaths.add(file.path); +} + +function collectCompanionContextDirs(cwd: string, agentDir: string): string[] { + const contextDirs: string[] = []; + const seenDirs = new Set(); + const configDir = dirname(resolve(agentDir)); + const defaultWorkspaceDir = join(configDir, "workspace"); + + const addDir = (dir: string): void => { + const resolvedDir = resolve(dir); + if (!existsSync(resolvedDir) || seenDirs.has(resolvedDir)) { + return; + } + contextDirs.push(resolvedDir); + seenDirs.add(resolvedDir); + }; + + addDir(defaultWorkspaceDir); + + let currentDir = resolve(cwd); + while (true) { + if (dirname(currentDir) === configDir) { + const dirName = basename(currentDir); + if (dirName === "workspace" || dirName.startsWith("workspace-")) { + addDir(currentDir); + } + } + + const parentDir = dirname(currentDir); + if (parentDir === currentDir) { + break; + } + currentDir = parentDir; + } + + return contextDirs; +} + function loadProjectContextFiles( options: { cwd?: string; agentDir?: string } = {}, ): Array<{ path: string; content: string }> { @@ -129,11 +201,11 @@ function loadProjectContextFiles( const contextFiles: Array<{ path: string; content: string }> = []; const seenPaths = new Set(); - const globalContext = loadContextFileFromDir(resolvedAgentDir); - if (globalContext) { - contextFiles.push(globalContext); - seenPaths.add(globalContext.path); - } + addContextFile( + contextFiles, + seenPaths, + loadContextFileFromDir(resolvedAgentDir), + ); const ancestorContextFiles: Array<{ path: string; content: string }> = []; @@ -156,17 +228,29 @@ function loadProjectContextFiles( contextFiles.push(...ancestorContextFiles); - const globalSoul = loadNamedContextFileFromDir(resolvedAgentDir, "SOUL.md"); - if (globalSoul && !seenPaths.has(globalSoul.path)) { - contextFiles.push(globalSoul); - seenPaths.add(globalSoul.path); + addContextFile( + contextFiles, + seenPaths, + loadNamedContextFileFromDir(resolvedAgentDir, "SOUL.md"), + ); + + for (const companionDir of collectCompanionContextDirs( + resolvedCwd, + resolvedAgentDir, + )) { + for (const file of loadNamedContextFilesFromDir( + companionDir, + companionContextFilenames, + )) { + addContextFile(contextFiles, seenPaths, file); + } } - const projectSoul = loadNamedContextFileFromDir(resolvedCwd, "SOUL.md"); - if (projectSoul && !seenPaths.has(projectSoul.path)) { - contextFiles.push(projectSoul); - seenPaths.add(projectSoul.path); - } + addContextFile( + contextFiles, + seenPaths, + loadNamedContextFileFromDir(resolvedCwd, "SOUL.md"), + ); return contextFiles; } diff --git a/packages/coding-agent/src/core/system-prompt.ts b/packages/coding-agent/src/core/system-prompt.ts index 9fb37ac..4098bbe 100644 --- a/packages/coding-agent/src/core/system-prompt.ts +++ b/packages/coding-agent/src/core/system-prompt.ts @@ -49,12 +49,42 @@ function buildProjectContextSection( ({ path }) => path.replaceAll("\\", "/").endsWith("/SOUL.md") || path === "SOUL.md", ); + const hasContextFile = (filename: string) => + contextFiles.some( + ({ path }) => + path.replaceAll("\\", "/").endsWith(`/${filename}`) || + path === filename, + ); let section = "\n\n# Project Context\n\n"; section += "Project-specific instructions and guidelines:\n"; if (hasSoulFile) { section += "\nIf SOUL.md is present, embody its persona and tone. Avoid generic assistant filler and follow its guidance unless higher-priority instructions override it.\n"; } + if (hasContextFile("IDENTITY.md")) { + section += + "\nIf IDENTITY.md is present, treat it as the agent's self-description and stay consistent with it.\n"; + } + if (hasContextFile("USER.md")) { + section += + "\nIf USER.md is present, use it as durable context about the user and avoid re-asking for facts already captured there.\n"; + } + if (hasContextFile("MEMORY.md")) { + section += + "\nIf MEMORY.md is present, use it as long-term memory and keep it aligned with durable user or project context when the task calls for it.\n"; + } + if (hasContextFile("TOOLS.md")) { + section += + "\nIf TOOLS.md is present, treat it as the source of truth for the current sandbox filesystem, app locations, and environment-specific workflow details.\n"; + } + if (hasContextFile("HEARTBEAT.md")) { + section += + "\nIf HEARTBEAT.md is present, honor it as recurring operational guidance for the agent.\n"; + } + if (hasContextFile("BOOTSTRAP.md")) { + section += + "\nIf BOOTSTRAP.md is present, treat it as an actionable onboarding task list and execute it before drifting into unrelated work.\n"; + } section += "\n"; for (const { path: filePath, content } of contextFiles) { section += `## ${filePath}\n\n${content}\n\n`; diff --git a/packages/coding-agent/test/package-manager.test.ts b/packages/coding-agent/test/package-manager.test.ts index c3e10d7..5c84157 100644 --- a/packages/coding-agent/test/package-manager.test.ts +++ b/packages/coding-agent/test/package-manager.test.ts @@ -322,6 +322,38 @@ Content`, result.skills.some((r) => r.path === middleSkill && r.enabled), ).toBe(true); }); + + it("should include default workspace skills when cwd is outside the workspace root", async () => { + const workspaceSkill = join( + tempDir, + "workspace", + ".agents", + "skills", + "build-app", + "SKILL.md", + ); + mkdirSync(join(tempDir, "workspace", ".agents", "skills", "build-app"), { + recursive: true, + }); + writeFileSync( + workspaceSkill, + "---\nname: build-app\ndescription: Build apps\n---\n", + ); + + const appCwd = join(tempDir, "apps", "portfolio"); + mkdirSync(appCwd, { recursive: true }); + + const pm = new DefaultPackageManager({ + cwd: appCwd, + agentDir, + settingsManager, + }); + + const result = await pm.resolve(); + expect( + result.skills.some((r) => r.path === workspaceSkill && r.enabled), + ).toBe(true); + }); }); describe("ignore files", () => { diff --git a/packages/coding-agent/test/resource-loader.test.ts b/packages/coding-agent/test/resource-loader.test.ts index 6a2b07f..6b9253f 100644 --- a/packages/coding-agent/test/resource-loader.test.ts +++ b/packages/coding-agent/test/resource-loader.test.ts @@ -320,6 +320,28 @@ Content`, expect(agentsFiles.some((f) => f.path.endsWith("SOUL.md"))).toBe(true); }); + it("should discover companion context files from the default workspace", async () => { + const workspaceDir = join(tempDir, "workspace"); + const appDir = join(tempDir, "apps", "todo-app"); + mkdirSync(workspaceDir, { recursive: true }); + mkdirSync(appDir, { recursive: true }); + writeFileSync(join(workspaceDir, "IDENTITY.md"), "# Identity\n\nPi"); + writeFileSync(join(workspaceDir, "TOOLS.md"), "# Tools\n\nUse ~/.pi"); + writeFileSync(join(workspaceDir, "BOOTSTRAP.md"), "# Bootstrap\n\nDo it"); + + const loader = new DefaultResourceLoader({ cwd: appDir, agentDir }); + await loader.reload(); + + const { agentsFiles } = loader.getAgentsFiles(); + expect(agentsFiles.some((f) => f.path.endsWith("IDENTITY.md"))).toBe( + true, + ); + expect(agentsFiles.some((f) => f.path.endsWith("TOOLS.md"))).toBe(true); + expect(agentsFiles.some((f) => f.path.endsWith("BOOTSTRAP.md"))).toBe( + true, + ); + }); + it("should discover SYSTEM.md from cwd/.pi", async () => { const piDir = join(cwd, ".pi"); mkdirSync(piDir, { recursive: true }); diff --git a/packages/coding-agent/test/system-prompt.test.ts b/packages/coding-agent/test/system-prompt.test.ts index c4cbf72..e9013f9 100644 --- a/packages/coding-agent/test/system-prompt.test.ts +++ b/packages/coding-agent/test/system-prompt.test.ts @@ -100,5 +100,35 @@ describe("buildSystemPrompt", () => { ); expect(prompt).toContain("## /tmp/project/SOUL.md"); }); + + test("adds companion context guidance for identity, tools, and bootstrap files", () => { + const prompt = buildSystemPrompt({ + contextFiles: [ + { + path: "/home/node/.pi/workspace/IDENTITY.md", + content: "# Identity\n\nPi", + }, + { + path: "/home/node/.pi/workspace/TOOLS.md", + content: "# Tools\n\nUse ~/.pi/apps", + }, + { + path: "/home/node/.pi/workspace/BOOTSTRAP.md", + content: "# Bootstrap\n\nDo the setup", + }, + ], + skills: [], + }); + + expect(prompt).toContain( + "If IDENTITY.md is present, treat it as the agent's self-description", + ); + expect(prompt).toContain( + "If TOOLS.md is present, treat it as the source of truth for the current sandbox filesystem", + ); + expect(prompt).toContain( + "If BOOTSTRAP.md is present, treat it as an actionable onboarding task list", + ); + }); }); });