// Row 64 (#1557): a role's skills reach the session as prompt text. The bundle copies each // skill on the role's list from /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 = /\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| { 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\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 and // claude as --system-prompt-file , 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| k !== i + 1); assert.ok(!rest.some((a) => a.includes(join(dir, "bundle", "skills")) || a.includes(REPO_SKILLS) || /SKILL\.md|