diff --git a/packages/business/README.md b/packages/business/README.md index f36ded9eb..29683efa5 100644 --- a/packages/business/README.md +++ b/packages/business/README.md @@ -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 diff --git a/packages/business/examples/mosaic-stack.example.json b/packages/business/examples/mosaic-stack.example.json index 702e8eb14..fd955602a 100644 --- a/packages/business/examples/mosaic-stack.example.json +++ b/packages/business/examples/mosaic-stack.example.json @@ -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" diff --git a/packages/business/tests/assistant.test.mjs b/packages/business/tests/assistant.test.mjs new file mode 100644 index 000000000..aaa809c44 --- /dev/null +++ b/packages/business/tests/assistant.test.mjs @@ -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/); +}); diff --git a/packages/business/tests/business.test.mjs b/packages/business/tests/business.test.mjs index ec3ccc651..96ba7fda8 100644 --- a/packages/business/tests/business.test.mjs +++ b/packages/business/tests/business.test.mjs @@ -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); }); diff --git a/packages/business/tests/chief-of-staff.test.mjs b/packages/business/tests/chief-of-staff.test.mjs index 08374d122..d32ec7bfe 100644 --- a/packages/business/tests/chief-of-staff.test.mjs +++ b/packages/business/tests/chief-of-staff.test.mjs @@ -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"); +}); + +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 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. + // 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"], crossRole: [] }); + 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 chief-of-staff instance is refused", () => { +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) => { diff --git a/packages/business/tests/cli.test.mjs b/packages/business/tests/cli.test.mjs index af87bba63..278e4369d 100644 --- a/packages/business/tests/cli.test.mjs +++ b/packages/business/tests/cli.test.mjs @@ -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", () => { diff --git a/packages/business/tests/helpers.mjs b/packages/business/tests/helpers.mjs index 334fbe262..e105b4263 100644 --- a/packages/business/tests/helpers.mjs +++ b/packages/business/tests/helpers.mjs @@ -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 `/secrets`, and a project root under ``. 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 } }, }; } diff --git a/packages/business/tests/resolve.test.mjs b/packages/business/tests/resolve.test.mjs index 75ac2925d..d442c5883 100644 --- a/packages/business/tests/resolve.test.mjs +++ b/packages/business/tests/resolve.test.mjs @@ -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); } }); diff --git a/packages/business/tests/role.test.mjs b/packages/business/tests/role.test.mjs index 00628f804..150be7ec2 100644 --- a/packages/business/tests/role.test.mjs +++ b/packages/business/tests/role.test.mjs @@ -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); diff --git a/packages/cli/tests/launcher.test.mjs b/packages/cli/tests/launcher.test.mjs index b7fef78ad..e9f087d1a 100644 --- a/packages/cli/tests/launcher.test.mjs +++ b/packages/cli/tests/launcher.test.mjs @@ -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"); }); diff --git a/packages/cli/tests/trackers-boot.test.mjs b/packages/cli/tests/trackers-boot.test.mjs index d7cef73ab..a75cc3ff7 100644 --- a/packages/cli/tests/trackers-boot.test.mjs +++ b/packages/cli/tests/trackers-boot.test.mjs @@ -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 }); diff --git a/packages/harness/tests/bundle.test.mjs b/packages/harness/tests/bundle.test.mjs index b9330030e..0cd90dd70 100644 --- a/packages/harness/tests/bundle.test.mjs +++ b/packages/harness/tests/bundle.test.mjs @@ -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); diff --git a/packages/harness/tests/mcp-server.test.mjs b/packages/harness/tests/mcp-server.test.mjs index 2219730cf..c86f6ec88 100644 --- a/packages/harness/tests/mcp-server.test.mjs +++ b/packages/harness/tests/mcp-server.test.mjs @@ -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) => { diff --git a/packages/harness/tests/pi-session.test.mjs b/packages/harness/tests/pi-session.test.mjs index 95954b9d3..9482d6136 100644 --- a/packages/harness/tests/pi-session.test.mjs +++ b/packages/harness/tests/pi-session.test.mjs @@ -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"); diff --git a/packages/harness/tests/tools.test.mjs b/packages/harness/tests/tools.test.mjs index c70976301..6ab36b534 100644 --- a/packages/harness/tests/tools.test.mjs +++ b/packages/harness/tests/tools.test.mjs @@ -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) => { diff --git a/packages/webui/tests/reads.test.mjs b/packages/webui/tests/reads.test.mjs index 13de88b21..7b1d6a0a4 100644 --- a/packages/webui/tests/reads.test.mjs +++ b/packages/webui/tests/reads.test.mjs @@ -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: '' }); @@ -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' }); diff --git a/roles/assistant.json b/roles/assistant.json new file mode 100644 index 000000000..286b879f0 --- /dev/null +++ b/roles/assistant.json @@ -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": [] +} diff --git a/roles/assistant.md b/roles/assistant.md new file mode 100644 index 000000000..a7f81671f --- /dev/null +++ b/roles/assistant.md @@ -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. diff --git a/roles/chief-of-staff.json b/roles/chief-of-staff.json index 031f4b7a6..aafbd2a6e 100644 --- a/roles/chief-of-staff.json +++ b/roles/chief-of-staff.json @@ -6,7 +6,7 @@ "tools": ["read", "grep", "find", "ls"], "network": "api-only", "authority": { - "withinRole": ["message.send"], + "withinRole": ["message.send", "role.launch"], "crossRole": [] }, "credentials": [] diff --git a/roles/chief-of-staff.md b/roles/chief-of-staff.md index 977f50354..28ef82826 100644 --- a/roles/chief-of-staff.md +++ b/roles/chief-of-staff.md @@ -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. diff --git a/roles/pm.json b/roles/pm.json index 62d71befc..66525798e 100644 --- a/roles/pm.json +++ b/roles/pm.json @@ -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": [ diff --git a/roles/pm.md b/roles/pm.md index 8ef433f58..0e5409b27 100644 --- a/roles/pm.md +++ b/roles/pm.md @@ -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.