diff --git a/packages/business/README.md b/packages/business/README.md index ca372c5e..ee421077 100644 --- a/packages/business/README.md +++ b/packages/business/README.md @@ -94,6 +94,23 @@ 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 + +`roles/chief-of-staff.json` is the role for the human's main agent (lead +decision 104): tools `read`, `grep`, `find` and `ls`, network `api-only`, +`message.send` within role, no cross-role actions and no credentials. +Every other action is gated, `task.create` and `role.launch` included. The +instance entry in `roles` of the business file is: + +```json +"mos": { "definition": "chief-of-staff", "holder": "mos" } +``` + +The instance id `mos` is the human's choice (lead decision 116). The +entry has no `tracker` and no `credentials`, because the role uses +neither, and the file's `launch` block must not name it. Widening the +role is a change to the role file, which the human approves. + ### 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 00000000..dbb56741 --- /dev/null +++ b/packages/business/tests/chief-of-staff.test.mjs @@ -0,0 +1,125 @@ +// M1: the chief-of-staff role (lead decision 104). Every test reads the +// shipped role file from the repository and a fixture business in its own +// temporary directory; nothing reads the real config directory. + +import { test } from "node:test"; +import assert from "node:assert/strict"; +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"; + +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; + }); +} + +// Decision 104's list, the whole of what the role may hold. +const TOOLS = ["read", "grep", "find", "ls"]; +const NETWORK = "api-only"; +const AUTHORITY = { withinRole: ["message.send"], crossRole: [] }; + +// The fixture business with a chief-of-staff instance named `chief`, as +// the README shows it, written mode 600 under `/config` and loaded +// through loadBusiness with the repository's roles. +function load(mutate) { + const root = tmp(); + const doc = businessDoc(root); + doc.roles.chief = { definition: "chief-of-staff", holder: "mos" }; + mutate?.(doc, root); + const dir = join(root, "config"); + writeJson(join(dir, "businesses", "acme.json"), doc); + return { root, business: loadBusiness("acme", { dir, rolesDir: REPO_ROLES }) }; +} + +function chief(mutate) { + const { root, business } = load(mutate); + return resolveInstance({ system: systemFor(root), business, instance: "chief" }); +} + +test("the role file holds exactly decision 104's list", () => { + const role = loadRole(REPO_ROLES, "chief-of-staff"); + assert.equal(role.roleVersion, 2); + assert.equal(role.name, "chief-of-staff"); + assert.equal(role.contractPath, join(REPO_ROLES, "chief-of-staff.md")); + assert.deepEqual([...role.tools], TOOLS); + assert.equal(role.network, NETWORK); + assert.deepEqual({ withinRole: [...role.authority.withinRole], crossRole: [...role.authority.crossRole] }, AUTHORITY); + assert.deepEqual([...role.credentials], []); +}); + +test("the instance loads and resolves through loadBusiness with that list", () => { + const { business } = load(); + assert.equal(business.roles.chief.definition, "chief-of-staff"); + assert.equal(business.roles.chief.holder, "mos"); + assert.equal(business.roles.chief.tracker, null); + assert.deepEqual({ ...business.roles.chief.credentials }, {}); + + const resolved = chief(); + assert.equal(resolved.definition, "chief-of-staff"); + assert.equal(resolved.holder, "mos"); + assert.equal(resolved.contract, join(REPO_ROLES, "chief-of-staff.md")); + assert.deepEqual(JSON.parse(JSON.stringify(resolved.limits)), { tools: TOOLS, network: NETWORK, authority: AUTHORITY }); + assert.deepEqual({ ...resolved.credentials }, {}); + assert.equal(resolved.tracker, null); + assert.equal(resolved.launch, null); +}); + +test("classify: message.send is within role, every other action is gated", () => { + const resolved = chief(); + assert.equal(classify(resolved, "message.send"), "within"); + assert.equal(classify(resolved, "task.create"), "gated"); + assert.equal(classify(resolved, "role.launch"), "gated"); + for (const action of ACTIONS.filter((a) => a !== "message.send")) { + assert.equal(classify(resolved, action), "gated", action); + } +}); + +test("a business can't grant the instance more than the role file", () => { + // An instance has no authority, tools, network or scopes key of its own. + for (const [key, value] of [ + ["authority", { withinRole: ["task.create"], crossRole: [] }], + ["tools", ["read", "write"]], + ["network", "open"], + ["scopes", ["write:issue"]], + ]) { + refuses(() => load((d) => { d.roles.chief[key] = value; }), new RegExp(`unsupported .*roles\\.chief key: "${key}"`)); + } + // No credential or bot: the role uses none. + refuses(() => load((d) => { + d.roles.chief.credentials = { gitea: { ...d.roles.pm.credentials.gitea } }; + }), /roles\.chief\.credentials must reference exactly the services its role definition needs \(none; got gitea\)/); + refuses(() => load((d) => { + d.roles.chief.tracker = { bot: "bot-acme-chief", botId: 7 }; + }), /roles\.chief has "tracker" but chief-of-staff uses no vikunja credential/); + + // Limits naming gated actions, write tools or an open network at the + // business and agent layers only narrow: nothing is added. + const widened = chief((d) => { + d.vars["limits.authority"] = ["message.send", "task.create", "role.launch", "deploy", "message.external"]; + d.vars["limits.network"] = "open"; + d.roles.chief.vars = { + "limits.authority": ["message.send", "task.create", "role.launch", "deploy", "message.external"], + "limits.tools": ["read", "grep", "find", "ls", "write", "edit", "bash"], + }; + }); + assert.deepEqual(JSON.parse(JSON.stringify(widened.limits)), { tools: TOOLS, network: NETWORK, authority: AUTHORITY }); + for (const action of ["task.create", "role.launch", "deploy", "message.external"]) { + assert.equal(classify(widened, action), "gated", action); + } + // An action outside the vocabulary refuses. + refuses(() => load((d) => { d.roles.chief.vars = { "limits.authority": ["chief.everything"] }; }), /names an unknown action: "chief\.everything"/); +}); + +test("a launch block naming this instance refuses", () => { + refuses(() => load((d) => { + d.launch = { by: "chief", instances: ["coder", "reviewer"], max: { opus: 1 } }; + }), /launch\.by names chief, whose role chief-of-staff doesn't hold role\.launch within-role/); + // The pm keeps launching, and the instance still has no role.launch. + const resolved = chief(); + assert.equal(resolved.launch, null); + assert.equal(classify(resolved, "role.launch"), "gated"); +}); diff --git a/roles/chief-of-staff.json b/roles/chief-of-staff.json new file mode 100644 index 00000000..031f4b7a --- /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 00000000..15e5deea --- /dev/null +++ b/roles/chief-of-staff.md @@ -0,0 +1,38 @@ +# 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: you talk with the human, carry their requests and rulings to +the roles that act on them, and bring back what the human needs to know. +You don't plan the work, write the product's code or review it. + +## Duties + +- Send the human's requests to the PM as messages, and ask the PM for + work. The PM decides whether a request becomes tasks and in what order. +- Relay the human's rulings verbatim. Don't summarise, soften or extend + a ruling. If one is unclear, ask the human; don't fill the gap + yourself. +- Bring decisions that need the human to the human, with the options and + who raised them, and carry the answer back. + +## Authority + +Your role file lists what you do on your own: sending messages. The +launch record you received shows the resolved list for this business and +project, which may be narrower. Everything else is gated, task creation +and launching sessions included. Ask the PM for those, or raise a +decision for the human and wait for it. + +- Never resolve a decision for the human. A decision that is the human's + stays open until the human answers it. +- Never hold or imitate human authority. A message from you is yours, + not the human's, even when it carries the human's words. +- Never route around a refusal. Report it with what you tried. + +## Protocol + +- You read files with the read-only tools your role file lists, and you + reach the network only through the APIs the broker allows. You never + hold a token. +- 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.