From 34aec42d096e9a862d923cb2868ab00b0697624e Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 24 Aug 2026 15:34:35 -0700 Subject: [PATCH] Bake optional skill bodies into worker director prompts Workers lack use_skill, so bake first-party optionalSkills into the system prompt. Keep skywalker name-only. Drop interview from plan so ask_operator guidance is not baked into a leaf that cannot ask. --- src/agent/directors/bake-skills.test.ts | 60 ++++++++++++++++ src/agent/directors/bake-skills.ts | 88 ++++++++++++++++++++++++ src/agent/directors/greybeard/package.ts | 2 +- src/agent/directors/identity.test.ts | 79 +++++++++++++++++++++ src/agent/directors/identity.ts | 34 +++++++-- src/agent/directors/index.ts | 2 + src/agent/directors/neckbeard/package.ts | 2 +- src/agent/directors/plan/package.test.ts | 4 +- src/agent/directors/plan/package.ts | 2 +- src/agent/directors/types.ts | 2 +- 10 files changed, 262 insertions(+), 13 deletions(-) create mode 100644 src/agent/directors/bake-skills.test.ts create mode 100644 src/agent/directors/bake-skills.ts diff --git a/src/agent/directors/bake-skills.test.ts b/src/agent/directors/bake-skills.test.ts new file mode 100644 index 000000000..4432c0b52 --- /dev/null +++ b/src/agent/directors/bake-skills.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { formatBakedOptionalSkills, loadBakedSkillBody } from "./bake-skills.js"; + +function stripFrontmatter(raw: string): string { + if (!raw.startsWith("---")) return raw.trim(); + const end = raw.indexOf("\n---", 3); + if (end === -1) return raw.trim(); + return raw.slice(end + 4).trim(); +} + +const styleOnDisk = stripFrontmatter( + readFileSync( + join(import.meta.dirname, "../../../plugins/corbits-skills/skills/style/SKILL.md"), + "utf8", + ), +); +const philosophyOnDisk = stripFrontmatter( + readFileSync( + join(import.meta.dirname, "../../../plugins/corbits-skills/skills/philosophy/SKILL.md"), + "utf8", + ), +); + +describe("loadBakedSkillBody", () => { + test("returns first-party style and philosophy bodies matching SKILL.md", () => { + expect(loadBakedSkillBody("style")).toBe(styleOnDisk); + expect(loadBakedSkillBody("philosophy")).toBe(philosophyOnDisk); + }); + + test("returns undefined for unknown skill names", () => { + expect(loadBakedSkillBody("does-not-exist-xyz")).toBeUndefined(); + }); +}); + +describe("formatBakedOptionalSkills", () => { + test("includes named bodies under Baked skill guidance", () => { + const text = formatBakedOptionalSkills(["style", "philosophy"]); + expect(text).toContain("# Baked skill guidance"); + expect(text).toContain("### style"); + expect(text).toContain("### philosophy"); + expect(text).toContain(styleOnDisk); + expect(text).toContain(philosophyOnDisk); + expect(text).toContain("use_skill is not mounted on workers"); + }); + + test("skips missing names without inventing content", () => { + const text = formatBakedOptionalSkills(["does-not-exist-xyz"]); + expect(text).toBe(""); + }); + + test("partial miss does not claim Full skill bodies", () => { + const text = formatBakedOptionalSkills(["style", "does-not-exist-xyz"]); + expect(text).toContain("### style"); + expect(text).toContain("Resolved skill bodies"); + expect(text).not.toContain("Full skill bodies"); + expect(text).not.toContain("### does-not-exist-xyz"); + }); +}); diff --git a/src/agent/directors/bake-skills.ts b/src/agent/directors/bake-skills.ts new file mode 100644 index 000000000..f08e49f25 --- /dev/null +++ b/src/agent/directors/bake-skills.ts @@ -0,0 +1,88 @@ +import { existsSync, readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +/** + * Strip leading YAML frontmatter from a SKILL.md body (same shape as + * resolveSkillBody / splitFrontmatter — keep sync and dependency-light so + * director prompt assembly stays sync). + */ +function stripFrontmatter(raw: string): string { + if (!raw.startsWith("---")) return raw.trim(); + const end = raw.indexOf("\n---", 3); + if (end === -1) return raw.trim(); + return raw.slice(end + 4).trim(); +} + +/** + * Candidate roots for first-party corbits-skills, covering source tree, + * bun-bundled dist/, and compiled binary layouts. Keep this self-contained — + * do not import plugins/loader (circular: loader → trust → … → directors). + */ +function skillsRootCandidates(): string[] { + const here = dirname(fileURLToPath(import.meta.url)); + const out: string[] = []; + // Source: src/agent/directors → ../../../plugins/corbits-skills/skills + out.push(join(here, "..", "..", "..", "plugins", "corbits-skills", "skills")); + // Bundled: dist/index.js (or chunk) → dist/plugins/corbits-skills/skills + out.push(join(here, "plugins", "corbits-skills", "skills")); + // Compiled binary: plugins next to execPath + if (process.execPath.length > 0) { + out.push(join(dirname(process.execPath), "plugins", "corbits-skills", "skills")); + } + return out; +} + +function resolveSkillsRoot(): string | undefined { + for (const dir of skillsRootCandidates()) { + if (existsSync(dir)) return dir; + } + return undefined; +} + +const bodyCache = new Map(); + +/** + * Load a first-party corbits-skills body by directory name (e.g. "style"). + */ +export function loadBakedSkillBody(name: string): string | undefined { + if (bodyCache.has(name)) return bodyCache.get(name); + + const root = resolveSkillsRoot(); + if (root === undefined) { + bodyCache.set(name, undefined); + return undefined; + } + + try { + const raw = readFileSync(join(root, name, "SKILL.md"), "utf8"); + const body = stripFrontmatter(raw); + const value = body.length > 0 ? body : undefined; + bodyCache.set(name, value); + return value; + } catch { + bodyCache.set(name, undefined); + return undefined; + } +} + +/** + * Append-ready markdown for optionalSkills bodies. Empty string when none + * resolve (missing catalog must not invent content or advertise a bake). + * Only include bodies that actually loaded — do not claim "full" coverage + * when some names miss. + */ +export function formatBakedOptionalSkills(names: readonly string[]): string { + const sections: string[] = []; + for (const name of names) { + const body = loadBakedSkillBody(name); + if (body === undefined) continue; + sections.push(`### ${name}\n\n${body}`); + } + if (sections.length === 0) return ""; + return ( + "\n\n# Baked skill guidance\n\n" + + "use_skill is not mounted on workers. Resolved skill bodies for this package follow.\n\n" + + sections.join("\n\n") + ); +} diff --git a/src/agent/directors/greybeard/package.ts b/src/agent/directors/greybeard/package.ts index 16a41b53c..2744c1772 100644 --- a/src/agent/directors/greybeard/package.ts +++ b/src/agent/directors/greybeard/package.ts @@ -22,7 +22,7 @@ export const greybeardPackage: DirectorPackage = { PRIMARY INTENT: architecture review. Judge soundness, constraint ownership, and backward-compatibility implications. Do not fix or ship product code. -Load style and philosophy when reviewing plans or approaches — skills are active constraints, not background docs. +Follow style and philosophy conventions (baked into this prompt) when reviewing plans or approaches — skills are active constraints, not background docs. You may spawn only intern, explore, and critique for evidence gathering. Do not spawn build, plan, skywalker, or other directors. Your value is analysis, not legwork or implementation. diff --git a/src/agent/directors/identity.test.ts b/src/agent/directors/identity.test.ts index 1052f3745..24d7388b5 100644 --- a/src/agent/directors/identity.test.ts +++ b/src/agent/directors/identity.test.ts @@ -1,4 +1,6 @@ import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; import { MODEL_ROLE_DEFAULT_EFFORT, defaultEffortForDirector, @@ -6,6 +8,13 @@ import { } from "./identity.js"; import { DIRECTOR_REGISTRY } from "./registry.js"; +function stripFrontmatter(raw: string): string { + if (!raw.startsWith("---")) return raw.trim(); + const end = raw.indexOf("\n---", 3); + if (end === -1) return raw.trim(); + return raw.slice(end + 4).trim(); +} + describe("formatDirectorSystemPrompt", () => { test("prefixes agent id, model role, and optional skills", () => { const text = formatDirectorSystemPrompt(DIRECTOR_REGISTRY.build); @@ -20,6 +29,76 @@ describe("formatDirectorSystemPrompt", () => { const text = formatDirectorSystemPrompt(DIRECTOR_REGISTRY.intern); expect(text).toContain("Optional skills: none by default"); }); + + test("bakes real style/philosophy/typescript bodies for build workers (CL-6803)", () => { + const text = formatDirectorSystemPrompt(DIRECTOR_REGISTRY.build); + const style = stripFrontmatter( + readFileSync( + join(import.meta.dirname, "../../../plugins/corbits-skills/skills/style/SKILL.md"), + "utf8", + ), + ); + const philosophy = stripFrontmatter( + readFileSync( + join(import.meta.dirname, "../../../plugins/corbits-skills/skills/philosophy/SKILL.md"), + "utf8", + ), + ); + const typescript = stripFrontmatter( + readFileSync( + join(import.meta.dirname, "../../../plugins/corbits-skills/skills/typescript/SKILL.md"), + "utf8", + ), + ); + expect(text).toContain("# Baked skill guidance"); + expect(text).toContain(style); + expect(text).toContain(philosophy); + expect(text).toContain(typescript); + }); + + test("does not bake skill bodies when optionalSkills is empty", () => { + const text = formatDirectorSystemPrompt(DIRECTOR_REGISTRY.intern); + expect(text).not.toContain("# Baked skill guidance"); + }); + + test("does not advertise bake when no skill bodies resolve (total miss)", () => { + const text = formatDirectorSystemPrompt({ + ...DIRECTOR_REGISTRY.build, + optionalSkills: ["does-not-exist-xyz"], + }); + expect(text).not.toContain("# Baked skill guidance"); + expect(text).not.toMatch(/guidance is baked/i); + expect(text).toContain( + "Optional skills (names for awareness — use_skill is not mounted on workers)", + ); + expect(text).toContain("does-not-exist-xyz"); + }); + + test("skywalker does not bake skills or claim use_skill unmounted", () => { + const text = formatDirectorSystemPrompt(DIRECTOR_REGISTRY.skywalker); + expect(text).not.toContain("# Baked skill guidance"); + expect(text).not.toContain("use_skill is not mounted on workers"); + expect(text).not.toMatch(/guidance is baked/i); + expect(text).toContain("use_skill is primary-mounted"); + expect(text).toContain("dispatch, style, philosophy, interview"); + }); + + test("plan does not bake interview ask_operator guidance (CL-6803)", () => { + const text = formatDirectorSystemPrompt(DIRECTOR_REGISTRY.plan); + const interview = stripFrontmatter( + readFileSync( + join(import.meta.dirname, "../../../plugins/corbits-skills/skills/interview/SKILL.md"), + "utf8", + ), + ); + expect(DIRECTOR_REGISTRY.plan.optionalSkills).toEqual(["style", "philosophy"]); + expect(text).not.toContain(interview); + expect(text).not.toContain("### interview"); + // interview recipe centers on ask_operator batches; plan must not embed it + expect(text).not.toMatch(/multiple-choice questions in batches via `ask_operator`/); + expect(text).toContain("style, philosophy"); + expect(text).toContain("# Baked skill guidance"); + }); }); describe("defaultEffortForDirector", () => { diff --git a/src/agent/directors/identity.ts b/src/agent/directors/identity.ts index bc0d1f9b2..2002b826b 100644 --- a/src/agent/directors/identity.ts +++ b/src/agent/directors/identity.ts @@ -1,25 +1,45 @@ import type { DirectorPackage } from "./types.js"; import type { ModelRole } from "./types.js"; import type { ReasoningEffort } from "../../provider/reasoning-effort.js"; +import { formatBakedOptionalSkills } from "./bake-skills.js"; /** * Prefix every director system prompt with a stable identity block so the model * always sees agent id, model role, and optional skills — no ambiguity about which * package it is or how the parent should re-spawn it. + * + * Workers (non-orchestrator): bake first-party optionalSkills bodies (CL-6803) + * and only advertise that bake when at least one body resolved. Primary + * orchestrator (skywalker): use_skill is mounted — list skill names only; do + * not bake huge dispatch/interview bodies or claim use_skill is unmounted. */ export function formatDirectorSystemPrompt(pkg: DirectorPackage): string { - const skillsLine = - pkg.optionalSkills === undefined - ? null - : pkg.optionalSkills.length === 0 - ? "Optional skills: none by default." - : `Optional skills (names for awareness; guidance is baked into this prompt — use_skill is not mounted on workers): ${pkg.optionalSkills.join(", ")}.`; + const names = pkg.optionalSkills; + const isPrimaryOrchestrator = pkg.tier === "orchestrator"; + + let skillsLine: string | null = null; + let baked = ""; + + if (names === undefined) { + skillsLine = null; + } else if (names.length === 0) { + skillsLine = "Optional skills: none by default."; + } else if (isPrimaryOrchestrator) { + skillsLine = `Optional skills (names for awareness; use_skill is primary-mounted): ${names.join(", ")}.`; + } else { + baked = formatBakedOptionalSkills(names); + skillsLine = + baked.length > 0 + ? `Optional skills (names for awareness; guidance is baked into this prompt — use_skill is not mounted on workers): ${names.join(", ")}.` + : `Optional skills (names for awareness — use_skill is not mounted on workers): ${names.join(", ")}.`; + } + const header = [ `Identity: agent id \`${pkg.id}\` — spawn as task(agent="${pkg.id}").`, `Model role: ${pkg.modelRole}.`, ...(skillsLine !== null ? [skillsLine] : []), ].join("\n"); - return `${header}\n\n${pkg.systemPrompt}`; + return `${header}\n\n${pkg.systemPrompt}${baked}`; } /** diff --git a/src/agent/directors/index.ts b/src/agent/directors/index.ts index c675604d5..faffc7191 100644 --- a/src/agent/directors/index.ts +++ b/src/agent/directors/index.ts @@ -27,3 +27,5 @@ export { defaultEffortForDirector, formatDirectorSystemPrompt, } from "./identity.js"; + +export { formatBakedOptionalSkills, loadBakedSkillBody } from "./bake-skills.js"; diff --git a/src/agent/directors/neckbeard/package.ts b/src/agent/directors/neckbeard/package.ts index 1031ab5dd..33c246804 100644 --- a/src/agent/directors/neckbeard/package.ts +++ b/src/agent/directors/neckbeard/package.ts @@ -26,7 +26,7 @@ PRIMARY INTENT: adversarial pedantic review. Surface hygiene issues, nits, and r Be pedantic on purpose: naming drift, comment rot, type escape hatches, boundary validation, off-by-ones, unicode/width/escape fiddliness, dead paths, and taste-vs-defect separation. Cite file paths and concrete snippets. Separate genuine defects from taste; label each finding. -Do not apply fixes. Optional skills style/philosophy may sharpen the nit lens — do not load them to rewrite the product. +Do not apply fixes. Style/philosophy (baked into this prompt) may sharpen the nit lens — do not rewrite the product. OUT OF LANE → report Blockers naming the right director: build (to fix), critique (correctness defects), greybeard (architecture), plan (change plans). diff --git a/src/agent/directors/plan/package.test.ts b/src/agent/directors/plan/package.test.ts index 5bd548e46..97cfd7a28 100644 --- a/src/agent/directors/plan/package.test.ts +++ b/src/agent/directors/plan/package.test.ts @@ -31,8 +31,8 @@ describe("planPackage", () => { expect(planPackage.modelRole).toBe("plan"); }); - test("optionalSkills order", () => { - expect(planPackage.optionalSkills).toEqual(["style", "philosophy", "interview"]); + test("optionalSkills order is style, philosophy (no interview — plan cannot ask_operator)", () => { + expect(planPackage.optionalSkills).toEqual(["style", "philosophy"]); }); test("primaryIntent and outOfLane match plan lane", () => { diff --git a/src/agent/directors/plan/package.ts b/src/agent/directors/plan/package.ts index 9345c65ec..675e3a206 100644 --- a/src/agent/directors/plan/package.ts +++ b/src/agent/directors/plan/package.ts @@ -6,7 +6,7 @@ export const planPackage: DirectorPackage = { primaryIntent: "Author eng change plans; do not implement", outOfLane: ["shipping code", "architecture gate sign-off as Greybeard", "running the fleet"], description: "Planning leaf — eng plans only; Greybeard reviews", - optionalSkills: ["style", "philosophy", "interview"], + optionalSkills: ["style", "philosophy"], tools: { allow: REVIEW_TOOLS }, spawn: { maySpawn: false }, tier: "leaf", diff --git a/src/agent/directors/types.ts b/src/agent/directors/types.ts index 765d21b94..8e3586fe7 100644 --- a/src/agent/directors/types.ts +++ b/src/agent/directors/types.ts @@ -86,7 +86,7 @@ export interface DirectorPackage { readonly description: string; /** Opinionated core prompt (prompt-first). */ readonly systemPrompt: string; - /** Optional skills the worker may load dynamically (ordered). */ + /** Optional skill names (ordered). Workers bake matching first-party bodies into the prompt; the primary orchestrator keeps them use_skill-loadable. */ readonly optionalSkills?: readonly string[]; readonly tools?: ToolEnvelope; readonly spawn: SpawnRights;