Skip to content

Commit 59cd75e

Browse files
authored
feat: agent prompt inheritance — base prompts auto-update with package (#49)
Implements the 'inheritance by default' model from issue #18: - Base agent prompts (task-worker, task-reviewer, task-merger) ship in the package at templates/agents/ and auto-update on pi update - Local .pi/agents/ files are thin project-specific overrides - loadAgentDef() composes: base prompt + separator + local content - Local frontmatter (tools, model) overrides base values - standalone: true in frontmatter opts out of inheritance entirely - taskplane init now scaffolds thin local files with guidance comments - Existing full-copy agent files continue to work (treated as standalone) Closes #18
1 parent 9906c3d commit 59cd75e

5 files changed

Lines changed: 222 additions & 15 deletions

File tree

bin/taskplane.mjs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -645,10 +645,11 @@ async function cmdInit(args) {
645645
console.log(`\n${c.bold}Creating files...${c.reset}\n`);
646646
const skipIfExists = !force;
647647

648-
// Agent prompts
648+
// Agent prompts — copy thin local files (base prompts ship in the package
649+
// and are composed automatically by the task-runner at runtime)
649650
for (const agent of ["task-worker.md", "task-reviewer.md", "task-merger.md"]) {
650651
copyTemplate(
651-
path.join(TEMPLATES_DIR, "agents", agent),
652+
path.join(TEMPLATES_DIR, "agents", "local", agent),
652653
path.join(projectRoot, ".pi", "agents", agent),
653654
{ skipIfExists, label: `.pi/agents/${agent}` }
654655
);

extensions/task-runner.ts

Lines changed: 133 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -339,21 +339,141 @@ function clearConversationLog(prefix: string): void {
339339

340340
// ── Agent Loader ─────────────────────────────────────────────────────
341341

342+
/**
343+
* Parse a markdown agent file into frontmatter key-value pairs and body content.
344+
* Returns null if the file doesn't exist or has no frontmatter block.
345+
*/
346+
function parseAgentFile(filePath: string): { fm: Record<string, string>; body: string } | null {
347+
if (!existsSync(filePath)) return null;
348+
const raw = readFileSync(filePath, "utf-8").replace(/\r\n/g, "\n");
349+
const match = raw.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
350+
if (!match) return null;
351+
const fm: Record<string, string> = {};
352+
for (const line of match[1].split("\n")) {
353+
const idx = line.indexOf(":");
354+
if (idx > 0) fm[line.slice(0, idx).trim()] = line.slice(idx + 1).trim();
355+
}
356+
return { fm, body: match[2].trim() };
357+
}
358+
359+
/** Cached package root — resolved once, reused for all agent file lookups. */
360+
let _packageRoot: string | null = null;
361+
362+
/**
363+
* Find the taskplane package root directory.
364+
*
365+
* Strategy: this file lives at <package-root>/extensions/task-runner.ts.
366+
* When pi loads it via `-e`, it resolves the full path. We can find the
367+
* package root by searching for package.json with name "taskplane"
368+
* starting from known candidate locations.
369+
*/
370+
function findPackageRoot(): string {
371+
if (_packageRoot !== null) return _packageRoot;
372+
373+
// Strategy 1: Walk up from this file's location via require.resolve or npm paths
374+
const candidates: string[] = [];
375+
376+
// The extension is loaded by pi from the installed package location.
377+
// Check well-known npm global paths.
378+
const home = process.env.HOME || process.env.USERPROFILE || "";
379+
if (home) {
380+
candidates.push(join(home, "AppData", "Roaming", "npm", "node_modules", "taskplane"));
381+
candidates.push(join(home, ".npm-global", "lib", "node_modules", "taskplane"));
382+
}
383+
candidates.push(join("/usr", "local", "lib", "node_modules", "taskplane"));
384+
385+
// Strategy 2: resolve from pi's node_modules peer
386+
try {
387+
const piPath = process.argv[1] || "";
388+
const piPkgDir = resolve(piPath, "..", "..");
389+
candidates.push(join(piPkgDir, "..", "taskplane"));
390+
} catch { /* ignore */ }
391+
392+
// Strategy 3: Check TASKPLANE_WORKSPACE_ROOT project-local install
393+
const wsRoot = process.env.TASKPLANE_WORKSPACE_ROOT;
394+
if (wsRoot) {
395+
candidates.push(join(wsRoot, ".pi", "npm", "node_modules", "taskplane"));
396+
candidates.push(join(wsRoot, "node_modules", "taskplane"));
397+
}
398+
399+
for (const dir of candidates) {
400+
try {
401+
const pkgPath = join(dir, "package.json");
402+
if (existsSync(pkgPath)) {
403+
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
404+
if (pkg.name === "taskplane") {
405+
_packageRoot = dir;
406+
return dir;
407+
}
408+
}
409+
} catch { /* ignore */ }
410+
}
411+
412+
_packageRoot = "";
413+
return "";
414+
}
415+
416+
/**
417+
* Resolve the package-shipped base agent file path.
418+
* Base files live in the package's templates/agents/ directory.
419+
*/
420+
function resolveBaseAgentPath(name: string): string {
421+
const root = findPackageRoot();
422+
if (!root) return "";
423+
return join(root, "templates", "agents", `${name}.md`);
424+
}
425+
426+
/**
427+
* Load an agent definition with prompt inheritance.
428+
*
429+
* Inheritance model (default: compose base + local):
430+
* 1. Load base agent from the shipped package (templates/agents/{name}.md)
431+
* 2. Load local agent from .pi/agents/{name}.md (if it exists)
432+
* 3. If local file has `standalone: true` in frontmatter, use it as-is (no base)
433+
* 4. Otherwise, compose: base prompt + separator + local content
434+
* 5. Local frontmatter values (tools, model) override base values
435+
*
436+
* If no local file exists, the base file is used directly.
437+
* If no base file exists (e.g., custom agent), local file is used as-is.
438+
*/
342439
function loadAgentDef(cwd: string, name: string): { systemPrompt: string; tools: string; model: string } | null {
343-
const paths = [join(cwd, ".pi", "agents", `${name}.md`), join(cwd, "agents", `${name}.md`)];
344-
for (const p of paths) {
345-
if (!existsSync(p)) continue;
346-
const raw = readFileSync(p, "utf-8").replace(/\r\n/g, "\n");
347-
const match = raw.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
348-
if (!match) continue;
349-
const fm: Record<string, string> = {};
350-
for (const line of match[1].split("\n")) {
351-
const idx = line.indexOf(":");
352-
if (idx > 0) fm[line.slice(0, idx).trim()] = line.slice(idx + 1).trim();
353-
}
354-
return { systemPrompt: match[2].trim(), tools: fm.tools || "read,grep,find,ls", model: fm.model || "" };
440+
const basePath = resolveBaseAgentPath(name);
441+
const localPaths = [join(cwd, ".pi", "agents", `${name}.md`), join(cwd, "agents", `${name}.md`)];
442+
443+
// Load base from package
444+
const baseDef = parseAgentFile(basePath);
445+
446+
// Load local override (first found wins)
447+
let localDef: { fm: Record<string, string>; body: string } | null = null;
448+
for (const p of localPaths) {
449+
localDef = parseAgentFile(p);
450+
if (localDef) break;
451+
}
452+
453+
// No base and no local → null
454+
if (!baseDef && !localDef) return null;
455+
456+
// Local with standalone: true → use local as-is, ignore base
457+
if (localDef?.fm.standalone === "true") {
458+
return {
459+
systemPrompt: localDef.body,
460+
tools: localDef.fm.tools || "read,grep,find,ls",
461+
model: localDef.fm.model || "",
462+
};
355463
}
356-
return null;
464+
465+
// Compose base + local
466+
const basePrompt = baseDef?.body || "";
467+
const localPrompt = localDef?.body || "";
468+
const composedPrompt = localPrompt
469+
? basePrompt + "\n\n---\n\n## Project-Specific Guidance\n\n" + localPrompt
470+
: basePrompt;
471+
472+
// Local frontmatter overrides base (tools, model)
473+
const tools = localDef?.fm.tools || baseDef?.fm.tools || "read,grep,find,ls";
474+
const model = localDef?.fm.model || baseDef?.fm.model || "";
475+
476+
return { systemPrompt: composedPrompt.trim(), tools, model };
357477
}
358478

359479
// ── PROMPT.md Parser ─────────────────────────────────────────────────
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
---
2+
name: task-merger
3+
# tools: read,write,edit,bash,grep,find,ls
4+
# model:
5+
# standalone: true
6+
---
7+
8+
<!-- ═══════════════════════════════════════════════════════════════════
9+
Project-Specific Merger Guidance
10+
11+
This file is COMPOSED with the base task-merger prompt shipped in the
12+
taskplane package. Your content here is appended after the base prompt.
13+
14+
The base prompt (maintained by taskplane) handles:
15+
- Branch merge workflow (fast-forward, 3-way, conflict resolution)
16+
- Post-merge verification command execution
17+
- Result file JSON format and writing conventions
18+
19+
Add project-specific merge rules below. Common examples:
20+
- Post-merge verification commands (build, lint, test)
21+
- Conflict resolution preferences
22+
- Protected files that should never be auto-merged
23+
24+
To override frontmatter values (tools, model), uncomment and edit above.
25+
To use this file as a FULLY STANDALONE prompt (ignoring the base),
26+
uncomment `standalone: true` above and write the complete prompt below.
27+
═══════════════════════════════════════════════════════════════════ -->
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
---
2+
name: task-reviewer
3+
# tools: read,write,bash,grep,find,ls
4+
# model: openai/gpt-5.3-codex
5+
# standalone: true
6+
---
7+
8+
<!-- ═══════════════════════════════════════════════════════════════════
9+
Project-Specific Reviewer Guidance
10+
11+
This file is COMPOSED with the base task-reviewer prompt shipped in the
12+
taskplane package. Your content here is appended after the base prompt.
13+
14+
The base prompt (maintained by taskplane) handles:
15+
- Plan review and code review workflows
16+
- Verdict format (APPROVE / REVISE)
17+
- Review file output conventions
18+
- Plan granularity guidance
19+
20+
Add project-specific review criteria below. Common examples:
21+
- Required test coverage thresholds
22+
- Security review checklist items
23+
- Architecture constraints to enforce
24+
- Performance requirements
25+
26+
To override frontmatter values (tools, model), uncomment and edit above.
27+
To use this file as a FULLY STANDALONE prompt (ignoring the base),
28+
uncomment `standalone: true` above and write the complete prompt below.
29+
═══════════════════════════════════════════════════════════════════ -->
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
---
2+
name: task-worker
3+
# tools: read,write,edit,bash,grep,find,ls
4+
# model: anthropic/claude-sonnet-4-20250514
5+
# standalone: true
6+
---
7+
8+
<!-- ═══════════════════════════════════════════════════════════════════
9+
Project-Specific Worker Guidance
10+
11+
This file is COMPOSED with the base task-worker prompt shipped in the
12+
taskplane package. Your content here is appended after the base prompt.
13+
14+
The base prompt (maintained by taskplane) handles:
15+
- STATUS.md-first workflow and checkpoint discipline
16+
- Fresh-context loop behavior and iteration rules
17+
- Git commit conventions and .DONE file creation
18+
- Review response handling
19+
20+
Add project-specific rules below. Common examples:
21+
- Preferred package manager (pnpm, yarn, bun)
22+
- Test commands (make test, npm run test:unit)
23+
- Coding standards (linting, formatting)
24+
- Framework-specific patterns
25+
- Environment or deployment constraints
26+
27+
To override frontmatter values (tools, model), uncomment and edit above.
28+
To use this file as a FULLY STANDALONE prompt (ignoring the base),
29+
uncomment `standalone: true` above and write the complete prompt below.
30+
═══════════════════════════════════════════════════════════════════ -->

0 commit comments

Comments
 (0)