From 7be720fd72255cbf9ede1b3cc2553dbba04e466d Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Tue, 13 Oct 2026 10:38:03 -0500 Subject: [PATCH] roles: chief-of-staff role file, contract and tests, row 84 (#1578) Lands the Haiku subagent build from agents/researcher/work/queue-84/r1/ (candidate manifest f72730de, base ac6b56ee). roles/chief-of-staff.json and .md follow decision 104's list; packages/business/tests/ chief-of-staff.test.mjs covers the contract, and the business README names the role. Approved by Filbert (comment 28176) and Darkwing (comment 28183). Sage's gate on 11bf4cb0 plus the patch: 14 node suites and all nine scripts/test-*.sh green, 15:33:26-15:37:31Z. Co-Authored-By: Claude Opus 5.5 --- packages/business/README.md | 21 +++ .../business/tests/chief-of-staff.test.mjs | 133 ++++++++++++++++++ roles/chief-of-staff.json | 13 ++ roles/chief-of-staff.md | 43 ++++++ 4 files changed, 210 insertions(+) create mode 100644 packages/business/tests/chief-of-staff.test.mjs create mode 100644 roles/chief-of-staff.json create mode 100644 roles/chief-of-staff.md diff --git a/packages/business/README.md b/packages/business/README.md index ca372c5ed..8c7a43350 100644 --- a/packages/business/README.md +++ b/packages/business/README.md @@ -94,6 +94,27 @@ 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 + +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. + +```json +"mos": { "definition": "chief-of-staff", "holder": "mos" } +``` + +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`. + ### Project file ```json diff --git a/packages/business/tests/chief-of-staff.test.mjs b/packages/business/tests/chief-of-staff.test.mjs new file mode 100644 index 000000000..08374d122 --- /dev/null +++ b/packages/business/tests/chief-of-staff.test.mjs @@ -0,0 +1,133 @@ +// 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. + +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"; + +// Decision 104's list, as approved. 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([]) }), + credentials: Object.freeze([]), +}); + +// A fixture instance name. The shipped entry for Jason's business is `mos`. +const INSTANCE = "chief"; + +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; + }); +} + +// 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. +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 }); +} + +test("the role file loads and resolves through loadBusiness", () => { + const fixture = loadFixture(); + const role = loadRole(REPO_ROLES, "chief-of-staff"); + assert.equal(role.roleVersion, 2); + assert.equal(role.name, "chief-of-staff"); + assert.equal(role.contract, "chief-of-staff.md"); + assert.equal(role.contractPath, join(REPO_ROLES, "chief-of-staff.md")); + + 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); +}); + +test("the shipped file is exactly decision 104's list", () => { + const raw = JSON.parse(readFileSync(join(REPO_ROLES, "chief-of-staff.json"), "utf8")); + const { title, ...rest } = raw; + assert.equal(typeof title, "string"); + assert.deepEqual(rest, { + roleVersion: 2, + name: "chief-of-staff", + contract: "chief-of-staff.md", + ...APPROVED, + }); +}); + +test("the resolved record matches decision 104, so a widened file fails", () => { + const record = resolved(loadFixture()); + assert.deepEqual(record.limits, { + tools: APPROVED.tools, + network: APPROVED.network, + authority: APPROVED.authority, + }); + assert.deepEqual(record.credentials, {}); +}); + +test("message.send is within role and 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, "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"); + 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", () => { + refuses( + () => loadFixture((doc) => { + doc.launch = { by: INSTANCE, instances: ["coder"], max: { sonnet: 1 } }; + }), + /launch\.by names chief, whose role chief-of-staff 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-chief", botId: 7 }; + }), + /has "tracker" but chief-of-staff uses no vikunja credential/, + ); +}); diff --git a/roles/chief-of-staff.json b/roles/chief-of-staff.json new file mode 100644 index 000000000..031f4b7a6 --- /dev/null +++ b/roles/chief-of-staff.json @@ -0,0 +1,13 @@ +{ + "roleVersion": 2, + "name": "chief-of-staff", + "title": "Chief of staff", + "contract": "chief-of-staff.md", + "tools": ["read", "grep", "find", "ls"], + "network": "api-only", + "authority": { + "withinRole": ["message.send"], + "crossRole": [] + }, + "credentials": [] +} diff --git a/roles/chief-of-staff.md b/roles/chief-of-staff.md new file mode 100644 index 000000000..977f50354 --- /dev/null +++ b/roles/chief-of-staff.md @@ -0,0 +1,43 @@ +# 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 +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. + +## 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 +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. + +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. + +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. + +## 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.