Files
stack/packages/harness/tests/skills.test.mjs

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(" "));
}
}
});