The SP1 listing test also requires a nonempty shipped listing, and a new SP2 case pins that installing a plug-in named after any shipped pack refuses core-name, so an installed pack cannot replace one.
253 lines
14 KiB
JavaScript
253 lines
14 KiB
JavaScript
// #1873 SP2: the five shipped starter packs. Design: starter-packs-design section 7 (contents) and
|
|
// SP2 in section 8 (acceptance). Each pack loads through SP1's reader with origin shipped, holds
|
|
// what section 7 lists, pins every template at today's templateHash, and records in its
|
|
// expected-lines.json the flagged and credential lines each proposed hire carries against the chief.
|
|
import test from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import { randomUUID } from "node:crypto";
|
|
import { appendFileSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
import { dirname, join, resolve } from "node:path";
|
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
import { hasModule, loadInstalled } from "../src/loader.mjs";
|
|
import { checkPack, installedStarterPacks } from "../src/starter-pack.mjs";
|
|
import { installPlugin } from "../src/install.mjs";
|
|
import { pluginHash } from "../src/pinned.mjs";
|
|
import { compositionOf, entryFromTemplate, readTemplate, templateHash } from "../../organization/src/definitions.mjs";
|
|
import { loadRole } from "../../organization/src/role.mjs";
|
|
import { buildRegistry, checkComposition } from "../../organization/src/composition.mjs";
|
|
import { credentialLines, hireDecisions } from "../../organization/src/hire-decisions.mjs";
|
|
import { organizationWriter } from "../../organization/src/organization.mjs";
|
|
import { businessDoc, rolesCopy, tmp, writeJson } from "../../organization/tests/helpers.mjs";
|
|
|
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
const REPO = resolve(HERE, "../../..");
|
|
const ROLES = join(REPO, "roles");
|
|
const PACK_DIR = join(REPO, "packages/plugins/starter-pack");
|
|
const CHIEF = "chief-of-staff";
|
|
const OFFICERS = ["chief-executive-officer", "chief-financial-officer", "chief-information-officer", "chief-information-security-officer"];
|
|
|
|
// Section 7, item by item. Columns are labels in the order todo, in-progress, in-review, blocked,
|
|
// done. Each workflow is its step roles in order.
|
|
const SECTION_7 = {
|
|
coding: {
|
|
definitions: ["coder", "reviewer", "security-reviewer", "pm", "merge-gate", "cto"],
|
|
hires: [["pm-1", "pm"], ["coder-1", "coder"], ["reviewer-1", "reviewer"]],
|
|
columns: ["Backlog", "Building", "Review", "Blocked", "Merged"], layout: "kanban",
|
|
workflows: { change: [["pm"], ["coder"], ["reviewer"], ["human", "merge-gate"]] },
|
|
skills: ["ms-sdlc", "ms-unslop", "ms-file-read", "ms-file-write", "ms-goal", "ms-communications"],
|
|
},
|
|
"creative-writing": {
|
|
definitions: ["ideas", "research", "proofreader", "fact-checker", "continuity", "companion"],
|
|
hires: [["ideas-1", "ideas"], ["proofreader-1", "proofreader"], ["continuity-1", "continuity"]],
|
|
columns: ["Ideas", "Drafting", "Editing", "On hold", "Finished"], layout: "kanban",
|
|
workflows: { chapter: [["ideas"], ["human", "companion"], ["continuity"], ["proofreader"]] },
|
|
skills: ["ms-unslop"],
|
|
},
|
|
marketing: {
|
|
definitions: ["chief-marketing-officer", "research", "ideas", "proofreader", "fact-checker"],
|
|
hires: [["cmo", "chief-marketing-officer"], ["research-1", "research"], ["proofreader-1", "proofreader"]],
|
|
columns: ["Ideas", "In progress", "Approval", "Blocked", "Live"], layout: "kanban",
|
|
workflows: { campaign: [["research"], ["ideas"], ["fact-checker"], ["proofreader"], ["human"]] },
|
|
skills: ["ms-unslop"],
|
|
},
|
|
montage: {
|
|
definitions: ["ideas", "research", "proofreader", "reviewer"],
|
|
hires: [["ideas-1", "ideas"], ["research-1", "research"]],
|
|
columns: ["Concept", "Script", "Review", "Blocked", "Final"], layout: "kanban",
|
|
workflows: { episode: [["research"], ["ideas"], ["proofreader"], ["reviewer"]] },
|
|
skills: ["ms-unslop"],
|
|
},
|
|
"business-management": {
|
|
definitions: [...OFFICERS, "pm", "research", "continuity"],
|
|
hires: [["pm-1", "pm"], ["research-1", "research"]],
|
|
columns: ["To do", "Doing", "Waiting on review", "Blocked", "Done"], layout: "list",
|
|
workflows: { "decision-brief": [["research"], ["pm"], OFFICERS, ["human"]] },
|
|
skills: ["ms-unslop", "ms-goal", "ms-communications"],
|
|
},
|
|
};
|
|
const IDS = Object.keys(SECTION_7);
|
|
const STATES = ["todo", "in-progress", "in-review", "blocked", "done"];
|
|
|
|
const moduleOf = (id) => import(pathToFileURL(join(PACK_DIR, id, "index.mjs")).href);
|
|
const expectedLines = (id) => JSON.parse(readFileSync(join(PACK_DIR, id, "expected-lines.json"), "utf8"));
|
|
|
|
// The lines one template carries against the chief, as the broker computes them for a hire
|
|
// (hire-decisions.mjs prepare): checkComposition's flagged lines and every credential line.
|
|
function linesFor(rolesDir, template) {
|
|
const chief = loadRole(rolesDir, CHIEF);
|
|
const { composition, flagged } = checkComposition(compositionOf(entryFromTemplate(readTemplate(rolesDir, template))),
|
|
buildRegistry(), chief, { assertClean() {} });
|
|
return { flagged: JSON.parse(JSON.stringify(flagged)), credentialLines: credentialLines(composition.credentials) };
|
|
}
|
|
|
|
// Every proposal of one pack against its expected-lines.json, under rolesDir.
|
|
async function checkLines(id, rolesDir) {
|
|
const { starterPack } = await moduleOf(id);
|
|
const pack = starterPack();
|
|
const expected = expectedLines(id);
|
|
assert.deepEqual(Object.keys(expected), ["chief", "hires"], id);
|
|
assert.deepEqual(expected.chief, { template: CHIEF, sha256: templateHash(rolesDir, CHIEF) }, `${id}: the chief moved; recompute expected-lines.json`);
|
|
assert.deepEqual(Object.keys(expected.hires), pack.hires.map((h) => h.name), id);
|
|
for (const hire of pack.hires) {
|
|
const want = expected.hires[hire.name];
|
|
assert.equal(want.definition, hire.definition, `${id} ${hire.name}`);
|
|
assert.deepEqual({ definition: want.definition, ...linesFor(rolesDir, pack.definitions[hire.definition].template) }, want, `${id} ${hire.name}`);
|
|
}
|
|
}
|
|
|
|
// Red before: on BASE no starter-pack module exists.
|
|
test("SP2 loading starter-pack/coding finds its module, and the loader finds all five", async () => {
|
|
assert.equal(hasModule("starter-pack", "coding"), true, "loading starter-pack/coding finds no module");
|
|
const { modules, refused } = await loadInstalled("starter-pack");
|
|
assert.deepEqual(refused, []);
|
|
assert.deepEqual(modules.map((m) => m.name), [...IDS].sort());
|
|
});
|
|
|
|
test("SP2 each shipped pack loads through SP1 with origin shipped", async () => {
|
|
const { packs, refused } = await installedStarterPacks({ rolesDir: ROLES });
|
|
assert.deepEqual(refused, []);
|
|
assert.deepEqual(packs.map((p) => [p.id, p.origin]), [...IDS].sort().map((id) => [id, "shipped"]));
|
|
for (const { id, pack } of packs) {
|
|
const { starterPack } = await moduleOf(id);
|
|
assert.deepEqual(pack, starterPack(), id);
|
|
assert.deepEqual(checkPack(starterPack(), { origin: "shipped", rolesDir: ROLES, name: id }), pack, id);
|
|
}
|
|
});
|
|
|
|
test("SP2 each pack holds what section 7 lists", async () => {
|
|
for (const id of IDS) {
|
|
const want = SECTION_7[id];
|
|
const pack = (await moduleOf(id)).starterPack();
|
|
assert.equal(pack.id, id);
|
|
assert.equal(pack.scope, "org", id);
|
|
assert.deepEqual(Object.keys(pack.definitions), want.definitions, id);
|
|
assert.deepEqual(pack.hires.map((h) => [h.name, h.definition]), want.hires, id);
|
|
assert.deepEqual(pack.board.columns.map((c) => c.state), STATES, id);
|
|
assert.deepEqual(pack.board.columns.map((c) => c.label), want.columns, id);
|
|
assert.equal(pack.board.layout, want.layout, id);
|
|
assert.deepEqual(Object.keys(pack.workflows), Object.keys(want.workflows), id);
|
|
for (const [name, roles] of Object.entries(want.workflows)) {
|
|
assert.deepEqual(pack.workflows[name].steps.map((s) => s.roles), roles, `${id} ${name}`);
|
|
// Every step role is a definition of this pack or the user, so SP3's applyPack always
|
|
// resolves it once the pack's definitions are in the organization.
|
|
for (const step of pack.workflows[name].steps) {
|
|
for (const role of step.roles) assert.ok(role === "human" || Object.hasOwn(pack.definitions, role), `${id} ${name} ${step.id}`);
|
|
}
|
|
}
|
|
assert.deepEqual(pack.skills, want.skills, id);
|
|
}
|
|
});
|
|
|
|
test("SP2 every definition is a whole template pinned at today's templateHash", async () => {
|
|
for (const id of IDS) {
|
|
const pack = (await moduleOf(id)).starterPack();
|
|
for (const [name, entry] of Object.entries(pack.definitions)) {
|
|
assert.deepEqual(entry, { template: name, sha256: templateHash(ROLES, name) }, `${id} ${name}`);
|
|
}
|
|
}
|
|
});
|
|
|
|
test("SP2 starterPack returns a fresh document on every call", async () => {
|
|
for (const id of IDS) {
|
|
const { starterPack } = await moduleOf(id);
|
|
const first = starterPack();
|
|
first.hires.length = 0;
|
|
first.definitions.extra = { template: "coder", sha256: "0".repeat(64) };
|
|
assert.notEqual(starterPack().hires.length, 0, id);
|
|
assert.equal(Object.hasOwn(starterPack().definitions, "extra"), false, id);
|
|
}
|
|
});
|
|
|
|
test("SP2 each proposed hire's lines against the chief equal expected-lines.json", async () => {
|
|
for (const id of IDS) await checkLines(id, ROLES);
|
|
});
|
|
|
|
test("SP2 chief-marketing-officer is the only proposal with no lines", async () => {
|
|
const empty = new Set(), carrying = new Set();
|
|
for (const id of IDS) {
|
|
const { hires } = expectedLines(id);
|
|
for (const { definition, flagged, credentialLines: lines } of Object.values(hires)) {
|
|
(flagged.length === 0 && lines.length === 0 ? empty : carrying).add(definition);
|
|
}
|
|
}
|
|
assert.deepEqual([...empty], ["chief-marketing-officer"]);
|
|
assert.deepEqual([...carrying].sort(), ["coder", "continuity", "ideas", "pm", "proofreader", "research", "reviewer"]);
|
|
});
|
|
|
|
// The broker's own hire proposal (hire-decisions.mjs prepare) in an organization whose chief is
|
|
// roles/chief-of-staff.json computes the same lists, messaging lines included.
|
|
test("SP2 the broker's hire proposal computes the lines expected-lines.json holds", async (t) => {
|
|
const root = tmp();
|
|
t.after(() => rmSync(root, { recursive: true, force: true }));
|
|
const rolesDir = rolesCopy(root);
|
|
const doc = businessDoc(root);
|
|
doc.plugins = { harness: ["pi"] };
|
|
assert.equal(doc.roles[doc.launch.by].definition, CHIEF);
|
|
writeJson(join(root, "businesses/acme.json"), doc);
|
|
const writer = organizationWriter({ dir: root, rolesDir });
|
|
const service = hireDecisions({ writer, rolesDir, configDir: root, dataRoot: join(root, "data"), assertClean: () => {} });
|
|
const seen = new Set();
|
|
for (const id of IDS) {
|
|
const pack = (await moduleOf(id)).starterPack();
|
|
const expected = expectedLines(id);
|
|
for (const hire of pack.hires) {
|
|
const ref = pack.definitions[hire.definition];
|
|
const p = await service.prepare("acme", {
|
|
op: "hire", level: "org", name: `sp2-${seen.size}`, role: { template: ref.template, sha256: ref.sha256 },
|
|
route: { harness: "pi", model: "claude-opus-5-5" }, reason: hire.reason,
|
|
}, { id: randomUUID(), expiresAt: "2099-01-01T00:00:00Z" });
|
|
seen.add(`${id}/${hire.name}`);
|
|
const want = expected.hires[hire.name];
|
|
assert.deepEqual(JSON.parse(JSON.stringify({ flagged: p.flagged, credentialLines: p.credentialLines })),
|
|
{ flagged: want.flagged, credentialLines: want.credentialLines }, `${id} ${hire.name}`);
|
|
}
|
|
}
|
|
assert.equal(seen.size, 13);
|
|
});
|
|
|
|
// A template edit that moves a hash turns the shipped listing red until the pack is re-pinned,
|
|
// which is the intended review point. A chief edit turns the expected lines red the same way.
|
|
test("SP2 a template edit refuses the packs that pin it until they are re-pinned", async (t) => {
|
|
const root = tmp();
|
|
t.after(() => rmSync(root, { recursive: true, force: true }));
|
|
const rolesDir = rolesCopy(root);
|
|
appendFileSync(join(rolesDir, "coder.md"), "\nOne more line.\n");
|
|
assert.notEqual(templateHash(rolesDir, "coder"), templateHash(ROLES, "coder"));
|
|
await assert.rejects(installedStarterPacks({ rolesDir }), (e) => {
|
|
assert.equal(e?.name, "PluginError");
|
|
assert.equal(e.code, "starter-pack-template-changed");
|
|
assert.equal(e.plugin, "coding");
|
|
return true;
|
|
});
|
|
for (const id of IDS) {
|
|
const doc = (await moduleOf(id)).starterPack();
|
|
if (Object.hasOwn(doc.definitions, "coder")) {
|
|
assert.throws(() => checkPack(doc, { origin: "shipped", rolesDir, name: id }), (e) => e.code === "starter-pack-template-changed");
|
|
} else {
|
|
checkPack(doc, { origin: "shipped", rolesDir, name: id });
|
|
}
|
|
}
|
|
appendFileSync(join(rolesDir, `${CHIEF}.md`), "\nOne more line.\n");
|
|
await assert.rejects(checkLines("coding", rolesDir), /the chief moved/);
|
|
});
|
|
|
|
// An installed plug-in cannot take a shipped pack's name, so it cannot replace a reviewed pack.
|
|
test("SP2 installing a plug-in named after a shipped pack refuses core-name", async (t) => {
|
|
const root = tmp();
|
|
t.after(() => rmSync(root, { recursive: true, force: true }));
|
|
for (const id of IDS) {
|
|
const src = join(root, `src-${id}`), dataRoot = join(root, `data-${id}`);
|
|
mkdirSync(join(src, "starter-pack"), { recursive: true });
|
|
mkdirSync(dataRoot);
|
|
const doc = (await moduleOf(id)).starterPack();
|
|
writeFileSync(join(src, "starter-pack", "index.mjs"), `export const interfaceVersion = 1;\nexport function starterPack() { return ${JSON.stringify(doc)}; }\n`);
|
|
const manifest = { name: id, version: "1.0.0", kinds: ["starter-pack"], interfaceVersions: { "starter-pack": 1 }, sha256: "0".repeat(64),
|
|
source: { path: "fixture", version: "1.0.0" }, signature: null, tools: [], actions: [], services: [], roleTemplates: [] };
|
|
writeFileSync(join(src, "plugin.json"), JSON.stringify(manifest));
|
|
manifest.sha256 = pluginHash(src);
|
|
writeFileSync(join(src, "plugin.json"), JSON.stringify(manifest));
|
|
assert.throws(() => installPlugin({ dataRoot, source: src, expectedHash: manifest.sha256 }),
|
|
(e) => e?.name === "PluginError" && e.code === "core-name", id);
|
|
}
|
|
});
|