Haiku subagent build under decision 129. Candidate manifest f72730de names roles/chief-of-staff.json and .md, the business README and packages/business/tests/chief-of-staff.test.mjs. Gate and mutants ran in Sage's slot. Co-Authored-By: Claude Opus 5.5 <[email protected]>
240 lines
9.5 KiB
Diff
240 lines
9.5 KiB
Diff
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.
|