roles: add the assistant role, move role.launch from the PM to the chief of staff (row 119, #1613)

roles/assistant.json and assistant.md are new. The assistant holds only
message.send and relays each ruling verbatim to the chief of staff. The
chief of staff gains role.launch; the PM drops it. Test fixtures move
launch.by from "pm" to a `chief` instance.

This does not change the live host. Jason's organization file names
launch.by "pm", which the new role files refuse, so his edit and the
re-pin happen together in one Mos window (decision 144; the order is in
agents/researcher/work/queue-119/build.md).

Lands Researcher's round 2 candidate, approved by Filbert in comment
28528 (rev 1240). Candidate manifest f77c4917, all 22 files match.
Landing gate on 46117feb8: 23 suites, GATE_PASSED 01:09:35Z.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-14 20:10:11 -05:00
co-authored by Claude Opus 5.5
parent 32221d90d0
commit 94e41ae29e
22 changed files with 494 additions and 186 deletions
+37 -14
View File
@@ -94,26 +94,49 @@ instance whose definition holds `role.launch` within-role; otherwise the
file refuses. Only that instance keeps `role.launch`. Every other instance
loses it, so for them it's gated.
### Chief-of-staff instance
### Chief-of-staff and assistant instances
Two roles stand above the PM (PRD 1.2; row 119, #1613).
The `assistant` role (`roles/assistant.json`, contract
`roles/assistant.md`, REQ-ASSIST-1) is the user's main agent: one per user
per organization. It talks with the user and passes work to the chief of
staff. It holds `message.send` within role and nothing else: no
credentials, no crossRole action, read-only tools. It doesn't direct
agents, create or assign tasks, or launch sessions. The broker's send
limits for it are row 120 (REQ-ASSIST-2).
The `chief-of-staff` role (`roles/chief-of-staff.json`, contract
`roles/chief-of-staff.md`, lead decision 104) is the main agent that
carries Jason's requests to the PM. It holds `message.send` within role
and nothing else: no write tool, no credentials, no crossRole action, and
no `launch` block. Its instance is `mos` (decision 116). Jason adds this
entry under `roles` in his business file, next to the other instances. The
stack never writes that file.
`roles/chief-of-staff.md`, REQ-COS-1) runs the organization: it hires and
terminates role instances, owns the structure, takes work from the
assistant and never talks to the user. It holds `message.send` and
`role.launch` within role (decision 139), with no credentials and the same
read-only tools. It is the only shipped role that holds `role.launch`, so
it is the only role `launch.by` can name. The PM holds no `role.launch`
(REQ-LAUNCH-1); it asks the chief of staff for a session.
The owner adds both instances under `roles` in the business file, next to
the others, and points `launch.by` at the chief of staff. The stack never
writes that file. The instance names are the owner's choice; these are the
example file's:
```json
"mos": { "definition": "chief-of-staff", "holder": "mos" }
"chief": { "definition": "chief-of-staff" },
"assistant": { "definition": "assistant", "holder": "mos" }
...
"launch": { "by": "chief", "instances": ["coder", "reviewer"], "max": { "opus": 4, "sonnet": 4 } }
```
The entry has no `tracker`, because the role uses no Vikunja, and no
`credentials`, because it holds none. The file must not name `mos` as the
`launch.by` instance: the loader refuses that, since the role has no
`role.launch`. `packages/business/tests/chief-of-staff.test.mjs` checks the
role against decision 104's list and loads it through `loadBusiness` from a
fixture business under the name `chief`.
Neither entry has a `tracker` or `credentials`, because neither role holds
any. A session that launches needs a harness and a model, so give each
instance `vars` (`harness`, `model`) as the PM has, unless the system's
execution settings cover it. A `launch.by` that names a PM, an assistant or any other instance
whose role lacks `role.launch` is refused. A file that still names the PM
fails to load with the shipped roles, so edit the file before a copy with
these roles goes live. `packages/business/tests/chief-of-staff.test.mjs` and
`assistant.test.mjs` check each role against its approved list and load it
through `loadBusiness`; `role.test.mjs` loads every file in `roles/`
through the validator.
### Project file
@@ -98,10 +98,17 @@
"expires": "YYYY-MM-DD"
}
}
},
"chief": {
"definition": "chief-of-staff"
},
"assistant": {
"definition": "assistant",
"holder": "mos"
}
},
"launch": {
"by": "pm",
"by": "chief",
"instances": [
"coder",
"reviewer"
+120
View File
@@ -0,0 +1,120 @@
// The assistant role (REQ-ASSIST-1, row 119). The role file is policy: it
// holds message.send within role and nothing else, no credentials, and tools
// as read-only as the chief of staff's. A widened file fails here.
import { test } from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { ACTIONS, BusinessError, classify, loadBusiness, loadRole, resolveInstance } from "../src/index.mjs";
import { businessDoc, REPO_ROLES, systemFor, tmp, writeJson } from "./helpers.mjs";
const APPROVED = Object.freeze({
tools: ["read", "grep", "find", "ls"],
network: "api-only",
authority: Object.freeze({ withinRole: Object.freeze(["message.send"]), crossRole: Object.freeze([]) }),
credentials: Object.freeze([]),
});
const INSTANCE = "assistant";
function refuses(fn, pattern) {
assert.throws(fn, (error) => {
assert.ok(error instanceof BusinessError, `expected BusinessError, got ${error}`);
assert.equal(error.exitCode, 2);
assert.match(error.message, pattern);
return true;
});
}
function loadFixture(mutate = () => {}) {
const root = tmp();
const doc = businessDoc(root, "acme");
doc.roles[INSTANCE] = { definition: "assistant", holder: "mos" };
mutate(doc);
writeJson(join(root, "businesses", "acme.json"), doc);
return { root, business: loadBusiness("acme", { dir: root, rolesDir: REPO_ROLES }) };
}
function resolved({ root, business }) {
return resolveInstance({ system: systemFor(root), business, instance: INSTANCE });
}
test("the role file loads and resolves through loadBusiness", () => {
const role = loadRole(REPO_ROLES, "assistant");
assert.equal(role.roleVersion, 2);
assert.equal(role.name, "assistant");
assert.equal(role.contract, "assistant.md");
assert.equal(role.contractPath, join(REPO_ROLES, "assistant.md"));
const record = resolved(loadFixture());
assert.equal(record.definition, "assistant");
assert.equal(record.holder, "mos");
assert.equal(record.contract, join(REPO_ROLES, "assistant.md"));
assert.equal(record.launch, null);
assert.equal(record.tracker, null);
});
test("the shipped file is exactly the approved list", () => {
const raw = JSON.parse(readFileSync(join(REPO_ROLES, "assistant.json"), "utf8"));
const { title, ...rest } = raw;
assert.equal(typeof title, "string");
assert.deepEqual(rest, { roleVersion: 2, name: "assistant", contract: "assistant.md", ...APPROVED });
});
test("the assistant's authority is message.send within role and nothing else", () => {
const role = loadRole(REPO_ROLES, "assistant");
assert.deepEqual(role.authority, { withinRole: ["message.send"], crossRole: [] });
assert.deepEqual(role.credentials, []);
const record = resolved(loadFixture());
assert.deepEqual(record.limits.authority, { withinRole: ["message.send"], crossRole: [] });
assert.deepEqual(record.credentials, {});
for (const action of ACTIONS) {
assert.equal(classify(record, action), action === "message.send" ? "within" : "gated", action);
}
for (const action of ["task.create", "task.assign", "role.launch"]) assert.equal(classify(record, action), "gated", action);
});
test("a business grant through limits does not widen the role", () => {
const record = resolved(loadFixture((doc) => {
doc.roles[INSTANCE].vars = { "limits.authority": ["message.send", "task.create", "role.launch"] };
}));
assert.deepEqual(record.limits.authority, { withinRole: ["message.send"], crossRole: [] });
assert.equal(record.launch, null);
});
test("a launch block whose by is the assistant is refused", () => {
refuses(
() => loadFixture((doc) => {
doc.launch = { by: INSTANCE, instances: ["coder"], max: { sonnet: 1 } };
}),
/launch\.by names assistant, whose role assistant doesn't hold role\.launch within-role/,
);
});
test("the instance takes no credential references and no tracker", () => {
refuses(
() => loadFixture((doc) => {
doc.roles[INSTANCE].credentials = { gitea: { file: "/x/token", rotateBy: "2099-01-01" } };
}),
/must reference exactly the services its role definition needs \(none; got gitea\)/,
);
refuses(
() => loadFixture((doc) => {
doc.roles[INSTANCE].tracker = { bot: "bot-acme-assistant", botId: 7 };
}),
/has "tracker" but assistant uses no vikunja credential/,
);
});
test("the contract passes work to the chief of staff and directs no one", () => {
const text = readFileSync(join(REPO_ROLES, "assistant.md"), "utf8");
assert.match(text, /chief of staff/);
assert.match(text, /don't direct\s+other agents/);
assert.match(text, /create or assign\s+tasks/);
assert.match(text, /launch\s+sessions/);
// Round 1 F1: rulings go to the chief of staff, never to the role they concern (REQ-ASSIST-1, REQ-ASSIST-2).
assert.match(text, /Relay each ruling the user gives verbatim to the chief\s+of\s+staff,\s+naming\s+whose\s+ruling\s+it\s+is\s+and\s+which\s+role\s+it\s+concerns/);
assert.match(text, /Don't\s+send\s+it\s+to\s+that\s+role\s+yourself/);
assert.doesNotMatch(text, /to\s+the\s+role\s+it\s+concerns/);
});
+4 -4
View File
@@ -34,12 +34,12 @@ test("config directory and file path follow MOSAIC_CONFIG", () => {
test("the fixture business validates and comes back frozen", () => {
const business = check();
assert.equal(business.id, "acme");
assert.deepEqual(Object.keys(business.roles), ["pm", "cto", "coder", "reviewer"]);
assert.deepEqual(Object.keys(business.roles), ["pm", "cto", "coder", "reviewer", "chief"]);
assert.equal(business.roles.pm.holder, "sage");
assert.equal(business.roles.reviewer.holder, null);
assert.equal(business.roles.coder.vars["limits.network"], "none");
assert.equal(business.definitions.pm.roleVersion, 2);
assert.deepEqual(business.launch, { by: "pm", instances: ["coder", "reviewer"], max: { opus: 2, sonnet: 4 } });
assert.deepEqual(business.launch, { by: "chief", instances: ["coder", "reviewer"], max: { opus: 2, sonnet: 4 } });
assert.ok(Object.isFrozen(business.roles.pm.credentials.gitea));
assert.throws(() => { business.roles.pm.holder = "x"; }, TypeError);
});
@@ -124,7 +124,7 @@ test("launch", () => {
refuses(() => check((d) => { d.launch.instances = []; }), /must not be empty/);
refuses(() => check((d) => { d.launch.instances = ["coder", "coder"]; }), /duplicate/);
refuses(() => check((d) => { d.launch.instances = ["ghost"]; }), /instances names ghost/);
refuses(() => check((d) => { d.launch.instances = ["pm"]; }), /can't include the launcher itself/);
refuses(() => check((d) => { d.launch.instances = ["chief"]; }), /can't include the launcher itself/);
refuses(() => check((d) => { d.launch.max = {}; }), /at least one model family/);
refuses(() => check((d) => { d.launch.max = { glm: 1 }; }), /unknown model family "glm"/);
refuses(() => check((d) => { d.launch.max = { opus: 5 }; }), /from 0 to 4/);
@@ -214,7 +214,7 @@ test("the shipped example refuses as written and validates once filled in", () =
.replaceAll("YYYY-MM-DD", "2099-01-01")
.replaceAll("/home/you", root));
const business = validateBusinessDocument(filled, file, { rolesDir: REPO_ROLES });
assert.deepEqual(Object.keys(business.roles), ["pm", "cto", "coder", "reviewer"]);
assert.deepEqual(Object.keys(business.roles), ["pm", "cto", "coder", "reviewer", "chief", "assistant"]);
assert.equal(business.tracker.sync.credentials.vikunja.file, join(root, ".config/mosaic-dev/secrets/mosaic-stack/sync-vikunja.token"));
assert.equal(id, 15);
});
+55 -33
View File
@@ -1,7 +1,8 @@
// The chief-of-staff role (lead decision 104, M1, row 84). The role file is
// policy: its tools, network, authority and credentials must equal the list
// Jason approved, so a widened file fails here. Fixtures live in a temp
// directory and nothing reads the real config.
// The chief-of-staff role (lead decision 104, M1, row 84; role.launch added by
// decision 139, row 119). The role file is policy: its tools, network,
// authority and credentials must equal the list approved, so a widened file
// fails here. Fixtures live in a temp directory and nothing reads the real
// config.
import { test } from "node:test";
import assert from "node:assert/strict";
@@ -10,15 +11,16 @@ import { join } from "node:path";
import { ACTIONS, BusinessError, classify, loadBusiness, loadRole, resolveInstance } from "../src/index.mjs";
import { businessDoc, REPO_ROLES, systemFor, tmp, writeJson } from "./helpers.mjs";
// Decision 104's list, as approved. A role file that differs from it fails.
// Decision 104's list plus role.launch (decision 139). A role file that
// differs from it fails.
const APPROVED = Object.freeze({
tools: ["read", "grep", "find", "ls"],
network: "api-only",
authority: Object.freeze({ withinRole: Object.freeze(["message.send"]), crossRole: Object.freeze([]) }),
authority: Object.freeze({ withinRole: Object.freeze(["message.send", "role.launch"]), crossRole: Object.freeze([]) }),
credentials: Object.freeze([]),
});
// A fixture instance name. The shipped entry for Jason's business is `mos`.
// The fixture business's chief-of-staff instance, which is also its launcher.
const INSTANCE = "chief";
function refuses(fn, pattern) {
@@ -31,19 +33,17 @@ function refuses(fn, pattern) {
}
// Write a business file to a temp config directory and load it with
// loadBusiness. The file is the shipped four roles from businessDoc(), plus
// one chief-of-staff instance, with `mutate` applied after both.
// loadBusiness. The file is businessDoc() with `mutate` applied after it.
function loadFixture(mutate = () => {}) {
const root = tmp();
const doc = businessDoc(root, "acme");
doc.roles[INSTANCE] = { definition: "chief-of-staff", holder: "mos" };
mutate(doc);
writeJson(join(root, "businesses", "acme.json"), doc);
return { root, business: loadBusiness("acme", { dir: root, rolesDir: REPO_ROLES }) };
}
function resolved({ root, business }) {
return resolveInstance({ system: systemFor(root), business, instance: INSTANCE });
function resolved({ root, business }, instance = INSTANCE) {
return resolveInstance({ system: systemFor(root), business, instance });
}
test("the role file loads and resolves through loadBusiness", () => {
@@ -56,13 +56,12 @@ test("the role file loads and resolves through loadBusiness", () => {
const record = resolved(fixture);
assert.equal(record.definition, "chief-of-staff");
assert.equal(record.holder, "mos");
assert.equal(record.contract, join(REPO_ROLES, "chief-of-staff.md"));
assert.equal(record.launch, null);
assert.equal(record.tracker, null);
assert.deepEqual(record.launch, { by: INSTANCE, instances: ["coder", "reviewer"], max: { opus: 2, sonnet: 4 } });
});
test("the shipped file is exactly decision 104's list", () => {
test("the shipped file is exactly the approved list", () => {
const raw = JSON.parse(readFileSync(join(REPO_ROLES, "chief-of-staff.json"), "utf8"));
const { title, ...rest } = raw;
assert.equal(typeof title, "string");
@@ -74,7 +73,7 @@ test("the shipped file is exactly decision 104's list", () => {
});
});
test("the resolved record matches decision 104, so a widened file fails", () => {
test("the resolved record matches the approved list, so a widened file fails", () => {
const record = resolved(loadFixture());
assert.deepEqual(record.limits, {
tools: APPROVED.tools,
@@ -84,39 +83,62 @@ test("the resolved record matches decision 104, so a widened file fails", () =>
assert.deepEqual(record.credentials, {});
});
test("message.send is within role and every other action is gated", () => {
test("the chief of staff holds role.launch and message.send; every other action is gated", () => {
const record = resolved(loadFixture());
for (const action of ACTIONS) {
assert.equal(classify(record, action), action === "message.send" ? "within" : "gated", action);
assert.equal(classify(record, action), action === "message.send" || action === "role.launch" ? "within" : "gated", action);
}
assert.equal(classify(record, "message.send"), "within");
assert.equal(classify(record, "task.create"), "gated");
assert.equal(classify(record, "role.launch"), "gated");
});
test("a business grant of a gated action through limits does not widen the role", () => {
// limits.authority only narrows, so the grant has no effect and both
// actions stay gated. limits only narrow (resolve.mjs), so the grant has
// no effect; this asserts that, rather than a refusal.
const fixture = loadFixture((doc) => {
doc.roles[INSTANCE].vars = { "limits.authority": ["message.send", "task.create", "role.launch"] };
});
const record = resolved(fixture);
assert.deepEqual(record.limits.authority, { withinRole: ["message.send"], crossRole: [] });
assert.equal(classify(record, "task.create"), "gated");
test("the pm does not hold role.launch", () => {
const role = loadRole(REPO_ROLES, "pm");
assert.ok(!role.authority.withinRole.includes("role.launch"));
assert.ok(!role.authority.crossRole.includes("role.launch"));
const record = resolved(loadFixture(), "pm");
assert.equal(classify(record, "role.launch"), "gated");
assert.equal(record.launch, null);
});
test("a launch block whose by is the chief-of-staff instance is refused", () => {
test("a business grant of a gated action through limits does not widen the role", () => {
// limits.authority only narrows (resolve.mjs), so the grant of task.create
// has no effect and the role keeps exactly its two actions.
const fixture = loadFixture((doc) => {
doc.roles[INSTANCE].vars = { "limits.authority": ["message.send", "task.create", "role.launch"] };
});
const record = resolved(fixture);
assert.deepEqual(record.limits.authority, { withinRole: ["message.send", "role.launch"], crossRole: [] });
assert.equal(classify(record, "task.create"), "gated");
assert.equal(classify(record, "role.launch"), "within");
});
test("a limit that drops role.launch leaves the chief of staff with no launch block", () => {
const record = resolved(loadFixture((doc) => {
doc.roles[INSTANCE].vars = { "limits.authority": ["message.send"] };
}));
assert.equal(classify(record, "role.launch"), "gated");
assert.equal(record.launch, null);
});
test("a launch block whose by is the pm instance is refused", () => {
refuses(
() => loadFixture((doc) => {
doc.launch = { by: INSTANCE, instances: ["coder"], max: { sonnet: 1 } };
doc.launch = { by: "pm", instances: ["coder"], max: { sonnet: 1 } };
}),
/launch\.by names chief, whose role chief-of-staff doesn't hold role\.launch within-role/,
/launch\.by names pm, whose role pm doesn't hold role\.launch within-role/,
);
});
test("the contract names no duty to talk to the human and the pm contract no launch", () => {
const chief = readFileSync(join(REPO_ROLES, "chief-of-staff.md"), "utf8");
assert.doesNotMatch(chief, /main (agent|point of interaction)/i);
assert.doesNotMatch(chief, /\bJason\b/);
assert.match(chief, /never talk to the user/);
const pm = readFileSync(join(REPO_ROLES, "pm.md"), "utf8");
assert.doesNotMatch(pm, /^- Launch /m);
assert.match(pm, /You don't launch sessions/);
});
test("the instance takes no credential references and no tracker", () => {
refuses(
() => loadFixture((doc) => {
+6 -4
View File
@@ -44,7 +44,7 @@ test("validate: a good business exits 0 and prints instance digests", () => {
const out = JSON.parse(r.out);
assert.equal(out.business, "acme");
assert.deepEqual(out.projects, { stack: "absent" });
assert.deepEqual(Object.keys(out.instances), ["pm", "cto", "coder", "reviewer"]);
assert.deepEqual(Object.keys(out.instances), ["pm", "cto", "coder", "reviewer", "chief"]);
for (const digest of Object.values(out.instances)) assert.match(digest, /^[0-9a-f]{64}$/);
assert.match(r.err, /warning: project stack: no project file/);
assert.doesNotMatch(r.out + r.err, /placeholder-not-a-token/);
@@ -138,11 +138,13 @@ test("resolve: prints one instance's record", () => {
assert.equal(coder.contract, join(s.env.MOSAIC_ROLES_DIR, "coder.md"));
assert.doesNotMatch(r.out, /placeholder-not-a-token/);
const chief = JSON.parse(run(s, "resolve", "acme", "chief").out);
assert.equal(chief.project, null);
assert.ok(chief.limits.authority.withinRole.includes("role.launch"));
const pm = JSON.parse(run(s, "resolve", "acme", "pm").out);
assert.equal(pm.project, null);
assert.ok(pm.limits.authority.withinRole.includes("role.launch"));
assert.ok(!pm.limits.authority.withinRole.includes("role.launch"));
const validated = JSON.parse(run(s, "validate", "acme").out).instances;
assert.equal(pm.digest, validated.pm);
assert.equal(chief.digest, validated.chief);
});
test("resolve: refusals", () => {
+5 -2
View File
@@ -37,7 +37,7 @@ export function rolesCopy(root) {
return dir;
}
// A complete valid business document for the shipped four roles, with
// A complete valid business document for the shipped roles, with
// token files under `<root>/secrets`, and a project root under `<root>`.
export function businessDoc(root, id = "acme") {
const secrets = join(root, "secrets");
@@ -68,8 +68,11 @@ export function businessDoc(root, id = "acme") {
cto: role("cto", 4, { holder: "darkwing", vars: { harness: "claude-code" } }),
coder: role("coder", 5, { vars: { harness: "pi", "limits.network": "none" } }),
reviewer: role("reviewer", 6),
// The chief of staff launches (REQ-LAUNCH-1, decision 139); it holds no
// credential and no tracker bot.
chief: { definition: "chief-of-staff" },
},
launch: { by: "pm", instances: ["coder", "reviewer"], max: { opus: 2, sonnet: 4 } },
launch: { by: "chief", instances: ["coder", "reviewer"], max: { opus: 2, sonnet: 4 } },
};
}
+20 -16
View File
@@ -89,35 +89,39 @@ test("limits narrow the definition and never widen it", () => {
});
test("role.launch stays within-role only for the instance the launch block names", () => {
const pm = resolve(setup(), "pm");
assert.equal(classify(pm, "role.launch"), "within");
assert.deepEqual(pm.launch.instances, ["coder", "reviewer"]);
const chief = resolve(setup(), "chief");
assert.equal(classify(chief, "role.launch"), "within");
assert.deepEqual(chief.launch.instances, ["coder", "reviewer"]);
assert.equal(resolve(setup(), "cto").launch, null);
// The pm no longer launches (decision 139): its role file lacks the action.
const pm = resolve(setup(), "pm");
assert.equal(classify(pm, "role.launch"), "gated");
assert.equal(pm.launch, null);
const noLaunch = resolve(setup((d) => { delete d.launch; }), "pm");
const noLaunch = resolve(setup((d) => { delete d.launch; }), "chief");
assert.equal(classify(noLaunch, "role.launch"), "gated");
assert.equal(noLaunch.launch, null);
// A second pm instance is the launcher, so the first one may not launch.
// A second chief-of-staff instance is the launcher, so the first one may not launch.
const two = setup((d) => {
d.roles.pm2 = { ...d.roles.pm, holder: undefined, tracker: { bot: "bot-acme-pm2", botId: 7 } };
d.launch.by = "pm2";
d.roles.chief2 = { definition: "chief-of-staff" };
d.launch.by = "chief2";
});
assert.equal(classify(resolve(two, "pm"), "role.launch"), "gated");
assert.equal(resolve(two, "pm").launch, null);
assert.equal(classify(resolve(two, "pm2"), "role.launch"), "within");
assert.equal(resolve(two, "pm2").launch.by, "pm2");
assert.equal(classify(resolve(two, "chief"), "role.launch"), "gated");
assert.equal(resolve(two, "chief").launch, null);
assert.equal(classify(resolve(two, "chief2"), "role.launch"), "within");
assert.equal(resolve(two, "chief2").launch.by, "chief2");
});
test("limits.authority without role.launch leaves the launcher with no launch block", () => {
for (const layer of ["agent", "project"]) {
const authority = ["task.create", "task.assign", "message.send"];
const s = layer === "agent"
? setup((d) => { d.roles.pm.vars["limits.authority"] = authority; })
: setup(undefined, { roles: { pm: { vars: { "limits.authority": authority } } } });
const pm = resolve(s, "pm");
assert.equal(classify(pm, "role.launch"), "gated", layer);
assert.equal(pm.launch, null, layer);
? setup((d) => { d.roles.chief.vars = { "limits.authority": authority }; })
: setup(undefined, { roles: { chief: { vars: { "limits.authority": authority } } } });
const chief = resolve(s, "chief");
assert.equal(classify(chief, "role.launch"), "gated", layer);
assert.equal(chief.launch, null, layer);
}
});
+14 -2
View File
@@ -1,6 +1,6 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { symlinkSync, writeFileSync } from "node:fs";
import { existsSync, readdirSync, symlinkSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { loadRole, loadRoleFile, validateRoleDocument, GATED_ONLY, BusinessError } from "../src/index.mjs";
import { REPO_ROLES, tmp, writeJson } from "./helpers.mjs";
@@ -65,11 +65,23 @@ test("shipped authority follows the note's table", () => {
assert.deepEqual(auth("coder").crossRole, ["task.scope.change"]);
assert.ok(!auth("reviewer").withinRole.includes("git.push.working"));
assert.deepEqual(auth("reviewer").crossRole, []);
assert.ok(auth("pm").withinRole.includes("role.launch"));
assert.ok(auth("chief-of-staff").withinRole.includes("role.launch"));
assert.ok(!auth("pm").withinRole.includes("role.launch"));
assert.ok(auth("cto").withinRole.includes("decision.resolve.technical"));
assert.ok(!auth("pm").withinRole.includes("decision.resolve.technical"));
});
test("every role file in roles/ loads through the validator, and a v2 contract exists", () => {
// conductor-policy.json is the conductor's policy, not a role definition.
const names = readdirSync(REPO_ROLES).filter((f) => f.endsWith(".json") && f !== "conductor-policy.json").map((f) => f.slice(0, -5));
for (const required of ["assistant", "chief-of-staff", "pm"]) assert.ok(names.includes(required), required);
for (const name of names) {
const role = loadRole(REPO_ROLES, name);
assert.equal(role.name, name);
if (role.roleVersion === 2) assert.ok(existsSync(role.contractPath), `${name} contract`);
}
});
test("version 1 files keep loading with no authority", () => {
const role = loadRole(REPO_ROLES, "researcher");
assert.equal(role.roleVersion, 1);
+66 -59
View File
@@ -1,6 +1,7 @@
// The S6 launcher inside a real bus host: real broker child, real runners
// in the bwrap sandbox, the fake adapter from packages/harness standing in for the
// harness. The PM launches a coder through its `launch` tool; every host
// harness. The chief of staff launches a coder through its `launch` tool (row 119: the
// PM no longer launches, REQ-LAUNCH-1); every host
// refusal comes back over the launch socket and lands in the launch log.
import { test } from "node:test";
@@ -26,12 +27,13 @@ const FAKE = fileURLToPath(new URL("../../harness/tests/fixtures/fake-adapter.mj
const HOST = fileURLToPath(new URL("./fixtures/launch-host.mjs", import.meta.url));
const skip = sandboxProblem() ?? false;
// pm opus, coder and reviewer sonnet, one of each family; cto is listed but
// runs the system model, which is in no family.
// chief opus, coder and reviewer sonnet, one of each family; cto is listed but
// runs the system model, which is in no family. The chief of staff is the launcher.
function edit(doc) {
doc.roles.chief.vars = { harness: "pi", model: "claude-opus-5-5" };
doc.roles.coder.vars.model = "claude-sonnet-5-5";
doc.roles.reviewer.vars = { harness: "pi", model: "claude-sonnet-5-5" };
doc.launch = { by: "pm", instances: ["coder", "reviewer", "cto"], max: { opus: 1, sonnet: 1 } };
doc.launch = { by: "chief", instances: ["coder", "reviewer", "cto"], max: { opus: 1, sonnet: 1 } };
return doc;
}
@@ -127,17 +129,17 @@ function ask(path, line) {
const log = (f) => readFileSync(launchLogFile(f.dataRoot, "acme"), "utf8").trim().split("\n").map((l) => JSON.parse(l));
test("bus start --pm: the PM runs under its own PID namespace, registered, with a manifest", { skip, timeout: 30000 }, async (t) => {
test("bus start --pm: the launcher instance (the chief of staff) runs under its own PID namespace, registered, with a manifest", { skip, timeout: 30000 }, async (t) => {
const { f, launcher, host, cto, close } = await setup(t);
const sock = launchSocketPath(f.dataRoot);
assert.equal(statSync(sock).mode & 0o777, 0o600);
const pm = await launcher.launchPm();
assert.deepEqual({ ...pm, run: undefined }, { run: undefined, instance: "pm", harness: "pi", model: "claude-opus-5-5" });
assert.match(pm.run, /^r[0-9a-f]{12}$/);
const chief = await launcher.launchPm();
assert.deepEqual({ ...chief, run: undefined }, { run: undefined, instance: "chief", harness: "pi", model: "claude-opus-5-5" });
assert.match(chief.run, /^r[0-9a-f]{12}$/);
const [s] = readSessions(f.dataRoot);
assert.equal(s.run, pm.run);
assert.equal(s.run, chief.run);
assert.equal(s.live, true);
assert.equal(s.family, "opus");
assert.equal(s.launchedBy, "jason");
@@ -150,14 +152,14 @@ test("bus start --pm: the PM runs under its own PID namespace, registered, with
assert.equal(session.launchedBy, "jason");
assert.equal(session.brokerSocket, host.path);
assert.equal(session.launchSocket, sock);
// Row 93: the PM neither pushes nor reviews, so no git socket; the launcher serves one.
// Row 93: the chief of staff neither pushes nor reviews, so no git socket; the launcher serves one.
assert.equal(session.gitSocket, null);
assert.equal(statSync(gitSocketPath(f.dataRoot)).mode & 0o777, 0o600);
const manifest = JSON.parse(readFileSync(join(runDir, "bundle", "manifest.json"), "utf8"));
assert.equal(manifest.piVersion, "0.85.1");
assert.equal(manifest.claudeVersion, null);
const [launched] = log(f).filter((e) => e.event === "launch");
assert.equal(launched.run, pm.run);
assert.equal(launched.run, chief.run);
assert.equal(launched.manifest, join(runDir, "bundle", "manifest.json"));
assert.equal(manifest.model, "claude-opus-5-5");
@@ -175,7 +177,7 @@ test("bus start --pm: the PM runs under its own PID namespace, registered, with
}
};
const environ = proc(s.runnerPid, "environ").split("\0");
assert.ok(environ.includes(`MOSAIC_RUN_ID=${pm.run}`));
assert.ok(environ.includes(`MOSAIC_RUN_ID=${chief.run}`));
assert.ok(!environ.some((v) => v.startsWith("MOSAIC_CONFIG=")), "only allowlisted variables pass");
const files = (dir) => readdirSync(dir, { recursive: true, withFileTypes: true }).filter((d) => d.isFile()).map((d) => join(d.parentPath, d.name));
const capOf = cto.cap; // a known capability shape, to prove the scan finds nothing like it
@@ -195,40 +197,40 @@ test("bus start --pm: the PM runs under its own PID namespace, registered, with
assert.deepEqual(readSessions(f.dataRoot), []);
assert.equal(existsSync(sock), false);
const end = log(f).find((e) => e.event === "end");
assert.equal(end.run, pm.run);
assert.equal(end.run, chief.run);
assert.equal(end.reason, "stopped");
assert.equal(end.exitCode, 0);
});
test("the PM launches a coder through its launch tool; the coder answers; refusals name their code", { skip, timeout: 30000 }, async (t) => {
test("the chief of staff launches a coder through its launch tool; the coder answers; refusals name their code", { skip, timeout: 30000 }, async (t) => {
const { f, launcher, client, cto } = await setup(t);
const sock = launchSocketPath(f.dataRoot);
const pm = await launcher.launchPm();
const chief = await launcher.launchPm();
// cto asks pm to launch a coder; pm's turn calls the launch tool.
const sent = await client.call("message.send", { to: "pm", class: "REQUEST", body: 'TOOL launch {"instance":"coder"}' });
// cto asks chief to launch a coder; chief's turn calls the launch tool.
const sent = await client.call("message.send", { to: "chief", class: "REQUEST", body: 'TOOL launch {"instance":"coder"}' });
const reply = await until(async () => (await client.call("message.receive")).find((m) => m.in_reply_to === sent.id));
assert.equal(reply.from_role, "pm");
assert.equal(reply.from_role, "chief");
assert.equal(reply.class, "RESULT");
const result = JSON.parse(reply.body);
assert.equal(result.instance, "coder");
assert.equal(result.model, "claude-sonnet-5-5");
const coder = readSessions(f.dataRoot).find((s) => s.instance === "coder");
assert.equal(coder.run, result.run);
assert.equal(coder.launchedBy, "pm");
assert.equal(coder.launchedBy, "chief");
assert.equal(coder.live, true);
// Only the role that may launch has the launch socket in its sandbox.
const bound = (pid) => readFileSync(`/proc/${pid}/cmdline`, "utf8").split("\0").includes(sock);
assert.equal(bound(readSessions(f.dataRoot).find((s) => s.instance === "pm").pid), true);
assert.equal(bound(readSessions(f.dataRoot).find((s) => s.instance === "chief").pid), true);
assert.equal(bound(coder.pid), false);
// Row 93: and only a role that pushes or reviews has the git socket.
const gitBound = (pid) => readFileSync(`/proc/${pid}/cmdline`, "utf8").split("\0").includes(gitSocketPath(f.dataRoot));
assert.equal(gitBound(coder.pid), true);
assert.equal(gitBound(readSessions(f.dataRoot).find((s) => s.instance === "pm").pid), false);
assert.equal(gitBound(readSessions(f.dataRoot).find((s) => s.instance === "chief").pid), false);
assert.equal(JSON.parse(readFileSync(join(coder.runDir, "session.json"), "utf8")).gitSocket, gitSocketPath(f.dataRoot));
// Row 104 (#1598): each runner commits as its own instance, in this business.
const identity = (pid) => readFileSync(`/proc/${pid}/environ`, "utf8").split("\0").filter((e) => e.startsWith("GIT_")).sort();
for (const who of ["pm", "coder"]) {
for (const who of ["chief", "coder"]) {
const runnerPid = readSessions(f.dataRoot).find((s) => s.instance === who).runnerPid;
assert.deepEqual(identity(runnerPid), [`GIT_AUTHOR_EMAIL=${who}@acme.mosaic.invalid`, `GIT_AUTHOR_NAME=${who}`, `GIT_COMMITTER_EMAIL=${who}@acme.mosaic.invalid`, `GIT_COMMITTER_NAME=${who}`], who);
}
@@ -272,12 +274,12 @@ test("the PM launches a coder through its launch tool; the coder answers; refusa
// The launch log has the launches, every refusal, and the coder's end.
const entries = log(f);
assert.deepEqual(entries.filter((e) => e.event === "launch").map((e) => [e.instance, e.launchedBy]), [["pm", "jason"], ["coder", "pm"]]);
assert.deepEqual(entries.filter((e) => e.event === "launch").map((e) => [e.instance, e.launchedBy]), [["chief", "jason"], ["coder", "chief"]]);
const refused = entries.filter((e) => e.event === "refused").map((e) => [e.instance, e.code]);
for (const r of refusals) assert.ok(refused.some(([i, c]) => i === r[0] && c === r[1]), `${r} is logged`);
const end = entries.find((e) => e.event === "end" && e.run === coderRun);
assert.equal(end.reason, "stopped");
assert.ok(readSessions(f.dataRoot).some((s) => s.run === pm.run));
assert.ok(readSessions(f.dataRoot).some((s) => s.run === chief.run));
});
// Row 91 (#1585): the project clone in each workspace.
@@ -285,7 +287,7 @@ test("the PM launches a coder through its launch tool; the coder answers; refusa
test("each launch makes sure the workspace has a clone of the project: cloned, fast-forwarded, or fetched and logged", { skip, timeout: 60000 }, async (t) => {
const given = prepare(t);
const project = given.f.doc.projects.stack.root;
const clone = join(workspaceDir(given.f.dataRoot, "acme", "pm"), "stack");
const clone = join(workspaceDir(given.f.dataRoot, "acme", "chief"), "stack");
const prompt = (run) => readFileSync(join(given.f.dataRoot, "launches", "acme", run, "bundle", "prompt.md"), "utf8");
const kept = () => log(given.f).filter((e) => e.event === "checkout-kept");
const fetched = () => log(given.f).filter((e) => e.event === "checkout-fetched");
@@ -299,7 +301,7 @@ test("each launch makes sure the workspace has a clone of the project: cloned, f
assert.equal(gitIn(clone, "config", "--get-all", "remote.origin.url"), project);
assert.ok(prompt(pm1.run).includes(`- Project clones: stack at ${clone}, on branch main at commit ${one} when this session started. `));
// While the PM runs, a second launch is refused before the host runs git
// While the chief of staff runs, a second launch is refused before the host runs git
// in its workspace: the root moved on, and the clone didn't.
const two = commit(project, "two");
await assert.rejects(first.launcher.launchPm(), /PM launch refused: instance-running/);
@@ -326,7 +328,7 @@ test("each launch makes sure the workspace has a clone of the project: cloned, f
assert.equal(gitIn(clone, "rev-parse", "HEAD"), two);
assert.equal(gitIn(clone, "rev-parse", "refs/remotes/origin/main"), three);
assert.equal(readFileSync(join(clone, "notes.txt"), "utf8"), "mine\n");
assert.deepEqual(fetched().map(({ instance, project: p, reason }) => ({ instance, project: p, reason })), [{ instance: "pm", project: "stack", reason: "dirty" }]);
assert.deepEqual(fetched().map(({ instance, project: p, reason }) => ({ instance, project: p, reason })), [{ instance: "chief", project: "stack", reason: "dirty" }]);
assert.deepEqual(kept(), []);
assert.ok(prompt(pm3.run).includes(`- Project clones: stack at ${clone}, on branch main at commit ${two} when this session started, fetched but not fast-forwarded at this launch (dirty), with origin/main at commit ${three}. `));
assert.equal(await third.close(), 0);
@@ -337,43 +339,48 @@ test("each launch makes sure the workspace has a clone of the project: cloned, f
assert.equal(gitIn(project, "status", "--porcelain"), "");
});
test("a project clone that can't be made refuses checkout-failed, from --pm and from a session, with no run and no clone left", { skip, timeout: 30000 }, async (t) => {
test("a project clone that can't be made refuses checkout-failed, from --chief and from a session, with no run and no clone left", { skip, timeout: 30000 }, async (t) => {
const given = prepare(t);
renameSync(join(given.f.doc.projects.stack.root, ".git"), join(given.root, "away.git"));
const { f, launcher, host } = await setup(t, undefined, given);
await assert.rejects(launcher.launchPm(), (e) => e.exitCode === 3 && /^PM launch refused: checkout-failed \(stack: the root .* can't be read: /.test(e.message));
// A pm capability bound to this process asks for the coder.
const pm = await host.bindLaunch({ business: "acme", role: "pm", run: "pm-test", harness: "pi", pid: process.pid, startTime: startTimeOf(process.pid) });
await new Client({ path: host.path, cap: pm.cap }).call("role.claim");
assert.deepEqual(await launcher.fromSession(pm.cap, "coder"), { ok: false, error: "checkout-failed" });
// A chief capability bound to this process asks for the coder.
const chief = await host.bindLaunch({ business: "acme", role: "chief", run: "chief-test", harness: "pi", pid: process.pid, startTime: startTimeOf(process.pid) });
await new Client({ path: host.path, cap: chief.cap }).call("role.claim");
assert.deepEqual(await launcher.fromSession(chief.cap, "coder"), { ok: false, error: "checkout-failed" });
const entries = log(f);
assert.deepEqual(entries.map((e) => [e.event, e.instance, e.code]), [["refused", "pm", "checkout-failed"], ["refused", "coder", "checkout-failed"]]);
assert.deepEqual(entries.map((e) => [e.event, e.instance, e.code]), [["refused", "chief", "checkout-failed"], ["refused", "coder", "checkout-failed"]]);
for (const e of entries) assert.match(e.why, /^stack: the root /);
assert.deepEqual(readSessions(f.dataRoot), []);
assert.deepEqual(readdirSync(join(f.dataRoot, "launches")), ["acme.jsonl"], "no run was made");
for (const instance of ["pm", "coder"]) assert.deepEqual(readdirSync(workspaceDir(f.dataRoot, "acme", instance)), [], `${instance}: no clone, no temp`);
for (const instance of ["chief", "coder"]) assert.deepEqual(readdirSync(workspaceDir(f.dataRoot, "acme", instance)), [], `${instance}: no clone, no temp`);
});
test("a runner that stops at once ends its launch with the runner's reason", { skip, timeout: 30000 }, async (t) => {
// pm's tracker token has expired: the runner's founder-credential stop
// exits 20 before role.claim, and the host records founder-credentials.
const { f, launcher } = await setup(t, (doc) => {
doc.roles.pm.credentials.vikunja.expires = "2000-01-01";
// The coder's tracker token has expired: the runner's founder-credential stop
// exits 20 before role.claim, and the host records founder-credentials. The chief
// of staff holds no credential (row 119), so a chief capability launches the coder.
const { f, launcher, host } = await setup(t, (doc) => {
doc.roles.coder.credentials.vikunja.expires = "2000-01-01";
return doc;
});
const pm = await launcher.launchPm();
const end = await until(() => existsSync(launchLogFile(f.dataRoot, "acme")) && log(f).find((e) => e.event === "end" && e.run === pm.run));
const chief = await host.bindLaunch({ business: "acme", role: "chief", run: "chief-test", harness: "pi", pid: process.pid, startTime: startTimeOf(process.pid) });
await new Client({ path: host.path, cap: chief.cap }).call("role.claim");
const launched = await launcher.fromSession(chief.cap, "coder");
assert.equal(launched.ok, true, JSON.stringify(launched));
const run = launched.result.run;
const end = await until(() => existsSync(launchLogFile(f.dataRoot, "acme")) && log(f).find((e) => e.event === "end" && e.run === run));
assert.equal(end.reason, "founder-credentials");
assert.equal(end.exitCode, 20);
assert.deepEqual(readSessions(f.dataRoot), []);
assert.match(readFileSync(join(f.dataRoot, "launches", "acme", pm.run, "runner.log"), "utf8"), /vikunja/);
assert.match(readFileSync(join(f.dataRoot, "launches", "acme", run, "runner.log"), "utf8"), /vikunja/);
});
test("a Claude Code launch with no session credential refuses before it starts", { timeout: 30000 }, async (t) => {
const given = prepare(t, (doc) => {
doc.roles.pm.vars.harness = "claude-code";
doc.roles.chief.vars.harness = "claude-code";
return doc;
});
delete given.f.env.CLAUDE_CODE_OAUTH_TOKEN;
@@ -391,9 +398,9 @@ test("a pi launch needs no session credential: pi logs in through its own ENV_AL
delete given.f.env.CLAUDE_CODE_OAUTH_TOKEN;
assert.deepEqual(SESSION_CREDENTIALS.flat().filter((k) => k in given.f.env), []);
const { f, launcher } = await setup(t, undefined, given);
const pm = await launcher.launchPm();
assert.equal(pm.harness, "pi");
assert.deepEqual(log(f).map((e) => [e.event, e.instance]), [["launch", "pm"]]);
const chief = await launcher.launchPm();
assert.equal(chief.harness, "pi");
assert.deepEqual(log(f).map((e) => [e.event, e.instance]), [["launch", "chief"]]);
});
// The runner is pid 1 in its namespace and ignores a SIGKILL sent from
@@ -401,12 +408,12 @@ test("a pi launch needs no session credential: pi logs in through its own ENV_AL
// which the launcher reads back as the signal.
test("a runner killed from the host ends its launch as killed, not failed", { skip, timeout: 30000 }, async (t) => {
const { f, launcher } = await setup(t);
const pm = await launcher.launchPm();
const runnerLog = join(f.dataRoot, "launches", "acme", pm.run, "runner.log");
const chief = await launcher.launchPm();
const runnerLog = join(f.dataRoot, "launches", "acme", chief.run, "runner.log");
await until(() => existsSync(runnerLog) && readFileSync(runnerLog, "utf8").includes("claimed by run"));
const [s] = readSessions(f.dataRoot);
process.kill(s.runnerPid, "SIGKILL");
const end = await until(() => log(f).find((e) => e.event === "end" && e.run === pm.run));
const end = await until(() => log(f).find((e) => e.event === "end" && e.run === chief.run));
assert.deepEqual([end.reason, end.exitCode, end.signal], ["killed", null, "SIGKILL"]);
assert.deepEqual(readSessions(f.dataRoot), []);
});
@@ -451,14 +458,14 @@ test("a real Claude Code coder's shell can't read a stand-in secret or anything
}
const claudeAdapter = join(REPO, "adapters", "claude", "adapter.sh");
const { f, launcher, client } = await setup(t, undefined, given, { adapters: { pi: given.adapter, "claude-code": claudeAdapter } });
const pm = await launcher.launchPm();
const pmRun = readSessions(f.dataRoot).find((s) => s.run === pm.run).runDir;
const chief = await launcher.launchPm();
const chiefRun = readSessions(f.dataRoot).find((s) => s.run === chief.run).runDir;
// [what, shell command, may the session do it]
const probes = [
["read the stand-in secret", `cat '${secret}'`, false],
["read the stand-in ~/.mosaic", `cat '${mosaic}'`, false],
["read the PM's run directory", `cat '${pmRun}/session.json'`, false],
["read the chief's run directory", `cat '${chiefRun}/session.json'`, false],
["read the data root's sessions file", `cat '${sessionsFile(f.dataRoot)}'`, false],
["read the checkout", `cat '${join(REPO, "AGENTS.md")}'`, false],
["write outside", `touch '${given.root}/escaped'`, false],
@@ -469,7 +476,7 @@ test("a real Claude Code coder's shell can't read a stand-in secret or anything
const command = probes.map(([what, cmd], i) => `if { ${cmd}; } >/dev/null 2>&1; then echo "${i} yes"; else echo "${i} no"; fi`).join("\n");
script.push({ name: "Bash", input: { command, description: "probe the sandbox" } });
const sent = await client.call("message.send", { to: "pm", class: "REQUEST", body: 'TOOL launch {"instance":"coder"}' });
const sent = await client.call("message.send", { to: "chief", class: "REQUEST", body: 'TOOL launch {"instance":"coder"}' });
const launched = await until(async () => (await client.call("message.receive")).find((m) => m.in_reply_to === sent.id));
assert.equal(JSON.parse(launched.body).instance, "coder", launched.body);
const coder = readSessions(f.dataRoot).find((s) => s.instance === "coder");
@@ -529,7 +536,7 @@ for (const exited of [false, true]) {
const s = await setup(t, undefined, given);
assert.equal(startTimeOf(left.pid), null, "the old session process is gone");
assert.equal(startTimeOf(left.runnerPid), null, "and its runner with it");
assert.ok(s.logs.includes(`run ${first.run} (pm) from an earlier host ended: host-lost`), s.logs.join("\n"));
assert.ok(s.logs.includes(`run ${first.run} (chief) from an earlier host ended: host-lost`), s.logs.join("\n"));
const ended = (await s.client.call("trail", { subject: first.run })).filter((r) => r.kind === "session.ended");
assert.equal(ended.length, 1);
assert.deepEqual(ended[0].body, { reason: "host-lost", exitCode: null, released: true });
@@ -537,9 +544,9 @@ for (const exited of [false, true]) {
assert.deepEqual(log.filter((e) => e.event === "end").map((e) => [e.run, e.reason]), [[first.run, "host-lost"]]);
assert.deepEqual(readSessions(f.dataRoot), []);
// Without the end, the old run would still hold pm and this runner would exit 21.
// Without the end, the old run would still hold chief and this runner would exit 21.
const again = await s.launcher.launchPm();
await until(async () => (await s.client.call("agents")).some((a) => a.role === "pm" && a.holder_run === again.run));
await until(async () => (await s.client.call("agents")).some((a) => a.role === "chief" && a.holder_run === again.run));
await s.client.call("role.release"); // the test's own cto claim, so the next host's can claim again
assert.equal(await s.close(), 0);
@@ -547,7 +554,7 @@ for (const exited of [false, true]) {
const { live, ...entry } = left;
new Registry(f.dataRoot).add(entry);
const third = await setup(t, undefined, given);
assert.ok(third.logs.includes(`run ${first.run} (pm) from an earlier host was already ended`), third.logs.join("\n"));
assert.ok(third.logs.includes(`run ${first.run} (chief) from an earlier host was already ended`), third.logs.join("\n"));
assert.equal((await third.client.call("trail", { subject: first.run })).filter((r) => r.kind === "session.ended").length, 1);
assert.equal(await third.close(), 0);
});
@@ -610,8 +617,8 @@ test("a launch client that never closes its side doesn't hold the host's close",
test("a runner that ignores SIGTERM is killed when the host closes", { skip, timeout: 60000 }, async (t) => {
const { f, launcher, close } = await setup(t, undefined, undefined, { stopTimeoutMs: 1000 });
const pm = await launcher.launchPm();
const runnerLog = join(f.dataRoot, "launches", "acme", pm.run, "runner.log");
const chief = await launcher.launchPm();
const runnerLog = join(f.dataRoot, "launches", "acme", chief.run, "runner.log");
await until(() => existsSync(runnerLog) && readFileSync(runnerLog, "utf8").includes("claimed by run"));
const [s] = readSessions(f.dataRoot);
// Stopped from outside its namespace, it can't act on SIGTERM.
@@ -630,7 +637,7 @@ test("a runner that ignores SIGTERM is killed when the host closes", { skip, tim
assert.equal(startTimeOf(s.pid), null);
assert.equal(startTimeOf(s.runnerPid), null);
assert.deepEqual(readSessions(f.dataRoot), []);
const ends = log(f).filter((e) => e.event === "end" && e.run === pm.run);
const ends = log(f).filter((e) => e.event === "end" && e.run === chief.run);
assert.equal(ends.length, 1);
assert.equal(ends[0].signal, "SIGKILL");
});
+3 -2
View File
@@ -28,7 +28,8 @@ test("bootConfig trackers reach the S3 adapter in the real broker child, which g
const f = fixture(root, "acme", (doc) => {
doc.vars["tracker.baseUrl"] = srv.url;
doc.tracker.sync.botId = bots.sync;
for (const r of Object.keys(doc.roles)) doc.roles[r].tracker.botId = bots[r];
// The chief of staff (row 119) has no tracker bot.
for (const r of Object.keys(doc.roles)) if (doc.roles[r].tracker) doc.roles[r].tracker.botId = bots[r];
return doc;
});
const put = (file, token) => {
@@ -36,7 +37,7 @@ test("bootConfig trackers reach the S3 adapter in the real broker child, which g
chmodSync(file, 0o600);
};
put(f.doc.tracker.sync.credentials.vikunja.file, fake.token(bots.sync, SCOPES.sync));
for (const r of Object.keys(f.doc.roles)) put(f.doc.roles[r].credentials.vikunja.file, fake.token(bots[r], r === "pm" ? SCOPES.pm : SCOPES.worker));
for (const r of Object.keys(f.doc.roles)) if (f.doc.roles[r].credentials) put(f.doc.roles[r].credentials.vikunja.file, fake.token(bots[r], r === "pm" ? SCOPES.pm : SCOPES.worker));
writeJson(join(root, "project", ".mosaic", "project.json"), { projectVersion: 1, id: "stack", vars: { "tracker.project": project } });
mkdirSync(f.dataRoot, { recursive: true, mode: 0o700 });
+14 -2
View File
@@ -42,7 +42,8 @@ test("a pi bundle: prompt, policy, tools and manifest, 0600 in a 0700 directory"
const prompt = readFileSync(b.files.prompt, "utf8");
assert.match(prompt, /^# Contract\n\nDo the work\.\n\n## This session\n/);
assert.match(prompt, /Role instance: pm \(definition pm\)\. Run: run-1\./);
assert.match(prompt, /Bus tools: send_message, launch, /);
assert.match(prompt, /Bus tools: send_message, task_create, /);
assert.doesNotMatch(prompt, /Bus tools: [^.]*\blaunch\b/);
// Row 90 (#1584): PM fault 10 (#228 c91).
assert.match(prompt, /^- Only the human stops a run, with `mosaic stop`\. If you think a run should stop, raise a role\.revoke decision with blocking false/m);
assert.deepEqual(JSON.parse(readFileSync(b.files.policy, "utf8")), {
@@ -62,13 +63,24 @@ test("a pi bundle: prompt, policy, tools and manifest, 0600 in a 0700 directory"
assert.equal(m.gateTimeout, null);
assert.deepEqual(m.skills, []);
assert.equal(m.reliesOn.length, 5);
assert.ok(m.typed.includes("launch"));
assert.ok(!m.typed.includes("launch"), "row 119: the PM no longer launches");
assert.deepEqual(Object.keys(m.sources).sort(), ["gate", "piExtension", "tools"]);
assert.equal(m.sources.piExtension.sha256, sha(SOURCES.piExtension));
for (const [name, digest] of Object.entries(m.files)) assert.equal(digest, sha(join(b.dir, "bundle", name)), name);
assert.deepEqual(JSON.parse(readFileSync(b.files.manifest, "utf8")), JSON.parse(JSON.stringify(m)));
});
// Row 119 (#1613): the chief of staff's bundle carries the launch tool; its contract is the prompt's head.
test("a chief-of-staff bundle lists launch among the bus tools and the manifest's typed tools", (t) => {
const r = resolvedFor(t, "chief", (d) => (d.roles.chief.vars = { harness: "pi", model: "claude-opus-5-5" }));
const b = build(t, r);
const prompt = readFileSync(b.files.prompt, "utf8");
assert.match(prompt, /Role instance: chief \(definition chief-of-staff\)\. Run: run-1\./);
assert.match(prompt, /Bus tools: send_message, launch, raise_decision, /);
assert.ok(b.manifest.typed.includes("launch"));
assert.deepEqual(JSON.parse(readFileSync(b.files.policy, "utf8")).needs, []);
});
test("a claude-code bundle adds the wrapped gate hook and the MCP config", (t) => {
const r = resolvedFor(t, "coder", (d) => (d.roles.coder.vars = { harness: "claude-code", model: "claude-sonnet-5-5" }));
const b = build(t, r);
+1 -1
View File
@@ -38,7 +38,7 @@ function serve(t, toolsFile, socket) {
async function setup(t) {
const dir = scratch(t);
const tools = typedTools({ resolved: resolvedFor(t, "pm"), tracker: true });
const tools = typedTools({ resolved: resolvedFor(t, "chief"), tracker: true });
const toolsFile = join(dir, "tools.json");
writeFileSync(toolsFile, JSON.stringify(tools));
const sock = await fakeToolSocket(t, dir, (tool, args) => {
+6 -4
View File
@@ -13,7 +13,8 @@ import { fakeToolSocket, mockAnthropic, REPO, resolvedFor, scratch } from "./hel
const ADAPTER = join(REPO, "adapters", "pi", "adapter.sh");
async function session(t, script, place = (dir) => join(dir, "ws")) {
// The chief of staff (row 119) has no write tool; tests that write use the PM.
async function session(t, script, place = (dir) => join(dir, "ws"), instance = "chief") {
const dir = scratch(t);
const workspace = place(dir);
const agentDir = join(dir, "pi-agent");
@@ -23,7 +24,7 @@ async function session(t, script, place = (dir) => join(dir, "ws")) {
writeFileSync(join(dir, "secret.txt"), "outside\n");
const api = await mockAnthropic(t, { script, done: (r) => `ANSWER ${JSON.stringify(r.map((x) => [x.isError, x.content.slice(0, 80)]))}` });
writeFileSync(join(agentDir, "models.json"), JSON.stringify({ providers: { probe: { baseUrl: api.url, api: "anthropic-messages", apiKey: "probe-dummy", models: [{ id: "probe-model" }] } } }));
const resolved = resolvedFor(t, "pm", (d) => (d.roles.pm.vars = { ...d.roles.pm.vars, model: "probe-model" }));
const resolved = resolvedFor(t, instance, (d) => (d.roles[instance].vars = { ...d.roles[instance].vars, harness: "pi", model: "probe-model" }));
const sock = await fakeToolSocket(t, dir, (tool, args) => {
if (tool === "launch") throw new Error("capacity-full");
// trail answers a string `size` characters long once JSON-encoded, for the reply cap.
@@ -84,7 +85,7 @@ test("pi: typed tools reach the socket, the gate blocks, agent_end writes the ma
assert.match(results[0][1], /^\{"tool":"list_agents"/);
assert.equal(results[1][0], true);
assert.match(results[1][1], /refused: capacity-full/);
// read inside passes; outside is gated; bash isn't in the PM's tools.
// read inside passes; outside is gated; bash isn't in the chief of staff's tools.
assert.equal(results[2][0], false);
assert.match(results[2][1], /inside/);
assert.equal(results[3][0], true);
@@ -128,7 +129,7 @@ test("pi: a write through a dangling symlink is blocked, and nothing appears out
{ name: "write", input: { path: "notes.md", content: "planted\n" } },
{ name: "write", input: { path: "gone/x.md", content: "planted\n" } },
{ name: "write", input: { path: "fine.md", content: "inside\n" } },
]);
], undefined, "pm");
mkdirSync(join(dir, "outside"));
symlinkSync(join(dir, "outside", "planted.txt"), join(workspace, "notes.md"));
symlinkSync(join(dir, "outside", "made"), join(workspace, "gone"));
@@ -218,6 +219,7 @@ for (const [name, { place, outside, escape, stay }] of Object.entries(CLIMBS)) {
{ name: "write", input: { path: `${stay}/fine.md`, content: "inside\n" } },
],
place,
"pm",
);
mkdirSync(outside(dir), { recursive: true });
writeFileSync(join(outside(dir), "secret.txt"), "SECRET-OUTSIDE\n");
+29 -9
View File
@@ -6,19 +6,17 @@ import { fakeToolSocket, resolvedFor, scratch } from "./helpers.mjs";
const names = (tools) => tools.map((t) => t.name);
test("the PM gets launch, its task verbs and the reads", (t) => {
test("the PM gets its task verbs and the reads, and no launch", (t) => {
const pm = resolvedFor(t, "pm");
const tools = typedTools({ resolved: pm, arbiter: true, tracker: true });
assert.deepEqual(names(tools), [
"send_message", "launch",
"send_message",
"task_create", "task_assign", "task_reassign", "task_schedule", "task_priority_change", "task_scope_change",
"task_update_assigned", "task_close",
"raise_decision", "resolve_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task", "list_requirements",
]);
const launch = tools.find((x) => x.name === "launch");
assert.equal(launch.verb, "launch");
assert.equal(launch.action, "role.launch");
assert.deepEqual(launch.parameters.properties.instance.enum, ["coder", "reviewer"]);
// Row 119 (#1613): only the chief of staff launches (REQ-COS-1).
assert.equal(pm.launch, null);
// Row B: the project is optional, a positive integer, and only task_create takes it.
const create = tools.find((x) => x.name === "task_create");
assert.deepEqual({ ...create.parameters.properties.project, description: undefined }, { type: "integer", minimum: 1, description: undefined });
@@ -31,6 +29,27 @@ test("the PM gets launch, its task verbs and the reads", (t) => {
}
});
// Row 119 (#1613): the chief of staff holds launch and message.send, and nothing else.
test("the chief of staff gets launch and send_message, and no task verbs", (t) => {
const chief = resolvedFor(t, "chief");
const tools = typedTools({ resolved: chief, tracker: true });
assert.deepEqual(names(tools), ["send_message", "launch", "raise_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task"]);
const launch = tools.find((x) => x.name === "launch");
assert.equal(launch.verb, "launch");
assert.equal(launch.action, "role.launch");
assert.deepEqual(launch.parameters.properties.instance.enum, ["coder", "reviewer"]);
assert.deepEqual(untypedActions(chief, tools), []);
});
// Row 119 (#1613): the assistant holds message.send alone.
test("the assistant gets send_message and the reads, no launch", (t) => {
const assistant = resolvedFor(t, "assistant", (d) => (d.roles.assistant = { definition: "assistant", holder: "mos" }));
const tools = typedTools({ resolved: assistant, tracker: true });
assert.deepEqual(names(tools), ["send_message", "raise_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task"]);
assert.equal(assistant.launch, null);
assert.deepEqual(untypedActions(assistant, tools), []);
});
// Row 90 (#1584): list_requirements for the sessions that hold task.create, with a tracker.
test("list_requirements reads the requirement ids, for a task.create holder with a tracker", (t) => {
const pm = resolvedFor(t, "pm");
@@ -75,9 +94,10 @@ test("a coder with a tracker gets its own task tools and no task_reassign", (t)
});
test("launch only when the business's launch block names the instance as launcher", (t) => {
const pm = resolvedFor(t, "pm", (d) => delete d.launch);
assert.equal(pm.launch, null);
assert.ok(!names(typedTools({ resolved: pm })).includes("launch"));
assert.ok(names(typedTools({ resolved: resolvedFor(t, "chief") })).includes("launch"));
const chief = resolvedFor(t, "chief", (d) => delete d.launch);
assert.equal(chief.launch, null);
assert.ok(!names(typedTools({ resolved: chief })).includes("launch"));
});
test("an action outside the instance's authority has no tool", (t) => {
+5 -3
View File
@@ -116,7 +116,7 @@ test('business: names, vars on the allowlist, authority, credential metadata onl
const b = JSON.parse(r.text).business;
assert.deepEqual([b.id, b.human, b.arbiters, b.projects], ['acme', 'jason', { delivery: 'pm', technical: 'cto' }, ['stack']]);
assert.deepEqual(b.vars, { 'tracker.baseUrl': 'http://127.0.0.1:3456', 'gitea.baseUrl': 'https://git.example', 'tracker.pollSeconds': 60 });
assert.deepEqual(b.instances.map(i => [i.instance, i.definition, i.holder]), [['pm', 'pm', 'sage'], ['cto', 'cto', 'darkwing'], ['coder', 'coder', null], ['reviewer', 'reviewer', null]]);
assert.deepEqual(b.instances.map(i => [i.instance, i.definition, i.holder]), [['pm', 'pm', 'sage'], ['cto', 'cto', 'darkwing'], ['coder', 'coder', null], ['reviewer', 'reviewer', null], ['chief', 'chief-of-staff', null]]);
assert.deepEqual(b.instances.find(i => i.instance === 'coder').vars, { harness: 'pi', 'limits.network': 'none' });
// An allowlisted var is scrubbed too (Filbert T1).
assert.deepEqual(b.instances.find(i => i.instance === 'reviewer').vars, { model: '<path>' });
@@ -124,8 +124,10 @@ test('business: names, vars on the allowlist, authority, credential metadata onl
assert.deepEqual(Object.keys(i.authority), b.actions);
assert.ok(Object.values(i.authority).every(v => ['within', 'cross', 'gated'].includes(v)));
}
assert.equal(b.instances.find(i => i.instance === 'pm').authority['role.launch'], 'within');
assert.deepEqual(b.launch, { by: 'pm', instances: ['coder', 'reviewer'], max: { opus: 2, sonnet: 4 } });
// Row 119 (#1613): the chief of staff launches; the PM does not.
assert.equal(b.instances.find(i => i.instance === 'chief').authority['role.launch'], 'within');
assert.equal(b.instances.find(i => i.instance === 'pm').authority['role.launch'], 'gated');
assert.deepEqual(b.launch, { by: 'chief', instances: ['coder', 'reviewer'], max: { opus: 2, sonnet: 4 } });
const cred = (role, service) => b.credentials.find(c => c.role === role && c.service === service);
assert.deepEqual(Object.keys(cred('pm', 'gitea')).sort(), ['account', 'date', 'dateKind', 'role', 'service', 'state']);
assert.deepEqual(cred('coder', 'gitea'), { service: 'gitea', account: null, role: 'coder', dateKind: 'rotateBy', date: day(-1), state: 'rotation-due' });
+13
View File
@@ -0,0 +1,13 @@
{
"roleVersion": 2,
"name": "assistant",
"title": "Assistant",
"contract": "assistant.md",
"tools": ["read", "grep", "find", "ls"],
"network": "api-only",
"authority": {
"withinRole": ["message.send"],
"crossRole": []
},
"credentials": []
}
+45
View File
@@ -0,0 +1,45 @@
# Role contract: assistant (assistant)
You hold the `assistant` role for one user in one business. You are the
user's main agent. You talk with the user, and you pass the work the
user wants done for the business to the chief of staff. You don't direct
other agents, you don't create or assign tasks and you don't launch
sessions.
## Duties
- Talk with the user. Answer what they ask, say plainly what you don't
know and keep their requests in their own words.
- Send each request for the business to the chief of staff as a message,
addressed to the `chief-of-staff` role. Keep the user's words as they
gave them, and name the request each message carries.
- Relay each ruling the user gives verbatim to the chief of staff,
naming whose ruling it is and which role it concerns. Don't summarize
it, paraphrase it or add to it. Don't send it to that role yourself.
- Report to the user what the chief of staff answers, and every refusal
with its exact text.
## Authority
Your role file lists what you do on your own: sending messages. Anything
the list doesn't name is gated. That includes creating or assigning
tasks, directing another role, launching a role session, pushing to a
repository, sending messages outside the business, minting credentials,
and any change to policy or to a PRD. When an action is gated, tell the
user and wait for their answer. Never route around a refusal.
You hold no human authority. Never resolve a decision for the user, never
approve a request in their name, and never write words as if they had said
them. When a question needs their decision, pass it on as their question
and wait for their answer.
Only the human stops a run, with `mosaic stop`. If you think a run should
stop, say so to the user. Don't stop it yourself.
## Protocol
- You act only through the typed tools the broker gives you. Your tools
are read, grep, find and ls. You have no write tool, and you never hold
a token or a credential.
- Send messages through the bus, addressed to a role, not a person.
- Report a blocker as soon as you find it, with what you tried.
+1 -1
View File
@@ -6,7 +6,7 @@
"tools": ["read", "grep", "find", "ls"],
"network": "api-only",
"authority": {
"withinRole": ["message.send"],
"withinRole": ["message.send", "role.launch"],
"crossRole": []
},
"credentials": []
+37 -25
View File
@@ -1,43 +1,55 @@
# Role contract: chief of staff (chief-of-staff)
You hold the `chief-of-staff` role for one business. You are the human's
main agent in that business. You carry Jason's requests to the product
manager, relay his rulings and ask the PM for work. You don't write the
You hold the `chief-of-staff` role for one business. You run the
organization. You hire and terminate role instances, you own the
organization's structure and you are the only role that launches
sessions. You take work from the assistant and assign it through the
roles that do it. You never talk to the user. You don't write the
product's code, you don't review it and you don't create tasks yourself.
## Duties
- Send each request Jason makes to the PM as a message, addressed to the
`pm` role. Keep his words as he gave them, and name the request each
message carries.
- Ask the PM for work by message. The PM decides which work becomes a
task, who holds it and when it runs.
- Relay each ruling Jason gives verbatim to the role it concerns. Don't
summarize it, paraphrase it or add to it. Say whose ruling it is.
- Report to Jason what the PM answers, and every refusal with its exact
text.
- Take the work the assistant passes you. Send it to the role that does
it, addressed by role, in the words the assistant gave you. The `pm`
role turns product work into tasks and decides who holds them and when
they run.
- Launch role sessions with the `launch` tool, for the instances and
within the limits the business file's launch block lists. A launch
outside them is refused, not queued. Launch only role instances.
- Keep the organization's structure: which role instances exist, who
holds them and what they may do. To terminate a role instance, raise a
`role.revoke` decision, naming the instance and saying why. To hire one
or change the structure, raise a `policy.change` decision until the
broker gives you a verb for the organization file.
- Report to the assistant what the roles answer, and every refusal with
its exact text.
## Authority
Your role file lists what you do on your own: sending messages. Anything
the list doesn't name is gated. That includes creating or assigning
tasks, launching a role session, pushing to a repository, sending
Your role file lists what you do on your own: sending messages and
launching role sessions. Anything the list doesn't name is gated. That
includes creating or assigning tasks, pushing to a repository, sending
messages outside the business, minting credentials, and any change to
policy or to a PRD. When an action is gated, raise it to Jason and wait
for his answer. Never route around a refusal.
policy or to a PRD. When an action is gated, raise a decision. It
reaches the human through the inbox, not through you. Never route around
a refusal.
You hold no human authority. Never resolve a decision for Jason, never
approve a request in his name, and never write words as if he had said
them. When a question needs his decision, pass it on as his question and
wait for his answer.
You hold no human authority. Never resolve a decision for the human,
never approve a request in their name, and never write words as if they
had said them.
Only the human stops a run, with `mosaic stop`. If you think a coder or
reviewer run should stop, say so to Jason. Don't stop it yourself.
Only the human stops a run, with `mosaic stop`. If you think a run should
stop, raise a `role.revoke` decision with `blocking` false and the role
instance as target, naming the run and saying why.
## Protocol
- You act only through the typed tools the broker gives you. Your tools
are read, grep, find and ls. You have no write tool, and you never hold
a token or a credential.
are read, grep, find and ls, plus the broker's launch and message
tools. You have no write tool, and you never hold a token or a
credential.
- Each turn opens with a "Current state" block, read when the turn
started. It is the truth for launch state and task status. A message
or a note of yours that disagrees with it is out of date.
- Send messages through the bus, addressed to a role, not a person.
- Report a blocker as soon as you find it, with what you tried.
+1 -1
View File
@@ -6,7 +6,7 @@
"tools": ["read", "write", "edit", "grep", "find", "ls"],
"network": "api-only",
"authority": {
"withinRole": ["task.create", "task.assign", "task.reassign", "task.schedule", "task.update.assigned", "task.close", "role.launch", "message.send"],
"withinRole": ["task.create", "task.assign", "task.reassign", "task.schedule", "task.update.assigned", "task.close", "message.send"],
"crossRole": ["task.priority.change", "task.scope.change"]
},
"credentials": [
+4 -3
View File
@@ -27,8 +27,9 @@ order. You don't write the product's code and you don't review it.
stale verdict is refused.
- You arbitrate delivery order. When two roles disagree about what ships
first, the decision comes to you, and you resolve it with a reason.
- Launch the coder and reviewer seats the business file lets you launch,
within its limits. A launch outside them is refused, not queued.
- Take the work the chief of staff passes you. You don't launch sessions.
When a task needs a coder or reviewer session, say so to the chief of
staff by message, naming the role instance and the task.
## Authority
@@ -56,7 +57,7 @@ cross-role. Raise it as a decision for the technical arbiter.
- Each turn opens with a "Current state" block, read when the turn
started. It is the truth for launch state and task status. A message
or a note of yours that disagrees with it is out of date. Before you
plan to resume or launch work on a task, check the task with
plan to resume or start work on a task, check the task with
`get_task`.
- Send messages through the bus, addressed to a role, not a person.
- Report a blocker as soon as you find it, with what you tried.