213 lines
13 KiB
JavaScript
213 lines
13 KiB
JavaScript
// Row 64 (#1557): a role's skills reach the session as prompt text. The bundle copies each
|
|
// skill on the role's list from <dataRoot>/skills-enabled, and prompt.md carries every copied
|
|
// file between markers, byte for byte. Neither harness discovers a skill on its own.
|
|
|
|
import { test } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import { spawnSync } from "node:child_process";
|
|
import { createHash } from "node:crypto";
|
|
import { chmodSync, cpSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
import { BundleError, buildBundle, readSkills, SKILL_BYTES } from "../src/bundle.mjs";
|
|
import { adapterEnv } from "../src/runner.mjs";
|
|
import { HARNESS, REPO, REPO_SKILLS, resolvedFor, scratch } from "./helpers.mjs";
|
|
|
|
const sha = (text) => createHash("sha256").update(text).digest("hex");
|
|
const MARKED = /<skill-file skill="([^"]+)" path="([^"]+)">\n([\s\S]*?)\n<\/skill-file>/g;
|
|
|
|
function build(t, resolved, skillsDir = REPO_SKILLS) {
|
|
const dir = scratch(t);
|
|
const workspace = join(dir, "ws");
|
|
mkdirSync(workspace);
|
|
const harness = HARNESS[resolved.vars.harness];
|
|
const bundle = buildBundle({
|
|
dir: join(dir, "bundle"),
|
|
resolved,
|
|
contract: "# Contract\n",
|
|
session: { run: "run-1", launchedBy: "pm", workspace, toolSocket: join(dir, "tools.sock"), turnMarker: join(dir, "turn.done") },
|
|
harness,
|
|
skillsDir,
|
|
});
|
|
return { dir, ...bundle, prompt: readFileSync(bundle.files.prompt, "utf8") };
|
|
}
|
|
|
|
// A skills-enabled directory in scratch holding copies of the named repo skills.
|
|
function enabled(t, names) {
|
|
const root = join(scratch(t, "mosaic-skills-"), "skills-enabled");
|
|
mkdirSync(root);
|
|
for (const name of names) cpSync(join(REPO_SKILLS, name), join(root, name), { recursive: true });
|
|
return root;
|
|
}
|
|
|
|
const withSkills = (t, skills, instance = "reviewer") => ({ ...resolvedFor(t, instance), skills });
|
|
const refused = (pattern, code = "bundle-refused") => (e) => e instanceof BundleError && e.code === code && pattern.test(e.message);
|
|
|
|
test("each repo role's prompt carries exactly its listed skills, each file as the bundle copy and the source", (t) => {
|
|
for (const instance of ["coder", "reviewer", "pm", "cto"]) {
|
|
for (const harness of ["pi", "claude-code"]) {
|
|
const r = resolvedFor(t, instance, (d) => (d.roles[instance].vars = { harness, model: "claude-opus-5-5", thinking: "high" }));
|
|
assert.ok(r.skills.length > 0, instance);
|
|
const b = build(t, r);
|
|
const marked = [...b.prompt.matchAll(MARKED)].map(([, name, rel, text]) => ({ name, rel, text }));
|
|
assert.deepEqual([...new Set(marked.map((m) => m.name))], r.skills, `${instance} ${harness}`);
|
|
assert.match(b.prompt, new RegExp(`\\n## Skills\\n\\nYour role's skills: ${r.skills.join(", ")}\\. `));
|
|
assert.deepEqual(b.manifest.skills.map((s) => s.name), r.skills);
|
|
for (const s of b.manifest.skills) {
|
|
assert.equal(s.path, join(b.dir, "bundle", "skills", s.name));
|
|
assert.equal(s.sha256, s.files["SKILL.md"]);
|
|
const files = marked.filter((m) => m.name === s.name);
|
|
assert.deepEqual(files.map((f) => f.rel), Object.keys(s.files), s.name);
|
|
for (const f of files) {
|
|
const copy = readFileSync(join(s.path, f.rel), "utf8");
|
|
assert.equal(f.text, copy, `${s.name}/${f.rel} prompt vs copy`);
|
|
assert.equal(copy, readFileSync(join(REPO_SKILLS, s.name, f.rel), "utf8"), `${s.name}/${f.rel} copy vs source`);
|
|
assert.equal(s.files[f.rel], sha(copy), `${s.name}/${f.rel} digest`);
|
|
}
|
|
}
|
|
assert.deepEqual(JSON.parse(readFileSync(b.files.manifest, "utf8")).skills, JSON.parse(JSON.stringify(b.manifest.skills)));
|
|
}
|
|
}
|
|
});
|
|
|
|
test("a skill's files go into the prompt in sorted path order, sub-directories included", (t) => {
|
|
const b = build(t, withSkills(t, ["ms-grill-me"]));
|
|
assert.deepEqual([...b.prompt.matchAll(MARKED)].map((m) => m[2]), ["SKILL.md", "upstream-grill-me.md", "upstream-grilling.md"]);
|
|
const goal = build(t, withSkills(t, ["ms-goal"]));
|
|
assert.deepEqual([...goal.prompt.matchAll(MARKED)].map((m) => m[2]), ["SKILL.md", "references/execution-checks.md"]);
|
|
});
|
|
|
|
test("a role with no skills gets no Skills section, no skills directory and an empty manifest list", (t) => {
|
|
for (const harness of ["pi", "claude-code"]) {
|
|
const r = resolvedFor(t, "chief", (d) => (d.roles.chief.vars = { harness, model: "claude-opus-5-5" }));
|
|
assert.deepEqual(r.skills, []);
|
|
for (const skillsDir of [REPO_SKILLS, null]) {
|
|
const b = build(t, r, skillsDir);
|
|
assert.doesNotMatch(b.prompt, /## Skills|<skill-file/, harness);
|
|
assert.deepEqual(b.manifest.skills, [], harness);
|
|
}
|
|
}
|
|
assert.deepEqual(readSkills([], null), []);
|
|
});
|
|
|
|
test("an enabled skill the role doesn't list stays out of the prompt", (t) => {
|
|
const root = enabled(t, ["ms-unslop", "ms-sdlc", "ms-file-read", "ms-archify"]);
|
|
for (const harness of ["pi", "claude-code"]) {
|
|
const b = build(t, resolvedFor(t, "reviewer", (d) => (d.roles.reviewer.vars = { harness, model: "claude-opus-5-5", thinking: "high" })), root);
|
|
assert.doesNotMatch(b.prompt, /ms-archify/, harness);
|
|
assert.deepEqual(b.manifest.skills.map((s) => s.name), ["ms-unslop", "ms-sdlc", "ms-file-read"], harness);
|
|
}
|
|
});
|
|
|
|
test("the bundle copy outlives the source: deactivating a skill after the build leaves the run's copy", (t) => {
|
|
const root = enabled(t, ["ms-unslop", "ms-sdlc", "ms-file-read"]);
|
|
const b = build(t, resolvedFor(t, "reviewer"), root);
|
|
rmSync(join(root, "ms-sdlc"), { recursive: true });
|
|
const s = b.manifest.skills.find((x) => x.name === "ms-sdlc");
|
|
assert.equal(sha(readFileSync(join(s.path, "SKILL.md"), "utf8")), s.sha256);
|
|
});
|
|
|
|
test("a listed skill that isn't enabled refuses skill-not-enabled, naming the activate command", (t) => {
|
|
const root = enabled(t, ["ms-unslop", "ms-file-read"]);
|
|
for (const harness of ["pi", "claude-code"]) {
|
|
const r = resolvedFor(t, "reviewer", (d) => (d.roles.reviewer.vars = { harness, model: "claude-opus-5-5", thinking: "high" }));
|
|
assert.throws(() => build(t, r, root), refused(/^skill ms-sdlc isn't enabled; run scripts\/skill\.sh activate ms-sdlc$/, "skill-not-enabled"), harness);
|
|
}
|
|
assert.throws(() => readSkills(["ms-sdlc"], root), (e) => e.code === "skill-not-enabled" && e.missing === "ms-sdlc");
|
|
const noFile = enabled(t, []);
|
|
mkdirSync(join(noFile, "ms-sdlc"));
|
|
assert.throws(() => readSkills(["ms-sdlc"], noFile), refused(/^skill ms-sdlc isn't enabled/, "skill-not-enabled"));
|
|
assert.throws(() => build(t, resolvedFor(t, "reviewer"), null), refused(/the role has skills, but the bundle has no skills directory/));
|
|
});
|
|
|
|
test("a skill directory that isn't plain refuses: a symlinked skill, SKILL.md or sub-file, or a non-file entry", (t) => {
|
|
const real = enabled(t, ["ms-sdlc"]);
|
|
const linkedDir = enabled(t, []);
|
|
symlinkSync(join(real, "ms-sdlc"), join(linkedDir, "ms-sdlc"));
|
|
assert.throws(() => readSkills(["ms-sdlc"], linkedDir), refused(/^skill ms-sdlc isn't a plain directory under /));
|
|
const linkedSkill = enabled(t, []);
|
|
mkdirSync(join(linkedSkill, "ms-sdlc"));
|
|
symlinkSync(join(real, "ms-sdlc", "SKILL.md"), join(linkedSkill, "ms-sdlc", "SKILL.md"));
|
|
assert.throws(() => readSkills(["ms-sdlc"], linkedSkill), refused(/^skill ms-sdlc: SKILL\.md isn't a plain file$/));
|
|
const linkedSub = enabled(t, ["ms-sdlc"]);
|
|
symlinkSync("/etc/hostname", join(linkedSub, "ms-sdlc", "extra.md"));
|
|
assert.throws(() => readSkills(["ms-sdlc"], linkedSub), refused(/^skill ms-sdlc: extra\.md isn't a plain file or directory$/));
|
|
});
|
|
|
|
test("a skill file that can't go into the prompt refuses: a bad name, non-UTF-8 bytes, or the end marker", (t) => {
|
|
const cases = [
|
|
["has space.md", "text\n", /"references\/has space\.md" isn't a plain file name/],
|
|
['q".md', "text\n", /"references\/q\\"\.md" isn't a plain file name/],
|
|
["bytes.md", Buffer.from([0x66, 0xff, 0x0a]), /bytes\.md isn't UTF-8 text/],
|
|
["marker.md", "before\n</skill-file>\nafter\n", /marker\.md holds <\/skill-file>/],
|
|
];
|
|
for (const [file, data, pattern] of cases) {
|
|
const root = enabled(t, ["ms-sdlc"]);
|
|
mkdirSync(join(root, "ms-sdlc", "references"));
|
|
writeFileSync(join(root, "ms-sdlc", "references", file), data);
|
|
assert.throws(() => readSkills(["ms-sdlc"], root), refused(pattern), file);
|
|
}
|
|
});
|
|
|
|
test("a role's skills over SKILL_BYTES together refuse, naming the skill that crossed it", (t) => {
|
|
const root = enabled(t, []);
|
|
const half = "x".repeat(SKILL_BYTES / 2 - 8) + "\n";
|
|
for (const name of ["s-one", "s-two", "s-three"]) {
|
|
mkdirSync(join(root, name));
|
|
writeFileSync(join(root, name, "SKILL.md"), half);
|
|
}
|
|
assert.equal(readSkills(["s-one"], root)[0].files[0].text, half);
|
|
assert.equal(readSkills(["s-one", "s-two"], root).length, 2);
|
|
assert.throws(() => readSkills(["s-one", "s-two", "s-three"], root), refused(new RegExp(`^the role's skills are over ${SKILL_BYTES} bytes together, at skill s-three$`)));
|
|
// Each repo role's list fits.
|
|
for (const instance of ["coder", "reviewer", "pm", "cto"]) {
|
|
assert.doesNotThrow(() => readSkills(resolvedFor(t, instance).skills, REPO_SKILLS), instance);
|
|
}
|
|
});
|
|
|
|
// Mos's check (mos-0857): the adapter hands the harness the prompt with the skills in it, and
|
|
// discovery stays off. A stand-in for each harness's CLI records the adapter's argv, NUL-separated.
|
|
// The chief of staff lists no skill: its prompt carries none and discovery is off all the same.
|
|
test("each adapter passes the skills only in the prompt file, with skill discovery off", (t) => {
|
|
for (const [instance, harness] of [["reviewer", "pi"], ["reviewer", "claude-code"], ["chief", "pi"], ["chief", "claude-code"]]) {
|
|
const dir = scratch(t);
|
|
const workspace = join(dir, "ws");
|
|
mkdirSync(workspace);
|
|
mkdirSync(join(dir, "bin"));
|
|
const cli = join(dir, "bin", harness === "pi" ? "pi" : "claude");
|
|
writeFileSync(cli, '#!/bin/sh\nprintf "%s\\0" "$@" > "$ARGV_OUT"\necho \'{"type":"result","subtype":"success","result":"ok"}\'\n');
|
|
chmodSync(cli, 0o755);
|
|
const resolved = resolvedFor(t, instance, (d) => (d.roles[instance].vars = { harness, model: "claude-opus-5-5", thinking: "high" }));
|
|
assert.equal(resolved.skills.length > 0, instance === "reviewer", instance);
|
|
const s = { harness, provider: harness === "pi" ? "probe" : null, model: "claude-opus-5-5", workspace, sessionDir: join(dir, "session"), toolSocket: join(dir, "tools.sock"), turnMarker: join(dir, "turn.done") };
|
|
const models = { "claude-opus-5-5": { provider: "probe", model: "claude-opus-5-5" } };
|
|
const b = buildBundle({ dir: join(dir, "bundle"), resolved, contract: "# Contract\n", harness: HARNESS[harness], models, skillsDir: REPO_SKILLS, session: { ...s, run: "run-1", launchedBy: "pm" } });
|
|
s.bundle = b.files;
|
|
s.harnessEnv = b.env;
|
|
const env = { ...adapterEnv(s, { PATH: `${join(dir, "bin")}:/usr/bin:/bin`, HOME: join(dir, "home") }), ARGV_OUT: join(dir, "argv"), MOSAIC_REQUEST: "x" };
|
|
const r = spawnSync("/bin/sh", [join(REPO, "adapters", harness === "pi" ? "pi" : "claude", "adapter.sh")], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], env });
|
|
assert.equal(r.status, 0, `${harness}: ${r.stderr}`);
|
|
const argv = readFileSync(join(dir, "argv"), "utf8").split("\0");
|
|
// Row 213 (C2-08): the adapter passes the prompt file's path, pi as --system-prompt <path> and
|
|
// claude as --system-prompt-file <path>, so the prompt never sits in argv.
|
|
const flag = harness === "pi" ? "--system-prompt" : "--system-prompt-file";
|
|
const i = argv.indexOf(flag);
|
|
assert.ok(i >= 0 && argv.indexOf(flag, i + 1) < 0, harness);
|
|
assert.equal(argv[i + 1], b.files.prompt, harness);
|
|
const prompt = readFileSync(argv[i + 1], "utf8");
|
|
const marked = [...prompt.matchAll(MARKED)];
|
|
assert.deepEqual([...new Set(marked.map((m) => m[1]))], resolved.skills, harness);
|
|
if (instance === "chief") assert.doesNotMatch(prompt, /## Skills|<skill-file/, harness);
|
|
for (const [, name, rel, text] of marked) assert.equal(text, readFileSync(join(dir, "bundle", "skills", name, rel), "utf8"), `${harness} ${name}/${rel}`);
|
|
// No skill reaches the harness any other way.
|
|
const rest = argv.filter((a, k) => k !== i + 1);
|
|
assert.ok(!rest.some((a) => a.includes(join(dir, "bundle", "skills")) || a.includes(REPO_SKILLS) || /SKILL\.md|<skill-file/.test(a)), `${harness}: ${rest.join(" ")}`);
|
|
if (harness === "pi") {
|
|
assert.ok(argv.includes("--no-skills"), argv.join(" "));
|
|
assert.ok(!argv.includes("--skill"), argv.join(" "));
|
|
} else {
|
|
assert.ok(argv.includes("--disable-slash-commands"), argv.join(" "));
|
|
assert.ok(argv.includes("--restricted"), argv.join(" "));
|
|
}
|
|
}
|
|
});
|