roles: add the assistant role, move role.launch from the PM to the chief of staff (row 119, #1613)
roles/assistant.json and assistant.md are new. The assistant holds only
message.send and relays each ruling verbatim to the chief of staff. The
chief of staff gains role.launch; the PM drops it. Test fixtures move
launch.by from "pm" to a `chief` instance.
This does not change the live host. Jason's organization file names
launch.by "pm", which the new role files refuse, so his edit and the
re-pin happen together in one Mos window (decision 144; the order is in
agents/researcher/work/queue-119/build.md).
Lands Researcher's round 2 candidate, approved by Filbert in comment
28528 (rev 1240). Candidate manifest f77c4917, all 22 files match.
Landing gate on 46117feb8: 23 suites, GATE_PASSED 01:09:35Z.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
+37
-14
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
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");
|
||||
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 launch block whose by is the chief-of-staff instance is refused", () => {
|
||||
test("a business grant of a gated action through limits does not widen the role", () => {
|
||||
// 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", "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 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) => {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 `<root>/secrets`, and a project root under `<root>`.
|
||||
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 } },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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: '<path>' });
|
||||
@@ -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' });
|
||||
|
||||
@@ -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": []
|
||||
}
|
||||
@@ -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.
|
||||
@@ -6,7 +6,7 @@
|
||||
"tools": ["read", "grep", "find", "ls"],
|
||||
"network": "api-only",
|
||||
"authority": {
|
||||
"withinRole": ["message.send"],
|
||||
"withinRole": ["message.send", "role.launch"],
|
||||
"crossRole": []
|
||||
},
|
||||
"credentials": []
|
||||
|
||||
+37
-25
@@ -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.
|
||||
|
||||
+1
-1
@@ -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": [
|
||||
|
||||
+4
-3
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user