Skip to content

Commit a97fb12

Browse files
Bake optional skill bodies into worker director prompts (#676)
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.
1 parent ed0ade0 commit a97fb12

11 files changed

Lines changed: 271 additions & 13 deletions

File tree

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { describe, expect, test } from "bun:test";
2+
import { readFileSync } from "node:fs";
3+
import { join } from "node:path";
4+
import { formatBakedOptionalSkills, loadBakedSkillBody } from "./bake-skills.js";
5+
6+
function stripFrontmatter(raw: string): string {
7+
if (!raw.startsWith("---")) return raw.trim();
8+
const end = raw.indexOf("\n---", 3);
9+
if (end === -1) return raw.trim();
10+
return raw.slice(end + 4).trim();
11+
}
12+
13+
const styleOnDisk = stripFrontmatter(
14+
readFileSync(
15+
join(import.meta.dirname, "../../../plugins/corbits-skills/skills/style/SKILL.md"),
16+
"utf8",
17+
),
18+
);
19+
const philosophyOnDisk = stripFrontmatter(
20+
readFileSync(
21+
join(import.meta.dirname, "../../../plugins/corbits-skills/skills/philosophy/SKILL.md"),
22+
"utf8",
23+
),
24+
);
25+
26+
describe("loadBakedSkillBody", () => {
27+
test("returns first-party style and philosophy bodies matching SKILL.md", () => {
28+
expect(loadBakedSkillBody("style")).toBe(styleOnDisk);
29+
expect(loadBakedSkillBody("philosophy")).toBe(philosophyOnDisk);
30+
});
31+
32+
test("returns undefined for unknown skill names", () => {
33+
expect(loadBakedSkillBody("does-not-exist-xyz")).toBeUndefined();
34+
});
35+
});
36+
37+
describe("formatBakedOptionalSkills", () => {
38+
test("includes named bodies under Baked skill guidance", () => {
39+
const text = formatBakedOptionalSkills(["style", "philosophy"]);
40+
expect(text).toContain("# Baked skill guidance");
41+
expect(text).toContain("### style");
42+
expect(text).toContain("### philosophy");
43+
expect(text).toContain(styleOnDisk);
44+
expect(text).toContain(philosophyOnDisk);
45+
expect(text).toContain("use_skill is not mounted on workers");
46+
});
47+
48+
test("skips missing names without inventing content", () => {
49+
const text = formatBakedOptionalSkills(["does-not-exist-xyz"]);
50+
expect(text).toBe("");
51+
});
52+
53+
test("partial miss does not claim Full skill bodies", () => {
54+
const text = formatBakedOptionalSkills(["style", "does-not-exist-xyz"]);
55+
expect(text).toContain("### style");
56+
expect(text).toContain("Resolved skill bodies");
57+
expect(text).not.toContain("Full skill bodies");
58+
expect(text).not.toContain("### does-not-exist-xyz");
59+
});
60+
});

src/agent/directors/bake-skills.ts

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import { existsSync, readFileSync } from "node:fs";
2+
import { dirname, join } from "node:path";
3+
import { fileURLToPath } from "node:url";
4+
5+
/**
6+
* Strip leading YAML frontmatter from a SKILL.md body (same shape as
7+
* resolveSkillBody / splitFrontmatter — keep sync and dependency-light so
8+
* director prompt assembly stays sync).
9+
*/
10+
function stripFrontmatter(raw: string): string {
11+
if (!raw.startsWith("---")) return raw.trim();
12+
const end = raw.indexOf("\n---", 3);
13+
if (end === -1) return raw.trim();
14+
return raw.slice(end + 4).trim();
15+
}
16+
17+
/**
18+
* Candidate roots for first-party corbits-skills, covering source tree,
19+
* bun-bundled dist/, and compiled binary layouts. Keep this self-contained —
20+
* do not import plugins/loader (circular: loader → trust → … → directors).
21+
*/
22+
function skillsRootCandidates(): string[] {
23+
const here = dirname(fileURLToPath(import.meta.url));
24+
const out: string[] = [];
25+
// Source: src/agent/directors → ../../../plugins/corbits-skills/skills
26+
out.push(join(here, "..", "..", "..", "plugins", "corbits-skills", "skills"));
27+
// Bundled: dist/index.js (or chunk) → dist/plugins/corbits-skills/skills
28+
out.push(join(here, "plugins", "corbits-skills", "skills"));
29+
// Compiled binary: plugins next to execPath
30+
if (process.execPath.length > 0) {
31+
out.push(join(dirname(process.execPath), "plugins", "corbits-skills", "skills"));
32+
}
33+
return out;
34+
}
35+
36+
function resolveSkillsRoot(): string | undefined {
37+
for (const dir of skillsRootCandidates()) {
38+
if (existsSync(dir)) return dir;
39+
}
40+
return undefined;
41+
}
42+
43+
const bodyCache = new Map<string, string | undefined>();
44+
45+
/**
46+
* Load a first-party corbits-skills body by directory name (e.g. "style").
47+
*/
48+
export function loadBakedSkillBody(name: string): string | undefined {
49+
if (bodyCache.has(name)) return bodyCache.get(name);
50+
51+
const root = resolveSkillsRoot();
52+
if (root === undefined) {
53+
bodyCache.set(name, undefined);
54+
return undefined;
55+
}
56+
57+
try {
58+
const raw = readFileSync(join(root, name, "SKILL.md"), "utf8");
59+
const body = stripFrontmatter(raw);
60+
const value = body.length > 0 ? body : undefined;
61+
bodyCache.set(name, value);
62+
return value;
63+
} catch {
64+
bodyCache.set(name, undefined);
65+
return undefined;
66+
}
67+
}
68+
69+
/**
70+
* Append-ready markdown for optionalSkills bodies. Empty string when none
71+
* resolve (missing catalog must not invent content or advertise a bake).
72+
* Only include bodies that actually loaded — do not claim "full" coverage
73+
* when some names miss.
74+
*/
75+
export function formatBakedOptionalSkills(names: readonly string[]): string {
76+
const sections: string[] = [];
77+
for (const name of names) {
78+
const body = loadBakedSkillBody(name);
79+
if (body === undefined) continue;
80+
sections.push(`### ${name}\n\n${body}`);
81+
}
82+
if (sections.length === 0) return "";
83+
return (
84+
"\n\n# Baked skill guidance\n\n" +
85+
"use_skill is not mounted on workers. Resolved skill bodies for this package follow.\n\n" +
86+
sections.join("\n\n")
87+
);
88+
}

src/agent/directors/counsel/package.test.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,12 @@ describe("counselPackage", () => {
7979
});
8080

8181
test("optionalSkills order", () => {
82-
expect(counselPackage.optionalSkills).toEqual(["style", "philosophy", "interview"]);
82+
expect(counselPackage.optionalSkills).toEqual(["style", "philosophy"]);
83+
});
84+
85+
test("does not advertise interview skill workers cannot use", () => {
86+
expect(counselPackage.optionalSkills).not.toContain("interview");
87+
expect(counselPackage.systemPrompt).not.toMatch(/interview-skill awareness/i);
8388
});
8489

8590
test("primaryIntent and outOfLane match counsel / plan lane", () => {

src/agent/directors/counsel/package.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ export const counselPackage: DirectorPackage = {
1616
"becoming Builder or Critic",
1717
],
1818
description: "Counsel leaf — ordered eng plans only; Greybeard reviews",
19-
optionalSkills: ["style", "philosophy", "interview"],
19+
optionalSkills: ["style", "philosophy"],
2020
tools: { allow: REVIEW_TOOLS },
2121
spawn: { maySpawn: false },
2222
tier: "leaf",
@@ -34,7 +34,7 @@ Author an agent-proof plan:
3434
4. Risks and open questions
3535
5. Ordered steps a Builder can execute without guessing
3636
37-
When requirements are fuzzy, note open questions under Blockers instead of guessing — you cannot ask the operator mid-run. Prefer interview-skill awareness for discovery gaps; do not invent scope.
37+
When requirements are fuzzy, note open questions under Blockers instead of guessing — you cannot ask the operator mid-run. Do not invent scope.
3838
3939
DONE GATE: Stop when the plan covers every success_criteria item from the brief OR blockers are explicit. Do not expand into implementation, architecture essays, or review theater after the plan is complete.
4040

src/agent/directors/greybeard/package.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ PRIMARY INTENT: architecture judgment. Judge approach soundness, constraint owne
2424
2525
You are Greybeard — not a second Skywalker, not Critic (code defects with evidence), not Builder. Your value is architectural judgment, not legwork or implementation.
2626
27+
Follow style and philosophy conventions (baked into this prompt) when reviewing plans or approaches — skills are active constraints, not background docs.
28+
2729
Judge the approach:
2830
1. Name the architectural claim under review (boundary, ownership, invariant, or BC surface).
2931
2. Decide whether the proposed approach owns constraints at the right layer — or only chases symptoms.

src/agent/directors/identity.test.ts

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,20 @@
11
import { describe, expect, test } from "bun:test";
2+
import { readFileSync } from "node:fs";
3+
import { join } from "node:path";
24
import {
35
MODEL_ROLE_DEFAULT_EFFORT,
46
defaultEffortForDirector,
57
formatDirectorSystemPrompt,
68
} from "./identity.js";
79
import { DIRECTOR_REGISTRY } from "./registry.js";
810

11+
function stripFrontmatter(raw: string): string {
12+
if (!raw.startsWith("---")) return raw.trim();
13+
const end = raw.indexOf("\n---", 3);
14+
if (end === -1) return raw.trim();
15+
return raw.slice(end + 4).trim();
16+
}
17+
918
describe("formatDirectorSystemPrompt", () => {
1019
test("prefixes agent id, model role, and optional skills", () => {
1120
const text = formatDirectorSystemPrompt(DIRECTOR_REGISTRY.builder);
@@ -20,6 +29,76 @@ describe("formatDirectorSystemPrompt", () => {
2029
const text = formatDirectorSystemPrompt(DIRECTOR_REGISTRY.intern);
2130
expect(text).toContain("Optional skills: none by default");
2231
});
32+
33+
test("bakes real style/philosophy/typescript bodies for builder workers (CL-6803)", () => {
34+
const text = formatDirectorSystemPrompt(DIRECTOR_REGISTRY.builder);
35+
const style = stripFrontmatter(
36+
readFileSync(
37+
join(import.meta.dirname, "../../../plugins/corbits-skills/skills/style/SKILL.md"),
38+
"utf8",
39+
),
40+
);
41+
const philosophy = stripFrontmatter(
42+
readFileSync(
43+
join(import.meta.dirname, "../../../plugins/corbits-skills/skills/philosophy/SKILL.md"),
44+
"utf8",
45+
),
46+
);
47+
const typescript = stripFrontmatter(
48+
readFileSync(
49+
join(import.meta.dirname, "../../../plugins/corbits-skills/skills/typescript/SKILL.md"),
50+
"utf8",
51+
),
52+
);
53+
expect(text).toContain("# Baked skill guidance");
54+
expect(text).toContain(style);
55+
expect(text).toContain(philosophy);
56+
expect(text).toContain(typescript);
57+
});
58+
59+
test("does not bake skill bodies when optionalSkills is empty", () => {
60+
const text = formatDirectorSystemPrompt(DIRECTOR_REGISTRY.intern);
61+
expect(text).not.toContain("# Baked skill guidance");
62+
});
63+
64+
test("does not advertise bake when no skill bodies resolve (total miss)", () => {
65+
const text = formatDirectorSystemPrompt({
66+
...DIRECTOR_REGISTRY.builder,
67+
optionalSkills: ["does-not-exist-xyz"],
68+
});
69+
expect(text).not.toContain("# Baked skill guidance");
70+
expect(text).not.toMatch(/guidance is baked/i);
71+
expect(text).toContain(
72+
"Optional skills (names for awareness — use_skill is not mounted on workers)",
73+
);
74+
expect(text).toContain("does-not-exist-xyz");
75+
});
76+
77+
test("skywalker does not bake skills or claim use_skill unmounted", () => {
78+
const text = formatDirectorSystemPrompt(DIRECTOR_REGISTRY.skywalker);
79+
expect(text).not.toContain("# Baked skill guidance");
80+
expect(text).not.toContain("use_skill is not mounted on workers");
81+
expect(text).not.toMatch(/guidance is baked/i);
82+
expect(text).toContain("use_skill is primary-mounted");
83+
expect(text).toContain("dispatch, style, philosophy, interview");
84+
});
85+
86+
test("counsel does not bake interview ask_operator guidance (CL-6803)", () => {
87+
const text = formatDirectorSystemPrompt(DIRECTOR_REGISTRY.counsel);
88+
const interview = stripFrontmatter(
89+
readFileSync(
90+
join(import.meta.dirname, "../../../plugins/corbits-skills/skills/interview/SKILL.md"),
91+
"utf8",
92+
),
93+
);
94+
expect(DIRECTOR_REGISTRY.counsel.optionalSkills).toEqual(["style", "philosophy"]);
95+
expect(text).not.toContain(interview);
96+
expect(text).not.toContain("### interview");
97+
// interview recipe centers on ask_operator batches; counsel must not embed it
98+
expect(text).not.toMatch(/multiple-choice questions in batches via `ask_operator`/);
99+
expect(text).toContain("style, philosophy");
100+
expect(text).toContain("# Baked skill guidance");
101+
});
23102
});
24103

25104
describe("defaultEffortForDirector", () => {

src/agent/directors/identity.ts

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,45 @@
11
import type { DirectorPackage } from "./types.js";
22
import type { ModelRole } from "./types.js";
33
import type { ReasoningEffort } from "../../provider/reasoning-effort.js";
4+
import { formatBakedOptionalSkills } from "./bake-skills.js";
45

56
/**
67
* Prefix every director system prompt with a stable identity block so the model
78
* always sees agent id, model role, and optional skills — no ambiguity about which
89
* package it is or how the parent should re-spawn it.
10+
*
11+
* Workers (non-orchestrator): bake first-party optionalSkills bodies (CL-6803)
12+
* and only advertise that bake when at least one body resolved. Primary
13+
* orchestrator (skywalker): use_skill is mounted — list skill names only; do
14+
* not bake huge dispatch/interview bodies or claim use_skill is unmounted.
915
*/
1016
export function formatDirectorSystemPrompt(pkg: DirectorPackage): string {
11-
const skillsLine =
12-
pkg.optionalSkills === undefined
13-
? null
14-
: pkg.optionalSkills.length === 0
15-
? "Optional skills: none by default."
16-
: `Optional skills (names for awareness; guidance is baked into this prompt — use_skill is not mounted on workers): ${pkg.optionalSkills.join(", ")}.`;
17+
const names = pkg.optionalSkills;
18+
const isPrimaryOrchestrator = pkg.tier === "orchestrator";
19+
20+
let skillsLine: string | null = null;
21+
let baked = "";
22+
23+
if (names === undefined) {
24+
skillsLine = null;
25+
} else if (names.length === 0) {
26+
skillsLine = "Optional skills: none by default.";
27+
} else if (isPrimaryOrchestrator) {
28+
skillsLine = `Optional skills (names for awareness; use_skill is primary-mounted): ${names.join(", ")}.`;
29+
} else {
30+
baked = formatBakedOptionalSkills(names);
31+
skillsLine =
32+
baked.length > 0
33+
? `Optional skills (names for awareness; guidance is baked into this prompt — use_skill is not mounted on workers): ${names.join(", ")}.`
34+
: `Optional skills (names for awareness — use_skill is not mounted on workers): ${names.join(", ")}.`;
35+
}
36+
1737
const header = [
1838
`Identity: agent id \`${pkg.id}\` — spawn as task(agent="${pkg.id}").`,
1939
`Model role: ${pkg.modelRole}.`,
2040
...(skillsLine !== null ? [skillsLine] : []),
2141
].join("\n");
22-
return `${header}\n\n${pkg.systemPrompt}`;
42+
return `${header}\n\n${pkg.systemPrompt}${baked}`;
2343
}
2444

2545
/**

src/agent/directors/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,3 +27,5 @@ export {
2727
defaultEffortForDirector,
2828
formatDirectorSystemPrompt,
2929
} from "./identity.js";
30+
31+
export { formatBakedOptionalSkills, loadBakedSkillBody } from "./bake-skills.js";

src/agent/directors/neckbeard/package.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ describe("neckbeardPackage", () => {
1919
expect(neckbeardPackage.systemPrompt).toMatch(/NeckbeardDirector/);
2020
expect(neckbeardPackage.systemPrompt).toMatch(/never fix/i);
2121
expect(neckbeardPackage.systemPrompt).toContain("builder (to fix)");
22+
expect(neckbeardPackage.systemPrompt).toContain("Critic");
23+
expect(neckbeardPackage.systemPrompt).not.toMatch(/Critique/);
2224
});
2325

2426
test("spawn.maySpawn is false", () => {

src/agent/directors/neckbeard/package.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,11 @@ export const neckbeardPackage: DirectorPackage = {
2222
modelRole: "review",
2323
systemPrompt: `You are NeckbeardDirector, a specialist in Corbits Code.
2424
25-
PRIMARY INTENT: adversarial pedantic review. Surface hygiene issues, nits, and refactor proposals with evidence. Never fix product code. You are not the architecture owner (that is Greybeard). You are not the defect-severity owner (that is Critique).
25+
PRIMARY INTENT: adversarial pedantic review. Surface hygiene issues, nits, and refactor proposals with evidence. Never fix product code. You are not the architecture owner (that is Greybeard). You are not the defect-severity owner (that is Critic).
2626
2727
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.
2828
29-
Do not apply fixes. Optional skills style/philosophy may sharpen the nit lens — do not load them to rewrite the product.
29+
Do not apply fixes. Style/philosophy (baked into this prompt) may sharpen the nit lens — do not rewrite the product.
3030
3131
OUT OF LANE → report Blockers naming the right director: builder (to fix), critic (correctness defects), greybeard (architecture), counsel (change plans).
3232

0 commit comments

Comments
 (0)