diff --git a/docs/explanation/package-and-template-model.md b/docs/explanation/package-and-template-model.md index 1f74bd12..929a9ef3 100644 --- a/docs/explanation/package-and-template-model.md +++ b/docs/explanation/package-and-template-model.md @@ -90,6 +90,21 @@ Managed by users/maintainers inside their repositories. `taskplane init` copies/generates these into project-local paths. +### Agent prompt inheritance + +Runtime V2 workers, reviewers, and mergers use the same agent-definition loader. +By default, an agent's bundled prompt is followed by the body of its project-local +Markdown file under `## Project-Specific Guidance`. Frontmatter is configuration +and is removed from the system prompt. + +Set `standalone: true` in a local agent file's frontmatter to replace the bundled +prompt with that file's body. The project then owns the complete prompt for that +role, including its execution or review instructions. + +Bundled templates are resolved relative to the running Taskplane package, so +this behavior also works with Pi's private npm installation directory. Runtime +model and tool configuration retains its existing precedence. + --- ## Upgrade path diff --git a/extensions/taskplane/agent-bridge-extension.ts b/extensions/taskplane/agent-bridge-extension.ts index cf3be030..9664b571 100644 --- a/extensions/taskplane/agent-bridge-extension.ts +++ b/extensions/taskplane/agent-bridge-extension.ts @@ -34,12 +34,21 @@ import { } from "fs"; import { join, dirname } from "path"; import { spawn as nodeSpawn } from "child_process"; -import { resolvePiCliPath, resolveTaskplaneAgentTemplate } from "./path-resolver.ts"; +import { resolvePiCliPath } from "./path-resolver.ts"; +import { loadAgentDef } from "./agent-definition.ts"; import { loadPiSettingsPackages, filterExcludedExtensions } from "./settings-loader.ts"; import { randomBytes } from "crypto"; import { buildExpansionRequestId, type SegmentExpansionRequest } from "./types.ts"; import { latestReviewFilesPerGate, parseReviewVerdict } from "./review-analysis.ts"; +/** Load the same prompt contract used by the Runtime V2 worker and merger. */ +export function loadReviewerPrompt(cwd = process.cwd()): string { + return ( + loadAgentDef(cwd, "task-reviewer")?.systemPrompt ?? + "You are a code reviewer. Read the request and write your review to the specified output file." + ); +} + /** * Resolve the outbox directory from environment variables. * @@ -533,45 +542,6 @@ export default function (pi: ExtensionAPI) { // The reviewer runs as a separate Pi process, writes feedback to // .reviews/, and this tool returns the verdict to the worker. - /** - * Load the reviewer system prompt from base template + local override. - * Uses resolveTaskplaneAgentTemplate (path-resolver.ts) for all platform support (TP-157). - */ - function loadReviewerPrompt(): string { - let basePrompt = - "You are a code reviewer. Read the request and write your review to the specified output file."; - try { - const templatePath = resolveTaskplaneAgentTemplate("task-reviewer"); - if (existsSync(templatePath)) { - const raw = readFileSync(templatePath, "utf-8"); - const fmEnd = raw.indexOf("---", 4); - if (fmEnd > 0) basePrompt = raw.slice(fmEnd + 3).trim(); - } - } catch { - /* fall through to default */ - } - // Local override - const localPaths = [ - join(process.cwd(), ".pi", "agents", "task-reviewer.md"), - join(process.cwd(), "agents", "task-reviewer.md"), - ]; - for (const p of localPaths) { - try { - if (!existsSync(p)) continue; - const raw = readFileSync(p, "utf-8"); - const fmEnd = raw.indexOf("---", 4); - if (fmEnd > 0) { - const localBody = raw.slice(fmEnd + 3).trim(); - if (localBody) basePrompt += "\n\n---\n\n## Project-Specific Guidance\n\n" + localBody; - } - break; - } catch { - continue; - } - } - return basePrompt; - } - function reviewerStatePath(taskFolder: string): string { return process.env.TASKPLANE_REVIEWER_STATE_PATH || join(taskFolder, ".reviewer-state.json"); } diff --git a/extensions/taskplane/agent-definition.ts b/extensions/taskplane/agent-definition.ts new file mode 100644 index 00000000..21d63006 --- /dev/null +++ b/extensions/taskplane/agent-definition.ts @@ -0,0 +1,161 @@ +/** Shared agent prompt loading for the orchestrator and agent-side bridge. */ +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { resolveTaskplaneAgentTemplate } from "./path-resolver.ts"; +import { resolvePointer, loadWorkspaceConfig } from "./workspace.ts"; + +/** + * Parse an agent .md file: extract frontmatter and body. + * Returns null if file doesn't exist or is malformed. + * @since TP-117 + */ +function parseAgentFile(filePath: string): { fm: Record; body: string } | null { + try { + if (!existsSync(filePath)) return null; + const raw = readFileSync(filePath, "utf-8"); + const fmEnd = raw.indexOf("---", 4); + if (fmEnd < 0) return { fm: {}, body: raw.trim() }; + const fmBlock = raw.slice(4, fmEnd).trim(); + const fm: Record = {}; + for (const line of fmBlock.split("\n")) { + const m = line.match(/^([\w-]+)\s*:\s*(.+)/); + if (m) fm[m[1]] = m[2].trim(); + } + return { fm, body: raw.slice(fmEnd + 3).trim() }; + } catch { + return null; + } +} + +/** + * Load the base agent prompt from the taskplane package's templates/ directory. + * Resolves the running package root, with npm global paths as fallbacks. + * @since TP-117 + */ +export function loadBaseAgentPrompt(agentName: string): string { + // resolveTaskplaneAgentTemplate handles all npm setups (nvm, Homebrew, volta, Windows, etc.) + // via npm root -g caching and well-known fallback paths (see path-resolver.ts, TP-157). + // This avoids silently returning "" which would cause the worker to skip reviews. + try { + const resolved = resolveTaskplaneAgentTemplate(agentName); + if (existsSync(resolved)) { + const def = parseAgentFile(resolved); + if (def?.body) return def.body; + } + } catch { + /* fall through */ + } + return ""; +} + +// ── Agent Definition Loading ───────────────────────────────────────── + +/** Track whether an agent pointer warning has been logged this session (log once). */ +let _execPointerWarningLogged = false; + +/** + * Reset agent pointer warning state for testing. + * @since TP-161 + */ +export function resetPointerWarning(): void { + _execPointerWarningLogged = false; +} + +/** + * Resolve agent files using the workspace pointer (workspace mode only). + * Returns the agentRoot from the pointer, or null in repo mode / on failure. + */ +function resolveAgentPointerRoot(): string | null { + const wsRoot = process.env.TASKPLANE_WORKSPACE_ROOT; + if (!wsRoot) return null; + try { + const wsConfig = loadWorkspaceConfig(wsRoot); + const result = resolvePointer(wsRoot, wsConfig); + if (result?.warning && !_execPointerWarningLogged) { + _execPointerWarningLogged = true; + console.error(`[execution] pointer: ${result.warning}`); + } + return result?.agentRoot ?? null; + } catch { + return null; + } +} + +/** + * Load a complete agent definition (systemPrompt + tools + model) by name. + * + * Resolution order: + * 0. preferredAgentRoot/.md (when supplied) + * 1. cwd/.pi/agents/.md + * 2. cwd/agents/.md + * 3. pointer.agentRoot/.md (workspace mode only) + * 4. Base package templates/agents/.md + * + * If a local file has `standalone: true` in frontmatter, it is used as-is + * (no base composition). Otherwise, base + local are composed. + * + * @param cwd - Working directory (project root) to search for local agent files + * @param name - Agent name (e.g., "task-worker", "task-reviewer") + * @param preferredAgentRoot - Explicit agent directory to search before project paths + * @returns Composed agent definition, or null if no base and no local file found + * @since TP-161 + */ +export function loadAgentDef( + cwd: string, + name: string, + preferredAgentRoot?: string, +): { systemPrompt: string; tools: string; model: string } | null { + const localPaths = [join(cwd, ".pi", "agents", `${name}.md`), join(cwd, "agents", `${name}.md`)]; + + // An explicitly resolved agent root retains precedence for merge agents. + if (preferredAgentRoot) localPaths.unshift(join(preferredAgentRoot, `${name}.md`)); + + // In workspace mode, add pointer-resolved agent root as fallback + const agentRoot = resolveAgentPointerRoot(); + if (agentRoot) { + localPaths.push(join(agentRoot, `${name}.md`)); + } + + // Load base from package + let baseDef: { fm: Record; body: string } | null = null; + try { + const basePath = resolveTaskplaneAgentTemplate(name); + if (existsSync(basePath)) { + baseDef = parseAgentFile(basePath); + } + } catch { + /* fall through */ + } + + // Load local override (first found wins) + let localDef: { fm: Record; body: string } | null = null; + for (const p of localPaths) { + localDef = parseAgentFile(p); + if (localDef) break; + } + + // No base and no local → null + if (!baseDef && !localDef) return null; + + // Local with standalone: true → use local as-is, ignore base + if (localDef?.fm.standalone === "true") { + return { + systemPrompt: localDef.body, + tools: localDef.fm.tools || "read,grep,find,ls", + model: localDef.fm.model || "", + }; + } + + // Compose base + local + const basePrompt = baseDef?.body || ""; + const localPrompt = localDef?.body || ""; + const composedPrompt = localPrompt + ? basePrompt + "\n\n---\n\n## Project-Specific Guidance\n\n" + localPrompt + : basePrompt; + + // Local frontmatter overrides base (tools, model) + const tools = localDef?.fm.tools || baseDef?.fm.tools || "read,grep,find,ls"; + const model = localDef?.fm.model || baseDef?.fm.model || ""; + + return { systemPrompt: composedPrompt.trim(), tools, model }; +} diff --git a/extensions/taskplane/execution.ts b/extensions/taskplane/execution.ts index 1065456b..268fba07 100644 --- a/extensions/taskplane/execution.ts +++ b/extensions/taskplane/execution.ts @@ -61,8 +61,9 @@ import { import { allocateLanes } from "./waves.ts"; import { resolveOperatorId } from "./naming.ts"; import { runGit, runGitWithEnv } from "./git.ts"; -import { resolveTaskplanePackageFile, resolveTaskplaneAgentTemplate } from "./path-resolver.ts"; -import { resolvePointer, loadWorkspaceConfig } from "./workspace.ts"; +import { resolveTaskplanePackageFile } from "./path-resolver.ts"; +import { loadAgentDef, loadBaseAgentPrompt } from "./agent-definition.ts"; +export { loadAgentDef, resetPointerWarning } from "./agent-definition.ts"; // ── Taskplane Package File Resolution ──────────────────────────────── // getNpmGlobalRoot() and resolveTaskplanePackageFile() consolidated in path-resolver.ts (TP-157) @@ -2645,178 +2646,6 @@ export function buildAgentIdFromLane( * * @since TP-102 */ -/** - * Parse an agent .md file: extract frontmatter and body. - * Returns null if file doesn't exist or is malformed. - * @since TP-117 - */ -function parseAgentFile(filePath: string): { fm: Record; body: string } | null { - try { - if (!existsSync(filePath)) return null; - const raw = readFileSync(filePath, "utf-8"); - const fmEnd = raw.indexOf("---", 4); - if (fmEnd < 0) return { fm: {}, body: raw.trim() }; - const fmBlock = raw.slice(4, fmEnd).trim(); - const fm: Record = {}; - for (const line of fmBlock.split("\n")) { - const m = line.match(/^([\w-]+)\s*:\s*(.+)/); - if (m) fm[m[1]] = m[2].trim(); - } - return { fm, body: raw.slice(fmEnd + 3).trim() }; - } catch { - return null; - } -} - -/** - * Load the base agent prompt from the taskplane package's templates/ directory. - * Resolves the package root via well-known npm global paths. - * @since TP-117 - */ -function loadBaseAgentPrompt(agentName: string): string { - // resolveTaskplaneAgentTemplate handles all npm setups (nvm, Homebrew, volta, Windows, etc.) - // via npm root -g caching and well-known fallback paths (see path-resolver.ts, TP-157). - // This avoids silently returning "" which would cause the worker to skip reviews. - try { - const resolved = resolveTaskplaneAgentTemplate(agentName); - if (existsSync(resolved)) { - const def = parseAgentFile(resolved); - if (def?.body) return def.body; - } - } catch { - /* fall through */ - } - return ""; -} - -/** - * Load local project agent prompt from .pi/agents/ or agents/ directory. - * Supports standalone mode (local replaces base entirely). - * @since TP-117 - */ -function loadLocalAgentPrompt(stateRoot: string, agentName: string): string { - const paths = [ - join(stateRoot, ".pi", "agents", `${agentName}.md`), - join(stateRoot, "agents", `${agentName}.md`), - ]; - for (const p of paths) { - const def = parseAgentFile(p); - if (def) { - // standalone: true → use local as-is (body only, replaces base) - if (def.fm.standalone === "true") return def.body; - // Otherwise return body as project-specific guidance to append - if (def.body) return def.body; - } - } - return ""; -} - -// ── Agent Definition Loading ───────────────────────────────────────── - -/** Track whether an agent pointer warning has been logged this session (log once). */ -let _execPointerWarningLogged = false; - -/** - * Reset agent pointer warning state for testing. - * @since TP-161 - */ -export function resetPointerWarning(): void { - _execPointerWarningLogged = false; -} - -/** - * Resolve agent files using the workspace pointer (workspace mode only). - * Returns the agentRoot from the pointer, or null in repo mode / on failure. - */ -function resolveAgentPointerRoot(): string | null { - const wsRoot = process.env.TASKPLANE_WORKSPACE_ROOT; - if (!wsRoot) return null; - try { - const wsConfig = loadWorkspaceConfig(wsRoot); - const result = resolvePointer(wsRoot, wsConfig); - if (result?.warning && !_execPointerWarningLogged) { - _execPointerWarningLogged = true; - console.error(`[execution] pointer: ${result.warning}`); - } - return result?.agentRoot ?? null; - } catch { - return null; - } -} - -/** - * Load a complete agent definition (systemPrompt + tools + model) by name. - * - * Resolution order: - * 1. cwd/.pi/agents/.md - * 2. cwd/agents/.md - * 3. pointer.agentRoot/.md (workspace mode only) - * 4. Base package templates/agents/.md - * - * If a local file has `standalone: true` in frontmatter, it is used as-is - * (no base composition). Otherwise, base + local are composed. - * - * @param cwd - Working directory (project root) to search for local agent files - * @param name - Agent name (e.g., "task-worker", "task-reviewer") - * @returns Composed agent definition, or null if no base and no local file found - * @since TP-161 - */ -export function loadAgentDef( - cwd: string, - name: string, -): { systemPrompt: string; tools: string; model: string } | null { - const localPaths = [join(cwd, ".pi", "agents", `${name}.md`), join(cwd, "agents", `${name}.md`)]; - - // In workspace mode, add pointer-resolved agent root as fallback - const agentRoot = resolveAgentPointerRoot(); - if (agentRoot) { - localPaths.push(join(agentRoot, `${name}.md`)); - } - - // Load base from package - let baseDef: { fm: Record; body: string } | null = null; - try { - const basePath = resolveTaskplaneAgentTemplate(name); - if (existsSync(basePath)) { - baseDef = parseAgentFile(basePath); - } - } catch { - /* fall through */ - } - - // Load local override (first found wins) - let localDef: { fm: Record; body: string } | null = null; - for (const p of localPaths) { - localDef = parseAgentFile(p); - if (localDef) break; - } - - // No base and no local → null - if (!baseDef && !localDef) return null; - - // Local with standalone: true → use local as-is, ignore base - if (localDef?.fm.standalone === "true") { - return { - systemPrompt: localDef.body, - tools: localDef.fm.tools || "read,grep,find,ls", - model: localDef.fm.model || "", - }; - } - - // Compose base + local - const basePrompt = baseDef?.body || ""; - const localPrompt = localDef?.body || ""; - const composedPrompt = localPrompt - ? basePrompt + "\n\n---\n\n## Project-Specific Guidance\n\n" + localPrompt - : basePrompt; - - // Local frontmatter overrides base (tools, model) - const tools = localDef?.fm.tools || baseDef?.fm.tools || "read,grep,find,ls"; - const model = localDef?.fm.model || baseDef?.fm.model || ""; - - return { systemPrompt: composedPrompt.trim(), tools, model }; -} - export function resolveRuntimeStateRoot(repoRoot: string, workspaceRoot?: string): string { return workspaceRoot ?? repoRoot; } @@ -3004,15 +2833,7 @@ export async function executeLaneV2( "You are a task execution agent. Read STATUS.md first, find unchecked items, work on them, checkpoint after each."; let workerSegmentPrompt = ""; try { - const basePrompt = loadBaseAgentPrompt("task-worker"); - const localPrompt = loadLocalAgentPrompt(stateRoot, "task-worker"); - if (basePrompt && localPrompt) { - workerSystemPrompt = basePrompt + "\n\n---\n\n## Project-Specific Guidance\n\n" + localPrompt; - } else if (basePrompt) { - workerSystemPrompt = basePrompt; - } else if (localPrompt) { - workerSystemPrompt = localPrompt; - } + workerSystemPrompt = loadAgentDef(stateRoot, "task-worker")?.systemPrompt ?? workerSystemPrompt; // Load segment-scoped prompt overlay (appended when isSegmentScoped) const segPrompt = loadBaseAgentPrompt("task-worker-segment"); if (segPrompt) workerSegmentPrompt = segPrompt; diff --git a/extensions/taskplane/merge.ts b/extensions/taskplane/merge.ts index aa88022a..18765c9c 100644 --- a/extensions/taskplane/merge.ts +++ b/extensions/taskplane/merge.ts @@ -18,6 +18,7 @@ import { execSync, spawnSync } from "child_process"; import { join, dirname, resolve, relative } from "path"; import { execLog, isV2AgentAlive, setV2LivenessRegistryCache } from "./execution.ts"; +import { loadAgentDef } from "./agent-definition.ts"; import { resolveOperatorId } from "./naming.ts"; import { MERGE_POLL_INTERVAL_MS, @@ -784,20 +785,8 @@ export async function spawnMergeAgentV2( // Read the merge request as the agent prompt const prompt = readFileSync(mergeRequestPath, "utf-8"); - // Resolve merger system prompt - const systemPromptCandidates = [ - agentRoot ? join(agentRoot, "task-merger.md") : "", - join(stateRoot ?? repoRoot, ".pi", "agents", "task-merger.md"), - ].filter(Boolean); - const systemPromptPath = systemPromptCandidates.find((p) => existsSync(p)) || ""; - let systemPrompt: string | undefined; - if (systemPromptPath) { - try { - systemPrompt = readFileSync(systemPromptPath, "utf-8"); - } catch { - /* use default */ - } - } + // Honor task-merger.md inheritance while preserving explicit agentRoot precedence. + const systemPrompt = loadAgentDef(stateRoot ?? repoRoot, "task-merger", agentRoot)?.systemPrompt; // Resolve event/exit paths const sidecarRoot = join(stateRoot ?? repoRoot, ".pi"); diff --git a/extensions/taskplane/path-resolver.ts b/extensions/taskplane/path-resolver.ts index 78f06064..ce4cd5f3 100644 --- a/extensions/taskplane/path-resolver.ts +++ b/extensions/taskplane/path-resolver.ts @@ -46,6 +46,7 @@ import { spawnSync } from "child_process"; import { existsSync } from "fs"; import { join, resolve } from "path"; +import { fileURLToPath } from "url"; // ── Module-level cache ────────────────────────────────────────────── @@ -217,17 +218,18 @@ export function resolvePiCliPath(): string { * Resolve the path to a file within the taskplane npm package. * * This handles both local development (running from the taskplane repo itself) - * and the installed-package case (taskplane installed globally via npm). + * and installed packages, including Pi-managed private npm directories. * * Resolution order: * 1. `join(repoRoot, relPath)` — local development (taskplane's own repo) - * 2. `npm root -g` result: `{npmGlobalRoot}/taskplane/{relPath}` (dynamic, all setups) - * 3. `{APPDATA}/npm/node_modules/taskplane/{relPath}` (Windows) - * 4. `{HOME}/AppData/Roaming/npm/node_modules/taskplane/{relPath}` (Windows alt) - * 5. `{HOME}/.npm-global/lib/node_modules/taskplane/{relPath}` (macOS/Linux custom prefix) - * 6. `/usr/local/lib/node_modules/taskplane/{relPath}` (macOS system Node, Linux) - * 7. `/opt/homebrew/lib/node_modules/taskplane/{relPath}` (macOS Homebrew) - * 8. Peer of pi's package (adjacent to `process.argv[1]`) + * 2. This module's package root (Pi-managed, project-local, or global install) + * 3. `npm root -g` result: `{npmGlobalRoot}/taskplane/{relPath}` (dynamic, all setups) + * 4. `{APPDATA}/npm/node_modules/taskplane/{relPath}` (Windows) + * 5. `{HOME}/AppData/Roaming/npm/node_modules/taskplane/{relPath}` (Windows alt) + * 6. `{HOME}/.npm-global/lib/node_modules/taskplane/{relPath}` (macOS/Linux custom prefix) + * 7. `/usr/local/lib/node_modules/taskplane/{relPath}` (macOS system Node, Linux) + * 8. `/opt/homebrew/lib/node_modules/taskplane/{relPath}` (macOS Homebrew) + * 9. Peer of pi's package (adjacent to `process.argv[1]`) * * @param repoRoot - Absolute path to the project root (used for local dev check) * @param relPath - Relative path within the taskplane package, e.g. @@ -241,15 +243,20 @@ export function resolveTaskplanePackageFile(repoRoot: string, relPath: string): const localPath = join(resolve(repoRoot), relPath); if (existsSync(localPath)) return localPath; + // Resolve against the running package before consulting other installations. + // Pi installs packages in its private npm directory, outside `npm root -g`. + const packagePath = join(fileURLToPath(new URL("../../", import.meta.url)), relPath); + if (existsSync(packagePath)) return packagePath; + const candidates: string[] = []; - // 2. Dynamic: npm root -g (covers ALL npm setups: nvm, Homebrew, volta, etc.) + // 3. Dynamic: npm root -g (covers ALL npm setups: nvm, Homebrew, volta, etc.) const npmRoot = getNpmGlobalRoot(); if (npmRoot) { candidates.push(join(npmRoot, "taskplane", relPath)); } - // 3-7. Well-known static paths + // 4-8. Well-known static paths const home = process.env.HOME || process.env.USERPROFILE || ""; if (process.env.APPDATA) { candidates.push(join(process.env.APPDATA, "npm", "node_modules", "taskplane", relPath)); @@ -261,7 +268,7 @@ export function resolveTaskplanePackageFile(repoRoot: string, relPath: string): candidates.push(join("/usr", "local", "lib", "node_modules", "taskplane", relPath)); candidates.push(join("/opt", "homebrew", "lib", "node_modules", "taskplane", relPath)); - // 8. Peer of pi's package (look adjacent to pi's CLI entrypoint). + // 9. Peer of pi's package (look adjacent to pi's CLI entrypoint). // pi is at: //pi-coding-agent/dist/cli.js (where is // @earendil-works (current) or @mariozechner (legacy)). // so piPkgDir = //pi-coding-agent (resolve up 2 levels from cli.js). diff --git a/extensions/tests/path-resolver-package-root.test.ts b/extensions/tests/path-resolver-package-root.test.ts new file mode 100644 index 00000000..01f211ae --- /dev/null +++ b/extensions/tests/path-resolver-package-root.test.ts @@ -0,0 +1,36 @@ +import { it } from "node:test"; +import { strict as assert } from "node:assert"; +import { stripTypeScriptTypes } from "node:module"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { pathToFileURL } from "node:url"; + +it("resolves templates from the running Pi-managed package outside the project's cwd", async () => { + const root = mkdtempSync(join(tmpdir(), "tp-private-package-")); + try { + const installedRoot = join(root, ".pi", "agent", "npm", "node_modules", "taskplane"); + const resolverPath = join(installedRoot, "extensions", "taskplane", "path-resolver.mjs"); + mkdirSync(dirname(resolverPath), { recursive: true }); + // Pi transpiles installed TS modules. Strip types here so Node can load + // the fixture under node_modules while retaining its real import.meta.url. + const source = readFileSync(new URL("../taskplane/path-resolver.ts", import.meta.url), "utf8"); + writeFileSync(resolverPath, stripTypeScriptTypes(source)); + const relPath = join("templates", "agents", "task-merger.md"); + const templatePath = join(installedRoot, relPath); + mkdirSync(dirname(templatePath), { recursive: true }); + writeFileSync(templatePath, "Installed merger prompt."); + const projectRoot = join(root, "consumer-project"); + mkdirSync(projectRoot); + const { resolveTaskplanePackageFile } = await import(pathToFileURL(resolverPath).href); + assert.equal(resolveTaskplanePackageFile(projectRoot, relPath), templatePath); + + // Preserve the local-development override when the repo supplies a file. + const localTemplate = join(projectRoot, relPath); + mkdirSync(dirname(localTemplate), { recursive: true }); + writeFileSync(localTemplate, "Local development prompt."); + assert.equal(resolveTaskplanePackageFile(projectRoot, relPath), localTemplate); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/extensions/tests/runtime-agent-prompts.test.ts b/extensions/tests/runtime-agent-prompts.test.ts new file mode 100644 index 00000000..e08344ca --- /dev/null +++ b/extensions/tests/runtime-agent-prompts.test.ts @@ -0,0 +1,169 @@ +import { afterEach, beforeEach, describe, it, mock } from "node:test"; +import { strict as assert } from "node:assert"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import type { AgentHostOptions } from "../taskplane/agent-host.ts"; +import type { LaneRunnerConfig } from "../taskplane/lane-runner.ts"; +import type { AllocatedLane } from "../taskplane/types.ts"; + +const packageRoot = join(dirname(fileURLToPath(import.meta.url)), "..", ".."); +const originalCwd = process.cwd(); +const captured = new Error("prompt captured before spawning"); +let workerConfig: LaneRunnerConfig | undefined; +let mergerOptions: AgentHostOptions | undefined; + +const agentHost = await import("../taskplane/agent-host.ts"); +mock.module("../taskplane/agent-host.ts", { + namedExports: { + ...agentHost, + spawnAgent: (options: AgentHostOptions) => { + mergerOptions = options; + throw captured; + }, + }, +}); +const laneRunner = await import("../taskplane/lane-runner.ts"); +mock.module("../taskplane/lane-runner.ts", { + namedExports: { + ...laneRunner, + executeTaskV2: async (_unit: unknown, config: LaneRunnerConfig) => { + workerConfig = config; + throw captured; + }, + }, +}); +const { executeLaneV2 } = await import("../taskplane/execution.ts"); +const { spawnMergeAgentV2 } = await import("../taskplane/merge.ts"); +const { loadReviewerPrompt } = await import("../taskplane/agent-bridge-extension.ts"); +const { DEFAULT_ORCHESTRATOR_CONFIG } = await import("../taskplane/types.ts"); + +describe("Runtime V2 agent prompt inheritance (#619)", () => { + let projectRoot: string; + + beforeEach(() => { + projectRoot = mkdtempSync(join(tmpdir(), "tp-agent-prompts-")); + // Make the stock templates discoverable even before the package-root fix. + process.chdir(packageRoot); + workerConfig = undefined; + mergerOptions = undefined; + }); + afterEach(() => { + process.chdir(originalCwd); + rmSync(projectRoot, { recursive: true, force: true }); + }); + + function writeAgent( + name: string, + standalone: boolean, + agentRoot = join(projectRoot, ".pi", "agents"), + ) { + mkdirSync(agentRoot, { recursive: true }); + writeFileSync( + join(agentRoot, `${name}.md`), + `---\nname: ${name}\nstandalone: ${standalone}\nmodel: ignored-model\ntools: ignored-tools\n---\nLocal ${name} guidance.\n`, + ); + } + + function expectedPrompt(name: string, standalone: boolean) { + const local = `Local ${name} guidance.`; + if (standalone) return local; + const raw = readFileSync(join(packageRoot, "templates", "agents", `${name}.md`), "utf8"); + const base = raw.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n/, "").trim(); + return `${base}\n\n---\n\n## Project-Specific Guidance\n\n${local}`; + } + + for (const standalone of [true, false]) { + it(`loads the bridge reviewer prompt with standalone=${standalone}`, () => { + writeAgent("task-reviewer", standalone); + assert.equal(loadReviewerPrompt(projectRoot), expectedPrompt("task-reviewer", standalone)); + }); + + it(`passes the worker prompt to the lane runner with standalone=${standalone}`, async () => { + writeAgent("task-worker", standalone); + const taskFolder = join(projectRoot, "tasks", "TEST-001"); + mkdirSync(taskFolder, { recursive: true }); + writeFileSync(join(taskFolder, "PROMPT.md"), "# Test\n\n### Step 1: Test\n- [ ] Test\n"); + const lane = { + laneNumber: 1, + laneId: "lane-1", + laneSessionId: "orch-test-lane-1", + worktreePath: projectRoot, + branch: "task/test", + repoId: "default", + tasks: [ + { + taskId: "TEST-001", + order: 0, + task: { + taskId: "TEST-001", + taskName: "Test", + taskFolder, + promptPath: join(taskFolder, "PROMPT.md"), + dependencies: [], + fileScope: [], + status: "pending", + reviewLevel: 0, + }, + }, + ], + } as unknown as AllocatedLane; + await executeLaneV2( + lane, + DEFAULT_ORCHESTRATOR_CONFIG, + projectRoot, + { paused: false }, + undefined, + false, + { + TASKPLANE_WORKER_MODEL: "configured-model", + TASKPLANE_WORKER_TOOLS: "read,bash", + }, + ); + assert.ok(workerConfig, "the real lane path must reach executeTaskV2"); + assert.equal(workerConfig.workerSystemPrompt, expectedPrompt("task-worker", standalone)); + assert.equal(workerConfig.workerModel, "configured-model"); + assert.equal(workerConfig.workerTools, "read,bash"); + assert.ok(workerConfig.workerSegmentPrompt?.length); + }); + + it(`passes the merger prompt to agent-host with standalone=${standalone}`, async () => { + writeAgent("task-merger", standalone); + const requestPath = join(projectRoot, "request.md"); + writeFileSync(requestPath, "Merge the branch."); + const config = structuredClone(DEFAULT_ORCHESTRATOR_CONFIG); + config.merge.model = "configured-model"; + config.merge.tools = "read,bash"; + await assert.rejects( + spawnMergeAgentV2("orch-test-merge-1", projectRoot, projectRoot, requestPath, config), + (err) => err === captured, + ); + assert.ok(mergerOptions, "the real merge path must reach spawnAgent"); + assert.equal(mergerOptions.systemPrompt, expectedPrompt("task-merger", standalone)); + assert.equal(mergerOptions.model, "configured-model"); + assert.equal(mergerOptions.tools, "read,bash"); + }); + } + + it("uses an explicit merger agent root before project-local guidance", async () => { + writeAgent("task-merger", false); + const agentRoot = join(projectRoot, "shared-agents"); + writeAgent("task-merger", true, agentRoot); + const requestPath = join(projectRoot, "request.md"); + writeFileSync(requestPath, "Merge the branch."); + await assert.rejects( + spawnMergeAgentV2( + "orch-test-merge-1", + projectRoot, + projectRoot, + requestPath, + DEFAULT_ORCHESTRATOR_CONFIG, + undefined, + agentRoot, + ), + (err) => err === captured, + ); + assert.equal(mergerOptions?.systemPrompt, "Local task-merger guidance."); + }); +});