feat(packs): the chief applies a pack's non-definition half and raises hires from its proposals (#1873 SP5)

pack.preview, pack.apply, board.set and workflow.set accept an agent session whose role
lists organization.change (Jason 11-12 Q2): the check runs before the read, and the grant
is spent once after a write commits. A role without it gets decision-required. The packs
read lists packs for a within-role holder and spends nothing.

An agent apply never writes a definition. A definition a human apply would add is marked
not-written and its hire is pinned to the template or the inline entry. A workflow step
naming such a definition is left out and listed, and a workflow left with no steps is not
written. A definitions rename or a saved template refuses human-required, with the writer's
own guard behind both.

hire.raise takes {proposal: {pack, project?, hire}, route, reason?} from the chief. The broker
reads the recorded proposal, checks an inline pin's hash (starter-pack-proposal-stale) and
launch.noHire, and hands the pin to the hire service, which refuses a moved template or
definition as decision-stale or definition-rev-conflict.
This commit is contained in:
jason.woltje committed 2026-11-12 19:05:33 -06:00
1 parent 991eb8c332
commit 5ffe38e3b2
5 files changed
+432 -16

No files matched your search

+73 -7
View File
@@ -12,6 +12,8 @@ import { NAME as HARNESS_NAME } from '../../plugins/src/kinds.mjs';
import { loadRequirements } from '../../tasks/src/requirements.mjs';
import { MERGE, LandingRefusal, readLanding } from './landing.mjs';
import { pluginName, ID_PATTERN, THREAD_DEFINITIONS, END_PROTECTED, forkProblem, forkUrl, credentialKind, validArgv, commandAllowed, HOST_ID, CREATIVE_DEFINITIONS, giteaName, parseGiteaUrl, covers, grantScopeOf, grantInScope } from '../../organization/src/vocabulary.mjs';
import { canonicalJson } from '../../organization/src/util.mjs';
import { compositionOf } from '../../organization/src/definitions.mjs';
// Kept closed while S1 lands; its vocabulary is the integration authority.
export const ACTIONS = Object.freeze([
@@ -6287,6 +6289,9 @@ export class Broker {
if (request.verb === 'hire.raise' || request.verb === 'hire.start.console' ||
(request.verb === 'decision.raise' && ['role.hire','role.fire','role.change'].includes(args.action))) {
const input=request.verb==='decision.raise' ? (()=>{ keys(args,['action','hire'],['action','hire']); if(args.action!==`role.${args.hire?.op}`)fail('invalid-request'); return args.hire; })() : args;
// #1873 SP5: a raise from a pack's recorded proposal; the pin, not the caller, names the role.
if(request.verb==='hire.raise' && object(args) && Object.hasOwn(args,'proposal'))
return Promise.resolve().then(()=>this.#raiseHire(s,this.#proposalHire(s,args),false)).catch(e=>{throw this.#refused(s,new BusError(e.code??'invalid-request'),request);});
return this.#raiseHire(s,input,request.verb==='hire.start.console').catch(e=>{throw this.#refused(s,new BusError(e.code??'invalid-request'),request);});
}
if(request.verb==='decision.preview.console' && ['role.hire','role.fire','role.change'].includes(this.#decision(s,args.id).action))return this.#hireConsole(s,'hire.preview.console',args).catch(e=>{throw this.#refused(s,new BusError(e.code??'outcome-unknown'),request);});
@@ -6301,20 +6306,43 @@ export class Broker {
if (Object.hasOwn(PACK_BUS_KEYS, request.verb)) {
if (s.tester) fail('tester-refused');
const owner = this.#business(s.business).human;
if (request.verb.endsWith('.console')) { if (!s.console || s.human !== owner) fail('human-required'); }
else { this.#human(s); if (s.human !== owner) fail('human-required'); }
keys(args, ...PACK_BUS_KEYS[request.verb]);
// #1873 SP5: the unsuffixed verbs also serve an agent whose role file lists
// organization.change, the test organization.set uses; any other role gets decision-required.
// The packs read checks the grant and never spends it, as organization.revision; an agent
// without it gets human-required. A write checks before the writer, again inside it, and
// spends the grant once the writer has committed. The actor is the definition and instance.
const agent = !request.verb.endsWith('.console') && !s.human && !s.console;
let actor = s.human, authorityError = null;
if (agent) {
keys(args, ...PACK_BUS_KEYS[request.verb]);
if (request.verb === 'packs') {
this.#processingWork(); this.#agent(s);
if (this.#classification(s, 'organization.change') !== 'within-role') fail('human-required');
} else this.#checkAuthority(s, 'organization.change');
actor = this.#business(s.business).roles[s.role].definition + '.' + s.role;
}
else {
if (request.verb.endsWith('.console')) { if (!s.console || s.human !== owner) fail('human-required'); }
else { this.#human(s); if (s.human !== owner) fail('human-required'); }
keys(args, ...PACK_BUS_KEYS[request.verb]);
}
if (!this.#organization) fail('not-configured');
const verb = request.verb.replace(/\.console$/, '');
const spends = agent && verb !== 'packs' && verb !== 'pack.preview';
const agentContext = agent ? { agent: true, beforeWrite: () => { try { this.#checkAuthority(s, 'organization.change'); } catch (e) { authorityError = e; throw e; } } } : {};
const packFail = e => { if (authorityError && e === authorityError) throw e; this.#packFail(e); };
const openHires = verb === 'pack.preview' || verb === 'pack.apply'
? this.#store.all(`SELECT d.id FROM decisions d WHERE d.business=? AND d.action='role.hire' AND NOT EXISTS(SELECT 1 FROM decision_events x WHERE x.decision=d.id AND x.op IN ${CLOSED}) ORDER BY d.at, d.id`, s.business)
.map(({ id }) => this.#raised(s.business, id)?.hire).filter(object).map(h => ({ name: h.name, project: h.project ?? null }))
: [];
let result;
try { result = this.#organization(s.business, verb, structuredClone(args), s.human, { openHires }); }
catch (e) { this.#packFail(e); }
const done = (value) => { this.#secretCheck(value); return value; };
return result?.then ? result.then(done, e => this.#packFail(e)) : done(result);
try { result = this.#organization(s.business, verb, structuredClone(args), actor, { openHires, ...agentContext }); }
catch (e) { packFail(e); }
const done = (value) => {
if (spends) this.#store.transaction(() => this.#consumeAuthority(s, 'organization.change'));
this.#secretCheck(value); return value;
};
return result?.then ? result.then(done, packFail) : done(result);
}
if (["limits", "limits.set", "organization.set", "organization.revision", "plugins", "plugins.set", "instances", "instances.set", "routes", "routes.set", "veto.set", "library.limits.set"].includes(request.verb) || Object.hasOwn(DEFINITION_BUS_KEYS, request.verb)) {
// Row 123 (#1617, REQ-COS-1, REQ-LIMIT-1): organization.set is limits.set for a role whose
@@ -6664,6 +6692,44 @@ export class Broker {
if(s.human!==this.#business(s.business).human)fail('human-required');
return {actor:s.human,agent:false,rights:['org.hire'],owner:true,instanceOwner:true,subject:s.console};
}
// #1873 SP5 (design section 3, "The chief applying a pack"): hire.raise {proposal: {pack, project?,
// hire}, route, reason?}. Only the chief gets this far. The proposal is the latest packs entry of
// that pack id and project, in the organization as loaded, whose proposedHires names the hire. Its
// pin fills op, name, level and role; a caller's own role, name, level, op, credentials,
// personality or binding refuses. A template pin and a definition pin go to prepare as recorded,
// which refuses decision-stale and definition-rev-conflict. An inline pin is checked here: the
// stored entry must still hash to the pin (starter-pack-proposal-stale), and its from template
// must not be in launch.noHire (definition-not-hireable), since prepare's own noHire check
// reads only a template or definition ref. The returned input carries no proposal key, so
// prepare's request keys are unchanged. reason defaults to the one the pack recorded.
#proposalHire(s, args) {
this.#agent(s);
const b = this.#business(s.business);
if (s.role !== b.launch?.by) fail('role-not-chief');
keys(args, ['proposal', 'route', 'reason'], ['proposal', 'route']);
const proposal = args.proposal;
keys(proposal, ['pack', 'project', 'hire'], ['pack', 'hire']);
for (const value of [proposal.pack, proposal.hire, ...(Object.hasOwn(proposal, 'project') ? [proposal.project] : [])])
if (typeof value !== 'string' || !/^[a-z0-9][a-z0-9._-]{0,63}$/.test(value)) fail('invalid-request');
const project = proposal.project ?? null;
const entry = (Array.isArray(b.packs) ? b.packs : [])
.filter(e => e?.id === proposal.pack && (e.project ?? null) === project && Array.isArray(e.proposedHires) && e.proposedHires.some(h => h?.name === proposal.hire))
.at(-1);
if (!entry) fail('invalid-request');
const hire = entry.proposedHires.find(h => h.name === proposal.hire);
const pin = hire.pin;
if (!object(pin)) fail('invalid-request');
let role;
if (Object.hasOwn(pin, 'inline')) {
if (!object(hire.entry) || typeof pin.sha256 !== 'string') fail('invalid-request');
if (sha256(canonicalJson(hire.entry)) !== pin.sha256) fail('starter-pack-proposal-stale');
const from = hire.entry.from?.template;
if (from !== undefined && (b.launch?.noHire ?? []).includes(from)) fail('definition-not-hireable');
role = { inline: compositionOf(hire.entry) };
} else role = structuredClone(pin);
const reason = Object.hasOwn(args, 'reason') ? args.reason : hire.reason;
return { op: 'hire', level: project === null ? 'org' : 'project', ...(project === null ? {} : { project }), name: hire.name, role, route: structuredClone(args.route), reason };
}
async #raiseHire(s, input, immediate) {
const b=this.#business(s.business);
// Change is a replacement composition and uses the existing role.hire grant.
+7
View File
@@ -211,6 +211,13 @@ group(
"R8: apply writes only the plan its digest names",
"The organization file or the pack changed since the preview. Preview again and apply the new digest."
);
// #1873 SP5: a hire raised from a pack proposal is the role the proposal pinned. An inline pin is the
// canonical hash of the entry stored beside it; a stored entry that no longer hashes to it refuses.
group(
"starter-pack-proposal-stale",
"R8: a hire raised from a pack proposal matches its recorded pin",
"The role entry stored with this proposal no longer matches the hash it was recorded with. Preview and apply the pack again for a fresh proposal, then raise the hire from that one."
);
group(
"not-resolver",
"Role resolve right and decision route",
+44 -8
View File
@@ -351,7 +351,18 @@ function packOps({ writer, rolesDir, dataRoot, system, registryFor, assertClean,
// A saved template may reuse a shipped id, so the origin is part of the key.
const sameApply = (e, id, origin, project) => e.id === id && e.origin === origin && (e.project ?? null) === project;
const tombstones = (doc, id, origin, project) => new Set((doc.packs ?? []).filter(e => sameApply(e, id, origin, project)).flatMap(e => e.added ?? []));
// #1873 SP5 (design section 3, "The chief applying a pack"): context.agent is an
// organization.change holder's call. It names no saved template and no definitions rename
// (human-required), and its plan writes no definition: a definition a human apply would add is
// marked not-written, and its hire proposal pins the template or the inline entry, which the chief
// raises as a hire decision. A workflow step naming a pack definition the organization does not
// hold and that is not a V2 template is left out and listed; a workflow left with no step is
// marked left-out and not written. applyPack refuses an agent's definitions or saved origin
// whatever this builds.
async function build(id, args, actor, context) {
const agent = context?.agent === true;
if (agent && typeof args.pack === 'string' && args.pack.startsWith(SAVED_PREFIX)) throw new BusError('human-required');
if (agent && Array.isArray(args.as) && args.as.some(v => typeof v === 'string' && v.startsWith('definitions.'))) throw new BusError('human-required');
const { document: doc, revision } = writer.read(id);
const found = await loadPack(args.pack, actor);
const pack = found.pack;
@@ -379,6 +390,16 @@ function packOps({ writer, rolesDir, dataRoot, system, registryFor, assertClean,
const chief = chiefOf(doc, `${id}.json`);
const registry = registryFor(doc);
const noHire = doc.launch?.noHire ?? [];
const templates = new Map();
const isTemplate = name => {
if (!templates.has(name)) {
try { readTemplate(rolesDir, name); templates.set(name, true); } catch (error) {
if (error?.name !== 'BusinessError') throw error;
templates.set(name, false);
}
}
return templates.get(name);
};
const items = [];
const plan = {};
// Definitions are organization-wide in either scope: a project's agents name org definitions.
@@ -400,6 +421,7 @@ function packOps({ writer, rolesDir, dataRoot, system, registryFor, assertClean,
const primitives = PRIMITIVES.filter(k => canonicalJson(before[k] ?? null) !== canonicalJson(after[k] ?? null));
item.diff = { primitives, instructions: primitives.includes('instructions') ? lineDiff(before.instructions, after.instructions) : [] };
}
if (agent && mark === 'add') item.mark = mark = 'not-written';
if (mark === 'add') definitions[target] = entry;
checked[name].added = mark === 'add';
items.push(item);
@@ -423,11 +445,21 @@ function packOps({ writer, rolesDir, dataRoot, system, registryFor, assertClean,
const workflow = { description: value.description, steps: value.steps.map(s => ({ title: s.title, state: s.state, roles: s.roles.map(role) })) };
if (workflow.steps.some(s => new Set(s.roles).size !== s.roles.length)) bad();
const path = `${prefix}workflows.${target}`;
const leftOut = [];
if (agent) {
const unwritable = r => r !== 'human' && Object.hasOwn(pack.definitions, r) && !Object.hasOwn(doc.definitions ?? {}, role(r)) && !isTemplate(role(r));
workflow.steps = workflow.steps.filter((s, i) => {
const missing = value.steps[i].roles.filter(unwritable).map(role);
if (missing.length) leftOut.push({ step: i + 1, title: s.title, roles: missing });
return missing.length === 0;
});
}
const current = scope.workflows?.[target];
let mark;
if (current === undefined) mark = gone.has(path) ? 'removed' : 'add';
if (agent && workflow.steps.length === 0) mark = 'left-out';
else if (current === undefined) mark = gone.has(path) ? 'removed' : 'add';
else mark = canonicalJson(current) === canonicalJson(workflow) ? 'same' : 'kept';
items.push({ item: 'workflow', name, target, path, mark });
items.push({ item: 'workflow', name, target, path, mark, ...(leftOut.length ? { leftOut } : {}) });
if (mark === 'add') workflows[target] = workflow;
}
if (Object.keys(workflows).length) plan[`${prefix}workflows`] = workflows;
@@ -453,7 +485,10 @@ function packOps({ writer, rolesDir, dataRoot, system, registryFor, assertClean,
items, hires, skills: { notEnabled }, chief: chief !== null, plan, digest,
};
}
const write = (id, plan, revision, actor, detail) => {
// context.beforeWrite (SP5) is the broker's recheck of an agent's organization.change grant,
// run once more before the writer commits.
const write = (id, plan, revision, actor, detail, context) => {
context?.beforeWrite?.();
const out = writer.applyPack(id, plan, { revision, actor, assertClean, detail });
return { revision: out.revision, added: out.added };
};
@@ -479,15 +514,15 @@ function packOps({ writer, rolesDir, dataRoot, system, registryFor, assertClean,
if (Object.keys(plan).every(k => k === 'packs') && last && last.sha256 === plan.packs.sha256
&& canonicalJson(last.proposedHires ?? []) === canonicalJson(plan.packs.proposedHires))
return { revision: preview.revision, added: [], pack: preview.pack.id };
return { ...write(id, plan, preview.revision, actor, {}), pack: preview.pack.id };
return { ...write(id, plan, preview.revision, actor, {}, context), pack: preview.pack.id };
},
'board.set': (id, args, actor) => {
'board.set': (id, args, actor, context) => {
if (args.board !== null && !isObject(args.board)) bad();
return write(id, { [scoped(args, 'board')]: args.board }, revisionOf(args), actor, {});
return write(id, { [scoped(args, 'board')]: args.board }, revisionOf(args), actor, {}, context);
},
'workflow.set': (id, args, actor) => {
'workflow.set': (id, args, actor, context) => {
if (typeof args.name !== 'string' || (args.workflow !== null && !isObject(args.workflow))) bad();
return write(id, { [scoped(args, 'workflows')]: { [args.name]: args.workflow } }, revisionOf(args), actor, {});
return write(id, { [scoped(args, 'workflows')]: { [args.name]: args.workflow } }, revisionOf(args), actor, {}, context);
},
};
}
@@ -1140,6 +1175,7 @@ export async function startBroker({
}
// #1873 SP4: the human's pack, board and workflow ops. The broker passes only the business's human;
// the reads return here, and each write reloads the organization and refreshes its limit checks.
// SP5: or an organization.change holder's actor, with context.agent and context.beforeWrite.
if (Object.hasOwn(PACK_OPS, verb)) {
const ops = packOps({ writer: organizationWriter({dir:organization.dir,rolesDir:organization.rolesDir}), rolesDir: organization.rolesDir, dataRoot: instanceRoot ?? dataRoot,
system: organization.system, registryFor: doc => organizationPlugins(instanceRoot ?? dataRoot, doc.plugins).registry, assertClean: value => credentials.assertClean(value), configDir: organization.dir });
+304
View File
@@ -0,0 +1,304 @@
// #1873 SP5: the pack, board and workflow verbs for an agent whose role file lists
// organization.change, and hire.raise from a pack's recorded proposal (design section 3, "The
// chief applying a pack", and section 8, SP5). The chief's authority is read from the shipped
// roles/chief-of-staff.json, so the test pins the grant the stack runs.
import test from 'node:test';
import assert from 'node:assert/strict';
import { chmodSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { createHash } from 'node:crypto';
import { fileURLToPath } from 'node:url';
import { DatabaseSync } from 'node:sqlite';
import { Broker } from '../src/broker.mjs';
import { Store } from '../src/store.mjs';
import { REFUSALS } from '../src/refusals.mjs';
import { pluginHash } from '../../plugins/src/pinned.mjs';
import { installPlugin } from '../../plugins/src/install.mjs';
import { starterPack as codingPack } from '../../plugins/starter-pack/coding/index.mjs';
import { hireDecisions } from '../../organization/src/hire-decisions.mjs';
import { organizationWriter } from '../../organization/src/organization.mjs';
import { loadBusiness } from '../../organization/src/organization-config.mjs';
import { compositionOf, entryFromTemplate, readTemplate, templateHash } from '../../organization/src/definitions.mjs';
import { canonicalJson } from '../../organization/src/util.mjs';
import { businessDoc, rolesCopy, systemFor, writeJson } from '../../organization/tests/helpers.mjs';
const shipped = (name) => JSON.parse(readFileSync(fileURLToPath(new URL(`../../../roles/${name}.json`, import.meta.url)), 'utf8'));
const chiefAuthority = shipped('chief-of-staff').authority;
const sha = (text) => createHash('sha256').update(text).digest('hex');
const ROUTE = { harness: 'pi', model: 'claude-opus-5-5' };
// The SP4 harness (packs.test.mjs) with the shipped chief grant and a coder without it.
async function harness(t, { mutate = () => {} } = {}) {
const { startBroker } = await import('../src/runtime.mjs');
const root = mkdtempSync(join(tmpdir(), 'packs5-'));
const rolesDir = rolesCopy(root);
const doc = businessDoc(root, 'demo');
doc.plugins = { harness: ['pi'] };
mutate(doc);
writeJson(join(root, 'businesses/demo.json'), doc);
const data = join(root, 'data');
mkdirSync(data, { mode: 0o700 });
const roles = {
chief: { definition: 'chief-of-staff', authority: chiefAuthority },
coder: { definition: 'coder', authority: { withinRole: ['message.send'], crossRole: [] } },
};
const businesses = { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, roles } };
const bus = await startBroker({ dataRoot: data, businesses, organization: { dir: root, rolesDir, system: systemFor(root) } });
const thaw = (p) => { const st = lstatSync(p); if (st.isSymbolicLink()) return; if (st.isDirectory()) { chmodSync(p, 0o700); for (const e of readdirSync(p)) thaw(join(p, e)); } };
t.after(async () => { await bus.close(); thaw(root); rmSync(root, { recursive: true, force: true }); });
const b = bus.broker;
const human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
const chief = b.bindLaunch({ business: 'demo', role: 'chief', run: 'chief-run', harness: 'pi', address: 'chief-run' });
const coder = b.bindLaunch({ business: 'demo', role: 'coder', run: 'coder-run', harness: 'pi', address: 'coder-run' });
b.request(chief, { verb: 'role.claim' });
b.request(coder, { verb: 'role.claim' });
const call = async (cap, verb, args = {}) => b.request(cap, { verb, args });
const path = join(root, 'businesses/demo.json');
const file = () => JSON.parse(readFileSync(path, 'utf8'));
const revision = () => b.request(human, { verb: 'limits' }).revision;
let n = 0;
const install = (pack) => {
const src = join(root, `src-${n++}`);
mkdirSync(join(src, 'starter-pack'), { recursive: true });
writeFileSync(join(src, 'starter-pack', 'index.mjs'), `export const interfaceVersion = 1;\nexport function starterPack() { return ${JSON.stringify(pack)}; }\n`);
const manifest = { name: pack.id, version: '1.0.0', kinds: ['starter-pack'], interfaceVersions: { 'starter-pack': 1 }, sha256: '0'.repeat(64),
source: { path: 'fixture', version: '1.0.0' }, signature: null, tools: [], actions: [], services: [], roleTemplates: [] };
writeFileSync(join(src, 'plugin.json'), JSON.stringify(manifest));
manifest.sha256 = pluginHash(src);
writeFileSync(join(src, 'plugin.json'), JSON.stringify(manifest));
installPlugin({ dataRoot: data, source: src, expectedHash: manifest.sha256 });
};
// The broker's own database, read-only: grants spent and decisions raised.
const query = (sql) => { const db = new DatabaseSync(join(data, 'bus/bus.sqlite'), { readOnly: true }); try { return db.prepare(sql).all(); } finally { db.close(); } };
const spent = () => query("SELECT body FROM events WHERE kind='action.allowed' ORDER BY seq").map(e => JSON.parse(e.body)).filter(e => e.action === 'organization.change').length;
const decisions = () => query('SELECT count(*) n FROM decisions')[0].n;
return { b, root, rolesDir, path, human, chief, coder, call, file, revision, install, spent, decisions };
}
const variant = (id, patch = () => {}) => { const p = structuredClone(codingPack()); p.id = id; patch(p); return p; };
const marks = (preview) => Object.fromEntries(preview.items.map(i => [i.path, i.mark]));
test('SP5 the chief applies the coding pack: board and workflows written, no definitions, template pins, one grant spent', async t => {
const { call, chief, file, path, rolesDir, spent, decisions } = await harness(t);
const before = file();
const preview = await call(chief, 'pack.preview', { pack: 'coding' });
assert.equal(spent(), 0);
assert.deepEqual(readFileSync(path, 'utf8'), JSON.stringify(before, null, 2) + '\n');
// A definition a human apply would add is not written for an agent; its hire pins the template.
for (const name of ['coder', 'cto', 'merge-gate', 'pm', 'reviewer', 'security-reviewer']) assert.equal(marks(preview)[`definitions.${name}`], 'not-written');
assert.equal(marks(preview).board, 'add');
assert.equal(marks(preview)['workflows.change'], 'add');
assert.equal(preview.plan.definitions, undefined);
for (const h of preview.hires) {
assert.equal(h.mark, 'propose');
assert.deepEqual(h.pin, { template: h.definition, sha256: templateHash(rolesDir, h.definition) });
}
await call(chief, 'pack.apply', { pack: 'coding', digest: preview.digest });
const after = file();
assert.equal(after.board.layout, 'kanban');
assert.deepEqual(after.board, codingPack().board);
assert.deepEqual(after.workflows.change.steps.map(s => s.roles), [['pm'], ['coder'], ['reviewer'], ['human', 'merge-gate']]);
assert.deepEqual(after.definitions ?? null, before.definitions ?? null);
assert.equal(after.packs.length, 1);
assert.equal(after.packs[0].by, 'chief-of-staff.chief');
assert.deepEqual(after.packs[0].added.sort(), ['board', 'workflows.change']);
assert.deepEqual(after.packs[0].proposedHires.map(h => [h.name, h.pin.template]), [['pm-1', 'pm'], ['coder-1', 'coder'], ['reviewer-1', 'reviewer']]);
assert.equal(spent(), 1);
assert.equal(decisions(), 0);
});
test('SP5 a role without organization.change gets decision-required for the writes and human-required for the packs read', async t => {
const { call, coder, chief, path, revision, install, spent } = await harness(t);
const before = readFileSync(path);
for (const [verb, args] of [['pack.apply', { pack: 'coding', digest: 'a'.repeat(64) }], ['board.set', { revision: revision(), board: null }],
['workflow.set', { revision: revision(), name: 'x', workflow: null }], ['pack.preview', { pack: 'coding' }]])
await assert.rejects(call(coder, verb, args), /decision-required/, verb);
await assert.rejects(call(coder, 'packs'), /human-required/);
// The console names stay the owner's.
await assert.rejects(call(chief, 'pack.preview.console', { pack: 'coding' }), /human-required/);
await assert.rejects(call(chief, 'packs.console'), /human-required/);
assert.deepEqual(readFileSync(path), before);
// The chief's packs read lists shipped and installed packs and spends nothing.
install(variant('teamkit'));
const listed = (await call(chief, 'packs')).packs;
assert.ok(listed.some(p => p.id === 'coding' && p.origin === 'shipped'));
assert.ok(listed.some(p => p.id === 'teamkit' && p.origin === 'installed'));
assert.equal(spent(), 0);
// The agent request carries no origin, actor or unknown key.
await assert.rejects(call(chief, 'pack.preview', { pack: 'coding', actor: 'jason' }), /invalid-request/);
await assert.rejects(call(chief, 'packs', { all: true }), /invalid-request/);
});
test('SP5 the chief writes a board and a workflow, each spending the grant once, with its own actor', async t => {
const { call, chief, file, revision, spent } = await harness(t);
await call(chief, 'board.set', { revision: revision(), board: codingPack().board });
assert.deepEqual(file().board, codingPack().board);
assert.equal(spent(), 1);
const workflow = { description: 'Intake.', steps: [{ title: 'Take it', state: 'todo', roles: ['human'] }, { title: 'Build it', state: 'in-progress', roles: ['coder'] }] };
await call(chief, 'workflow.set', { revision: revision(), name: 'intake', workflow });
assert.deepEqual(file().workflows.intake, workflow);
assert.equal(spent(), 2);
await call(chief, 'workflow.set', { revision: revision(), name: 'intake', workflow: null });
assert.equal(file().workflows, undefined);
assert.equal(spent(), 3);
// A refused write spends nothing.
await assert.rejects(call(chief, 'board.set', { revision: 'b'.repeat(64), board: null }), /organization-|invalid-request|stale|conflict/);
await assert.rejects(call(chief, 'workflow.set', { revision: revision(), name: 'x', workflow: { description: 'x', steps: [{ title: 'Do', state: 'todo', roles: ['nobody-at-all'] }] } }), /invalid-request/);
assert.equal(spent(), 3);
});
test('SP5 an agent apply never writes a definition or a saved template: human-required', async t => {
const { call, chief, path, root, rolesDir } = await harness(t);
const before = readFileSync(path);
// A definitions rename names a definition to write.
await assert.rejects(call(chief, 'pack.preview', { pack: 'coding', as: ['definitions.coder=dev'] }), /human-required/);
await assert.rejects(call(chief, 'pack.apply', { pack: 'coding', as: ['definitions.coder=dev'], digest: 'a'.repeat(64) }), /human-required/);
// A saved template refuses before anything is read; the name need not exist.
await assert.rejects(call(chief, 'pack.preview', { pack: 'saved:x' }), /human-required/);
await assert.rejects(call(chief, 'pack.apply', { pack: 'saved:x', digest: 'a'.repeat(64) }), /human-required/);
// A workflows rename is allowed.
const renamed = await call(chief, 'pack.preview', { pack: 'coding', as: ['workflows.change=ship'] });
assert.equal(marks(renamed)['workflows.ship'], 'add');
assert.deepEqual(readFileSync(path), before);
// The writer's own fence: the chief's actor with a definition in the plan, or a saved origin.
const writer = organizationWriter({ dir: root, rolesDir });
const { rev: _rev, ...entry } = entryFromTemplate(readTemplate(rolesDir, 'coder'));
const pack = { id: 'kit', version: '1.0.0', sha256: 'a'.repeat(64), origin: 'installed', scope: 'org', project: null, proposedHires: [] };
const opts = () => ({ revision: writer.read('demo').revision, actor: 'chief-of-staff.chief', assertClean: () => {}, detail: {} });
assert.throws(() => writer.applyPack('demo', { definitions: { dev: entry }, packs: pack }, opts()), /human-required/);
assert.throws(() => writer.applyPack('demo', { board: codingPack().board, packs: { ...pack, origin: 'saved' } }, opts()), /human-required/);
assert.deepEqual(readFileSync(path), before);
});
test('SP5 a workflow step naming a definition the agent apply cannot write is left out and marked', async t => {
const { call, chief, file, install, rolesDir } = await harness(t);
const { rev: _rev, ...scribe } = entryFromTemplate(readTemplate(rolesDir, 'coder'));
install(variant('scribekit', p => {
p.definitions = { scribe, pm: p.definitions.pm };
p.hires = [{ name: 'scribe-1', definition: 'scribe', reason: 'Take notes.' }];
p.workflows = {
notes: { description: 'Notes.', steps: [{ id: 'plan', title: 'Plan it', state: 'todo', roles: ['pm'] }, { id: 'write', title: 'Write it', state: 'in-progress', roles: ['scribe', 'human'] }] },
solo: { description: 'Solo.', steps: [{ id: 'write', title: 'Write it', state: 'todo', roles: ['scribe'] }] },
};
}));
const preview = await call(chief, 'pack.preview', { pack: 'scribekit' });
assert.equal(marks(preview)['definitions.scribe'], 'not-written');
const items = Object.fromEntries(preview.items.map(i => [i.path, i]));
assert.equal(items['workflows.notes'].mark, 'add');
assert.deepEqual(items['workflows.notes'].leftOut, [{ step: 2, title: 'Write it', roles: ['scribe'] }]);
assert.equal(items['workflows.solo'].mark, 'left-out');
assert.deepEqual(items['workflows.solo'].leftOut, [{ step: 1, title: 'Write it', roles: ['scribe'] }]);
assert.deepEqual(Object.keys(preview.plan.workflows), ['notes']);
// The full-entry definition's hire pins the inline entry.
const hire = preview.hires.find(h => h.name === 'scribe-1');
assert.deepEqual(hire.pin, { inline: 'scribe', sha256: sha(canonicalJson(scribe)) });
await call(chief, 'pack.apply', { pack: 'scribekit', digest: preview.digest });
assert.deepEqual(file().workflows, { notes: { description: 'Notes.', steps: [{ title: 'Plan it', state: 'todo', roles: ['pm'] }] } });
assert.equal(file().definitions?.scribe, undefined);
assert.deepEqual(file().packs.at(-1).proposedHires[0].entry, scribe);
});
// The hire-decisions.test.mjs fixture: a Broker on the loaded organization and the hire service.
function fixture(t) {
const root = mkdtempSync(join(tmpdir(), 'sp5-bus-')), rolesDir = rolesCopy(root);
const file = writeJson(join(root, 'businesses/demo.json'), { ...businessDoc(root, 'demo'), plugins: { harness: ['pi'] } });
const storeRoot = join(root, 'data'); mkdirSync(storeRoot, { mode: 0o700 });
const writer = organizationWriter({ dir: root, rolesDir }), store = new Store(storeRoot);
const service = hireDecisions({ writer, rolesDir, dataRoot: storeRoot, configDir: root, assertClean: () => {} });
const loaded = () => { const doc = loadBusiness('demo', { dir: root, rolesDir }); return { ...doc, roles: Object.fromEntries(Object.entries(doc.roles).map(([n, e]) => [n, { ...doc.definitions[e.definition], ...e }])) }; };
const b = new Broker({ store, businesses: { demo: loaded() }, hires: service });
const caps = { chief: b.bindLaunch({ business: 'demo', role: 'chief', run: 'chief-run', harness: 'pi' }), coder: b.bindLaunch({ business: 'demo', role: 'coder', run: 'coder-run', harness: 'pi' }) };
b.request(caps.chief, { verb: 'role.claim' }); b.request(caps.coder, { verb: 'role.claim' });
t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
const call = (cap, verb, args = {}) => b.request(cap, { verb, args });
// A packs entry as an owner's apply records it, then the broker's organization reloads.
const record = (plan) => {
writer.applyPack('demo', plan, { revision: writer.read('demo').revision, actor: 'jason', assertClean: () => {}, detail: {} });
b.reloadBusiness('demo', loaded());
};
const pack = (proposedHires, extra = {}) => ({ id: 'kit', version: '1.0.0', sha256: 'a'.repeat(64), origin: 'installed', scope: 'org', project: null, proposedHires, ...extra });
// Edit the organization file directly, as a later change would, then reload.
const edit = (change) => { const doc = JSON.parse(readFileSync(file, 'utf8')); change(doc); writeJson(file, doc); b.reloadBusiness('demo', loaded()); };
const raise = (proposal, extra = {}) => call(caps.chief, 'hire.raise', { proposal, route: ROUTE, ...extra });
const count = () => store.get('SELECT count(*) n FROM decisions').n;
return { root, rolesDir, b, store, file, writer, caps, call, record, pack, edit, raise, count, loaded };
}
test('SP5 a hire raised from a template proposal carries the broker-computed role and lines, as a direct raise does', async t => {
const { rolesDir, record, pack, raise, call, caps, count } = fixture(t);
const pin = { template: 'reviewer', sha256: templateHash(rolesDir, 'reviewer') };
record({ packs: pack([{ name: 'scout', reason: 'Review the pack work.', pin }]) });
const d = await raise({ pack: 'kit', hire: 'scout' });
assert.equal(d.hire.name, 'scout');
assert.equal(d.hire.level, 'org');
// The lines are the broker's, the same as a direct raise of the same role computes.
const direct = await call(caps.chief, 'hire.raise', { op: 'hire', level: 'org', name: 'scout-b', role: pin, route: ROUTE, reason: 'Review.' });
assert.deepEqual(d.hire.role, direct.hire.role);
assert.deepEqual(d.hire.flagged, direct.hire.flagged);
assert.deepEqual(d.hire.credentialLines, direct.hire.credentialLines);
assert.ok(Array.isArray(d.hire.credentialLines));
assert.equal(count(), 2);
// hire.raise requires a reason and stores none. The raise above sent none, so the recorded one
// filled it; a caller's own reason replaces it, and an empty one refuses as a direct raise does.
await assert.rejects(raise({ pack: 'kit', hire: 'scout' }, { reason: '' }), { code: 'invalid-request' });
assert.equal((await raise({ pack: 'kit', hire: 'scout' }, { reason: 'Mine.' })).hire.name, 'scout');
assert.equal(count(), 3);
});
test('SP5 a proposal raise: role-not-chief, decision-stale, definition-rev-conflict, starter-pack-proposal-stale, definition-not-hireable', async t => {
const { rolesDir, record, pack, raise, call, caps, edit, count } = fixture(t);
const { rev: _rev, ...scribe } = entryFromTemplate(readTemplate(rolesDir, 'coder'));
const inline = { name: 'inl', reason: 'Inline.', pin: { inline: 'scribe', sha256: sha(canonicalJson(scribe)) }, entry: scribe };
record({
definitions: { helper: scribe },
packs: pack([
{ name: 'moved', reason: 'Moved.', pin: { template: 'reviewer', sha256: 'b'.repeat(64) } },
{ name: 'def', reason: 'Def.', pin: { definition: 'helper', rev: 1 } },
inline,
]),
});
// Only the chief raises, with or without a proposal.
await assert.rejects(call(caps.coder, 'hire.raise', { proposal: { pack: 'kit', hire: 'def' }, route: ROUTE }), { code: 'role-not-chief' });
await assert.rejects(raise({ pack: 'kit', hire: 'moved' }), { code: 'decision-stale' });
edit(doc => { doc.definitions.helper = { ...doc.definitions.helper, rev: 2, instructions: doc.definitions.helper.instructions + '\nAlso this.' }; });
await assert.rejects(raise({ pack: 'kit', hire: 'def' }), { code: 'definition-rev-conflict' });
// The inline entry stored with the proposal still hashes to its pin, so it raises.
const ok = await raise({ pack: 'kit', hire: 'inl' });
assert.equal(ok.hire.name, 'inl');
const direct = await call(caps.chief, 'hire.raise', { op: 'hire', level: 'org', name: 'inl-b', role: { inline: compositionOf(scribe) }, route: ROUTE, reason: 'Inline.' });
assert.deepEqual(ok.hire.role, direct.hire.role);
assert.equal(count(), 2);
// A later edit to the stored entry is caught at the raise.
edit(doc => { doc.packs[0].proposedHires[2].entry.instructions += '\nAnd more.'; });
await assert.rejects(raise({ pack: 'kit', hire: 'inl' }), { code: 'starter-pack-proposal-stale' });
// An inline entry from a template launch.noHire names refuses, though prepare reads no ref for it.
edit(doc => { doc.packs[0].proposedHires[2].entry = scribe; doc.launch.noHire = ['coder']; });
await assert.rejects(raise({ pack: 'kit', hire: 'inl' }), { code: 'definition-not-hireable' });
assert.equal(count(), 2);
});
test('SP5 a proposal raise names no role, name, level or other hire key; unknown proposals refuse; a plain raise is unchanged', async t => {
const { rolesDir, record, pack, raise, call, caps, count } = fixture(t);
const pin = { template: 'reviewer', sha256: templateHash(rolesDir, 'reviewer') };
record({ packs: pack([{ name: 'scout', reason: 'Review.', pin }]) });
for (const extra of [{ role: pin }, { name: 'other' }, { level: 'org' }, { op: 'hire' }, { credentials: {} }, { personality: 'x' }, { binding: {} }])
await assert.rejects(raise({ pack: 'kit', hire: 'scout' }, extra), { code: 'invalid-request' }, JSON.stringify(extra));
for (const proposal of [{ pack: 'kit' }, { pack: 'kit', hire: 'nobody' }, { pack: 'other', hire: 'scout' }, { pack: 'kit', hire: 'scout', project: 'stack' },
{ pack: 'kit', hire: 'scout', role: 'x' }, { pack: 'kit', hire: 7 }, 'kit'])
await assert.rejects(raise(proposal), { code: 'invalid-request' }, JSON.stringify(proposal));
await assert.rejects(call(caps.chief, 'hire.raise', { proposal: { pack: 'kit', hire: 'scout' } }), { code: 'invalid-request' });
assert.equal(count(), 0);
// Without proposal, hire.raise is what it was.
const INLINE = { tools: ['read'], network: 'none', authority: { withinRole: [], crossRole: [] }, credentials: [], instructions: 'Read and report.' };
const d = await call(caps.chief, 'hire.raise', { op: 'hire', level: 'org', name: 'plain', role: { inline: INLINE }, route: ROUTE, reason: 'Need help' });
assert.equal(d.hire.name, 'plain');
assert.equal(count(), 1);
});
test('SP5 starter-pack-proposal-stale is in the refusal table with a rail and a next step that names no path', () => {
const c = 'starter-pack-proposal-stale';
assert.ok(Object.hasOwn(REFUSALS, c));
assert.ok(REFUSALS[c].rail?.length > 5);
assert.ok(REFUSALS[c].nextStep?.length > 20);
assert.ok(!/(^|[\s("'`])(\/|~\/)/.test(REFUSALS[c].nextStep));
});
+4 -1
View File
@@ -318,7 +318,10 @@ test('SP4 who may call: the owner only; the tester, a member, an agent and a con
['pack.apply', { pack: 'coding', digest: 'a'.repeat(64) }], ['board.set', { revision: revision(), board: null }]])
assert.throws(() => b.request(member, { verb, args }), /human-required/);
const agent = b.bindLaunch({ business: 'demo', role: 'coder', run: 'coder-run', harness: 'pi', address: 'coder-run' });
assert.throws(() => b.request(agent, { verb: 'pack.apply', args: { pack: 'coding', digest: 'a'.repeat(64) } }), /human-required/);
// SP5: an agent reaches the unsuffixed names through its role's organization.change; the coder
// lists none, so its write needs a decision (packs-agent.test.mjs covers the chief).
b.request(agent, { verb: 'role.claim' });
assert.throws(() => b.request(agent, { verb: 'pack.apply', args: { pack: 'coding', digest: 'a'.repeat(64) } }), /decision-required/);
assert.throws(() => b.request(agent, { verb: 'pack.preview.console', args: { pack: 'coding' } }), /human-required/);
// The owner's console cap calls the .console names; the cli cap the unsuffixed ones.
await assert.rejects(call('pack.preview', { pack: 'coding' }), /human-required/);