Files
jason.woltje 068d868a99 lead: commit rocko's untracked evidence (4471 files: queue-124 queue-129 queue-171 queue-178 queue-280 queue-31 queue-84 queue-85 queue-94)
Unclaimed files left in the canonical checkout. Inventory: canonical-cleanup-inventory.md (commit-as-evidence groups). detect-secrets scan clean on 2026-11-02: hits are sha256 pins, git object ids, test sentinels already in tracked tests and unit-file lines.
2026-11-02 15:05:15 -06:00

223 lines
9.5 KiB
Diff

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 `<root>/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.