feat(bus,cli,organization,webui): applying a starter pack is the hire decision, hires fall back to the assistant's route (#1873) #1963

Merged
jason.woltje merged 4 commits from pdpr2 into refactor 2026-11-15 01:01:56 +00:00
27 changed files with 1274 additions and 73 deletions

No files matched your search

+2
View File
@@ -346,6 +346,8 @@ For local development on a named instance only, `mosaic bus start BUSINESS --dev
The hire form and the autonomy panel from a terminal (E2, #1873, P1-3). `mosaic org role-templates|hire|fire|change ORG` and `mosaic org autonomy ORG [set|review]` send the page's own request to the running Console's org route (`/api/orgs/ORG/decisions/hire/templates|start`, `/api/orgs/ORG/autonomy[/review]`), as `org reload` does, so the Console applies the same owner, `--send`, origin, body and live-session checks and the same bus verbs run (`hire.templates.console`, `hire.start.console`, `autonomy.get.console`, `autonomy.set.console`, `autonomy.review.console`). `hire` and `change` read the templates first and send the chosen template's name and sha256, as the form does. `autonomy set` needs the 64-hex `--revision` from `mosaic org autonomy ORG` and sends every setting the panel sends, with only the named ones changed; a stale revision is refused `revision-conflict`; an empty `--authority-resolver ""` clears the resolver, as emptying the panel's field does. These verbs need a named instance (`MOSAIC_CONFIG` set to `~/.config/mosaic-NAME/config.json`) whose Console runs with `--dev-local-owner`; the default instance is refused with exit 3. A Console refusal exits 3 with the Console's code and message; `outcome-unknown` exits 1; an unknown template exits 2. Every one of these verbs, reads included, refuses inside an agent run. `mosaic tasks create|edit|archive` are the task board's three writes: the arguments pass the board's own check (`workspace-tasks.mjs parseTaskWrite`) and go to the bus as the person (`tasks.create|edit|archive`, human transport), so the bus runs its human, field, digest and archive checks. A stale `--expect` is refused `task-conflict`; read the digest with `mosaic tasks --json`.
Starter packs (#1873): `mosaic org pack list`, `mosaic org pack preview ID PACK [--project P]`, `mosaic org pack apply ID PACK --digest D [--project P]` and `mosaic org pack hire ID PACK [--project P]` call the bus as the organization's human (`packs`, `pack.preview`, `pack.apply`, `pack.hire`). Ruling 2026-11-14 ("If I choose a starter pack and apply it, that is a decision!"): a person's apply is the hire decision. After the pack commits, the same act hires each proposed agent through the hire-decisions path and the receipt's `hires` lists `{name, state, decision | code}` per proposal (`hired`, `exists`, `open`, `awaiting-owner`, `refused`); a `hired` or `awaiting-owner` line adds `routeFrom: 'chief' | 'assistant'`. The owner's apply approves the flagged and credential lines; a credential line is approved but not bound, so the agent's launch refuses `credential-unbound` until its services are bound. The bus verb `pack.hire.console` also takes a member's console cap with `org.hire`: it hires only the proposals with no flagged or credential line, and the rest wait for the owner in the inbox. No member surface calls it yet: the WebUI packs routes and the CLI `pack` words are the owner's. An owner's pack hire also replaces and approves a hire this pack's own member hire left in the inbox; the old decision is withdrawn only after the new hire applies. Two pack hires on one organization at once: the second refuses `organization-busy`; an apply whose hire loop did not run still answers the apply's receipt with `hireError` and no hires. An agent's apply hires nothing. `pack hire` reruns the loop on a pack already applied and hires none twice; it refuses `pack-not-applied` when the pack has no packs entry, and a hire runs as the chief of staff's harness and model, else the organization assistant's (the instance whose definition is `assistant`), and a proposal refuses `pack-hire-route-missing` only when neither has both. All pack words refuse inside an agent run (exit 3). The WebUI pack card's "Hire the N proposed agents" is `POST /api/orgs/<id>/packs/hire`. Details: `packages/cli/README.md`, `packages/bus/README.md` and `packages/webui/README.md`, "Starter pack hires"; design: `docs/plans/2026-11-12_starter-packs-design.md` section 3.
The bus and Console lifecycle verbs refuse inside an agent run (agent-guard, #1873): `mosaic bus start`, `bus stop`, `bus restart` and `bus recover`, and `mosaic console start`, `console stop` and `console restart`, and `mosaic org archive` and `org delete`, which stop the organization's bus, and `mosaic org restore`, which starts it (`org restore --no-bus` starts nothing and is not refused), and `mosaic org role-templates`, `hire`, `fire`, `change` and `autonomy` (E2). Each exits 3 with `refused: this runs only from a human shell, outside any agent run (found MARKER in the environment)` before it reads anything. It checks the environment it is given and its own process environment, so a sanitized environment does not hide a marker. A usage error (a missing or extra argument, `--pm` with `--no-launch`, or a bad `--dev-insecure-bind`/`--dev-allow-host`) still comes first (exit 4). `mosaic bus status` and `mosaic console status` stay open to agents. The Console's Restart bus and Restart Console controls keep working: the Console starts its bus host with its own environment, which carries no marker, and Restart Console relaunches the Console under its supervisor without going through the CLI. The markers are the ones `packages/cli/src/transport.mjs` lists. The check is cooperative: a process that drops the markers from its environment is not stopped by it.
Refusal lines (E2E6, #1873). A refusal that has a next step prints it on a second line, `mosaic: next: ...`, after the unchanged message, so callers that match the message keep working. `mosaic bus status`, `start`, `stop` and `restart` on a stale bus host lock exit 3 with `mosaic: stale bus host lock ...` and `mosaic: next: if the bus host died, clear it with mosaic bus recover ORG; recover refuses while the host still runs`. A human-transport verb whose bus is not running (no socket, or the connect refused before anything was sent) exits 3 with `bus-not-running: the bus for ORG is not running, so the request was not sent` and a next line naming `mosaic bus start ORG` and `mosaic bus recover ORG`; `outcome-unknown` (exit 1) stays for a request that left and got no answer. `mosaic org definitions` with an unknown organization, an unknown or malformed name, or a bad id prints one refusal line (exit 3; `unknown-organization` only when the organization file itself is missing, `definitions-unreadable` when `templates` cannot read the roles directory) or, for a bad id, the usage (exit 4), with no stack trace and no host path. Pack, board and workflow refusals carry their next step as `template save` does. `mosaic console start` and `console stop` clear a `console/start.lock` whose named supervisor (pid and start time) is gone, once, with a `cleared a console start lock left by process PID, which is gone` warning; a lock whose holder still runs, or that names no holder, refuses and stays. A start or stop releasing its lock marks it released through its own descriptor and removes the path only while it is still that file, so a lock put back after its release is cleared the same way, with a `cleared a console start lock its holder had released` warning. An `autonomy-precondition` refusal from the bus carries the check that failed (owner notices off, no tested reverse, a resolver without the right, no `review.verdict` holder, no enforced budget, out of the loop only from near the loop, the trail review or near-loop decision missing); the human-transport child writes only the code, so a terminal `org autonomy set` shows the Console's message. `mosaic org template save` writes free text verbatim: it never rewrites prose, and the preview lists every field that mentions the organization id, a project id, an agent name or the owner's account, with the names it mentions, for the owner to edit before sharing. Saving under a name already saved for the account refuses `template-exists` (exit 2, nothing written) with a next line to save under another name or list the saved ones with `mosaic org template list ORG`.
@@ -374,6 +374,48 @@ exactly as if a person had made them by hand. The steps:
`projects.<p>.agents`; those entries refuse instance credential material
(`BOUND_KEYS`, `agent-levels.mjs:21`).
**2026-11-14 change (#1873, lane pd).** Jason's ruling: "Damnit. If I
choose a starter pack and apply it, that is a decision!" This replaces
"Apply never hires" above for a person's apply. The `packs` entry and the
proposals stay as written; what changes is who acts on them:
- A person's apply (`pack.apply.console` from the WebUI, `pack.apply` from
the CLI) is the hire decision. In the same act, after the pack's commit,
the broker raises one hire decision per proposal through the
hire-decisions path and resolves it, leaving the evidence an inbox
approval leaves: the decision row, its resolved event, `human.input`,
`hire.consume`, the `hire.apply` event and `action.allowed pack.hire`.
The hired entry carries `hireDecision`.
- Invariant 7 still decides each line. The owner's apply approves the
flagged and credential lines. A member with `org.hire` hires only the
proposals with neither a flagged nor a credential line; each of the
others is raised as an open decision that waits for the owner in the
inbox (`awaiting-owner`). The owner's later pack hire withdraws such a
decision and hires the agent itself. An
agent apply hires nothing: the hire stays a proposal for the chief, and
a hire verb from an agent is refused `human-required`.
- Credentials take the smallest rule. The hire writes the entry with no
credential material, so its services show `unbound` and a launch is
refused `credential-unbound` until someone binds them. The owner's
approval covers the credential lines; it binds nothing and reads no
secret.
- The route is the chief of staff's resolved harness and model. With
none, that proposal is refused `pack-hire-route-missing` and stays open.
Note 2026-11-14 (lead ruling, lane pr2): the route is the chief's, else
the organization assistant's (the instance whose definition is
`assistant`; the one named `assistant` when there are several, else the
first in key order), and the refusal applies only when neither resolves
a harness and a model. Hire lines carry `routeFrom`.
- `pack.hire` / `pack.hire.console` (`mosaic org pack hire ID PACK
[--project P]`, `POST /api/orgs/<id>/packs/hire`) runs the same loop on
a pack already applied, refused `pack-not-applied` otherwise. A proposal
whose instance exists is `exists`, and one with an open decision is
`open` or `awaiting-owner`, so neither a re-apply nor `pack hire` ever
hires twice. A project pack hires into `projects.<p>.agents`.
- Each hire line in the receipt is `{name, state, decision | code}` with
state `hired`, `exists`, `open`, `awaiting-owner` or `refused`. The
packs read adds `hired` and `open` (with each proposal's reason) to each
applied entry, and the instances read names the pack an agent came from.
### The surface
| Layer | Name |
+37
View File
@@ -1135,3 +1135,40 @@ Each write records `human.input`. A task read returns a `digest` that can be sup
`task-conflict`. Existing assignees may remain after a role is removed, but new assignees
must be current roles. Automatic tail placement and an explicitly occupied position refuse
`task-position-full` when there is no room; no task row is written.
## Starter pack hires (ruling 2026-11-14, #1873)
"If I choose a starter pack and apply it, that is a decision!" A person's
`pack.apply` (the organization's human, outside any run) or `pack.apply.console`
hires the proposals of the packs entry it wrote, once the apply has committed.
An agent's `pack.apply` hires nothing. `pack.hire` (the owner's human session)
and `pack.hire.console` (the owner's or a member's console cap, needing
`org.hire`) run the same loop on a pack already applied; with no packs entry they
refuse `pack-not-applied`.
Each proposal goes through `#raiseHire` and `hire.resolve.console`, the path an
inbox approval takes: the decision row, its resolved event, `human.input`,
`hire.consume`, the hire's commit and `hire.apply` event, plus
`action.allowed {operation: 'pack.hire', pack, version, origin, project, hire,
decision}`. The route is the chief of staff's resolved harness and model (the
`launch.by` instance), else the organization assistant's: the instance whose
definition is `assistant`, the one named `assistant` when there are several,
else the first in key order. Thinking is carried when set. With neither the
proposal refuses `pack-hire-route-missing`. A `hired` or `awaiting-owner` line
carries `routeFrom: 'chief' | 'assistant'`. The entry is written with
no credential material, so a credential line leaves its services `unbound`.
A proposal whose instance exists is `exists`; one an open hire decision names is
`open`, so nothing is hired twice. The owner approves every line, and replaces
an open decision an earlier hire of the same pack left for the owner: the new
hire is raised and applied first, and the old decision is withdrawn only once it
has applied, so a replacement that refuses leaves the old one in the inbox. A member's flagged or credentialed hires are raised after
the others, left open for the owner's inbox (`awaiting-owner`). A hire that
refuses records `action.allowed {operation: 'pack.hire.refused', code}` and the
loop goes on. The `packs` read adds `hired` and `open` to each applied entry,
and the `instances` read adds `pack: {id, origin, project}` to each instance a
pack hire created. An `awaiting-owner` line this pack's own hire raised carries
`fromPack: true` (the owner's pack hire would replace it). One pack hire loop
runs per business at a time; a second refuses `organization-busy`. An apply
whose hire loop throws after the apply committed still answers the apply's
receipt, with no hires and the loop's code as `hireError`. No member surface
calls `pack.hire.console` yet; the Console's packs routes are the owner's.
+215 -34
View File
@@ -178,7 +178,7 @@ export const USER_VERBS = Object.freeze(['project.list', 'thread.create', 'threa
'thread.share', 'thread.unshare', 'thread.shares']);
// Row 252 (#1753, design sections 2-3): the only verbs a Console owner cap reaches. Every other verb
// refuses it human-required before its own checks, and these refuse every other cap.
export const CONSOLE_VERBS = Object.freeze(['project.remote.get.console', 'project.remote.set.console', 'autonomy.get.console', 'autonomy.set.console', 'autonomy.review.console', 'decision.confirm.console', 'decision.reverse.console', 'request.workspace.console', 'request.artifact.console', 'decision.preview.console', 'decision.resolve.console', 'hire.preview.console', 'hire.resolve.console', 'hire.start.console', 'hire.templates.console', 'prd.revoke.console', 'request.mode.console', 'request.draft.console',
export const CONSOLE_VERBS = Object.freeze(['project.remote.get.console', 'project.remote.set.console', 'autonomy.get.console', 'autonomy.set.console', 'autonomy.review.console', 'decision.confirm.console', 'decision.reverse.console', 'request.workspace.console', 'request.artifact.console', 'decision.preview.console', 'decision.resolve.console', 'hire.preview.console', 'hire.resolve.console', 'hire.start.console', 'hire.templates.console', 'pack.hire.console', 'prd.revoke.console', 'request.mode.console', 'request.draft.console',
// Task 3 (#1839): and the owner's intake controls. The reads and writes are the same lifecycle methods the PM's
// unsuffixed verbs reach, with the owner's verified (provider, iss, sub) as the only caller; the PM-only
// request.question and the PM's own reads keep their unsuffixed names and refuse a console cap.
@@ -639,6 +639,9 @@ export class Broker {
#builtinBinding;
#organization;
#hires;
// Businesses with a pack hire loop running: a second loop on the same business refuses
// organization-busy rather than raising a second decision for the same agent.
#packHiring = new Set();
#autonomyHost;
#pluginService;
#credentialVerbs;
@@ -6267,7 +6270,7 @@ export class Broker {
this.#autonomyOwner(s);
return this.#autonomyConsole(s,request.verb,structuredClone(args)).catch(e=>{throw this.#refused(s,e instanceof BusError?e:new BusError(e.code??'outcome-unknown',e instanceof BusError?e.reason:undefined),request);});
}
if (s.hireMembership && !['hire.preview.console','hire.resolve.console','hire.start.console','hire.templates.console'].includes(request.verb)) fail('human-required');
if (s.hireMembership && !['hire.preview.console','hire.resolve.console','hire.start.console','hire.templates.console','pack.hire.console'].includes(request.verb)) fail('human-required');
if(request.verb==='decision.resolve' && !s.human) {
if(s.tester)fail('tester-refused');
keys(args,['id','choice','note'],['id','choice']);identifier(args.id);
@@ -6288,7 +6291,7 @@ export class Broker {
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',e instanceof BusError?e.reason:undefined),request);});
return Promise.resolve().then(()=>{const r=this.#proposalHire(s,args);return this.#raiseHire(s,r.input,false,{pack:r.pack});}).catch(e=>{throw this.#refused(s,new BusError(e.code??'invalid-request',e instanceof BusError?e.reason:undefined),request);});
return this.#raiseHire(s,input,request.verb==='hire.start.console').catch(e=>{throw this.#refused(s,new BusError(e.code??'invalid-request',e instanceof BusError?e.reason:undefined),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',e instanceof BusError?e.reason:undefined),request);});
@@ -6296,6 +6299,24 @@ export class Broker {
return this.#hireConsole(s,request.verb,args).catch(e=>{throw this.#refused(s,new BusError(e.code??'outcome-unknown',e instanceof BusError?e.reason:undefined),request);});
if (request.verb === 'grants.scope' || request.verb === 'grants.scope.set')
return this.#grantScopeVerb(s, request.verb, structuredClone(args));
// Ruling 2026-11-14: hire a pack's proposed agents after the apply that recorded them, through
// the same loop a person's apply runs. pack.hire is the owner's CLI human cap; pack.hire.console
// the owner's or a member's console cap. An agent and the tester never get here.
if (request.verb === 'pack.hire' || request.verb === 'pack.hire.console') {
if (s.tester) fail('tester-refused');
if ((request.verb === 'pack.hire.console') !== !!s.console) fail('human-required');
keys(args, ['pack', 'project'], ['pack']);
if (typeof args.pack !== 'string' || !/^(saved:)?[a-z0-9][a-z0-9._-]{0,63}$/.test(args.pack)) fail('invalid-request');
if (Object.hasOwn(args, 'project') && (typeof args.project !== 'string' || !/^[a-z0-9][a-z0-9._-]{0,63}$/.test(args.project))) fail('invalid-request');
const callerOf = () => this.#packHireCaller(s);
if (!callerOf().rights.includes('org.hire')) fail('right-required');
return Promise.resolve().then(async () => {
const state = this.#organization ? this.#organization(s.business, 'pack.state', { pack: args.pack, project: args.project ?? null }, s.human, {}) : null;
if (!state?.entry) fail('pack-not-applied');
const out = await this.#packHires(s, callerOf, { pack: args.pack, project: args.project ?? null });
this.#secretCheck(out); return { pack: state.entry.id, ...out };
}).catch(e => { if (e instanceof BusError) throw this.#refused(s, e, request); this.#packFail(e); });
}
// #1873 SP4: the owner's starter packs, board and workflows. Only the organization's owner calls
// them: the human cap from the CLI, or the owner's console cap (a hire member's cap is refused
// above). The tester is refused before any argument check. A preview and an apply raise no
@@ -6340,6 +6361,20 @@ export class Broker {
if (spends && wrote) this.#store.transaction(() => this.#consumeAuthority(s, 'organization.change'));
this.#secretCheck(value); return value;
};
// Ruling 2026-11-14: a person's apply is the hire decision. Once the apply has committed, the
// same call hires the proposals (#packHires); an agent's apply never hires.
// The apply has committed by then, so a hire loop that throws (organization-busy, a store error)
// still returns the apply's receipt, with no hires and the loop's code as hireError.
if (verb === 'pack.apply' && !agent) {
const hired = async (value) => {
let out;
try { out = await this.#packHires(s, () => this.#packHireCaller(s), { pack: args.pack, project: args.project ?? null }); }
catch (e) { out = { hires: [], hireError: e instanceof BusError && typeof e.code === 'string' ? e.code : 'outcome-unknown' }; }
return done({ ...value, ...out });
};
return Promise.resolve(result).then(value => value, packFail).then(hired);
}
if (verb === 'packs') return Promise.resolve(result).then(value => done(this.#packsEnrich(s.business, value)), packFail);
return result?.then ? result.then(done, packFail) : done(result);
}
// #1873 SP8: saving a template. Only the organization's owner: the human cap from the CLI or the
@@ -6461,6 +6496,12 @@ export class Broker {
entry.running = this.#running(s.business, name);
entry.alive = this.#aliveAfterFire(s.business, name);
}
// Ruling 2026-11-14: each instance a pack hire created names the pack, from its pack.hire event.
if (request.verb === "instances" && result?.instances && typeof result.instances === "object") {
const from = this.#packHireOrigins(s.business);
for (const entry of Object.values(result.instances))
if (typeof entry.hireDecision === 'string' && from.has(entry.hireDecision)) entry.pack = from.get(entry.hireDecision);
}
// #1873 A3: the copies this bus booted with, and each one's running runs. The file's own
// instances above carry no copy; a copy carries its state, missing service names or reason.
if (request.verb === "instances" && result?.instances && typeof result.instances === "object" && this.#business(s.business).copies)
@@ -6741,28 +6782,159 @@ export class Broker {
.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');
// The broker's sandbox import closure is fixed, so the digest and the composition are local
// copies: canonical is organization util.mjs canonicalJson, built as a string so integer-like
// keys keep the sorted order (an object would put them first), and the composition is
// definitions.mjs compositionOf.
const canonical = v => Array.isArray(v) ? `[${v.map(canonical).join(',')}]` : object(v)
? `{${Object.keys(v).sort().map(k => `${JSON.stringify(k)}:${canonical(v[k])}`).join(',')}}` : JSON.stringify(v);
if (sha256(canonical(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');
if (!object(hire.entry.role) || typeof hire.entry.instructions !== 'string') fail('invalid-request');
role = { inline: { ...hire.entry.role, instructions: hire.entry.instructions, ...(hire.entry.personality !== undefined ? { personality: hire.entry.personality } : {}) } };
} else role = structuredClone(pin);
const role = this.#pinRole(b.launch?.noHire ?? [], hire);
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 };
return { input: { op: 'hire', level: project === null ? 'org' : 'project', ...(project === null ? {} : { project }), name: hire.name, role, route: structuredClone(args.route), reason },
pack: { pack: entry.id, version: entry.version ?? null, origin: entry.origin ?? null, project } };
}
async #raiseHire(s, input, immediate) {
// A recorded proposal's pin as a hire request's role (SP5's chief raise and a person's pack hire).
#pinRole(noHire, hire) {
const pin = hire?.pin;
if (!object(pin)) fail('invalid-request');
if (!Object.hasOwn(pin, 'inline')) return structuredClone(pin);
if (!object(hire.entry) || typeof pin.sha256 !== 'string') fail('invalid-request');
// The broker's sandbox import closure is fixed, so the digest and the composition are local
// copies: canonical is organization util.mjs canonicalJson, built as a string so integer-like
// keys keep the sorted order (an object would put them first), and the composition is
// definitions.mjs compositionOf.
const canonical = v => Array.isArray(v) ? `[${v.map(canonical).join(',')}]` : object(v)
? `{${Object.keys(v).sort().map(k => `${JSON.stringify(k)}:${canonical(v[k])}`).join(',')}}` : JSON.stringify(v);
if (sha256(canonical(hire.entry)) !== pin.sha256) fail('starter-pack-proposal-stale');
const from = hire.entry.from?.template;
if (from !== undefined && noHire.includes(from)) fail('definition-not-hireable');
if (!object(hire.entry.role) || typeof hire.entry.instructions !== 'string') fail('invalid-request');
return { inline: { ...hire.entry.role, instructions: hire.entry.instructions, ...(hire.entry.personality !== undefined ? { personality: hire.entry.personality } : {}) } };
}
// Ruling 2026-11-14: a person's pack apply is the hire decision. The person who may hire from a
// pack: the owner's console cap, the owner's CLI human cap (outside any run), or a member's console
// cap with org.hire. Anyone else is refused human-required; the tester tester-refused.
#packHireCaller(s) {
if (s.tester) fail('tester-refused');
if (s.console) return this.#hireCaller(s);
this.#human(s);
if (s.human !== this.#business(s.business).human) fail('human-required');
return { actor: s.human, agent: false, rights: ['org.hire'], owner: true, instanceOwner: false, subject: null, via: 'cli' };
}
#openHireDecisions(business) {
return 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`, business)
.map(({ id }) => ({ id, hire: this.#raised(business, id)?.hire })).filter(x => object(x.hire));
}
// decision id -> {id, origin, project} of the pack whose hire raised it.
#packHireOrigins(business) {
return new Map(this.#packHireEvents(business).filter(e => e.operation === 'pack.hire' && typeof e.decision === 'string')
.map(e => [e.decision, { id: e.pack, origin: e.origin ?? null, project: e.project ?? null }]));
}
// Ruling 2026-11-14: the packs read gains, per applied entry, the agents its hires created
// (launched, for the organization level) and each proposal still open: awaiting-owner with its
// decision, refused with the last refusal code, or proposed. Each carries the pack's reason.
#packsEnrich(business, value) {
if (!Array.isArray(value?.applied)) return value;
const events = this.#packHireEvents(business), agents = value.agents ?? { org: {}, projects: {} };
const open = new Map(this.#openHireDecisions(business).map(x => [JSON.stringify([x.hire.name, x.hire.project ?? null]), x.id]));
const raisedBy = new Map(events.filter(e => e.operation === 'pack.hire').map(e => [e.decision, e]));
for (const a of value.applied) {
const level = a.project === null ? agents.org ?? {} : agents.projects?.[a.project] ?? {};
const mine = e => e.pack === a.id && (e.origin ?? null) === (a.origin ?? null) && (e.project ?? null) === (a.project ?? null);
// Hired: every live instance at the entry's level a hire of this pack created, whichever entry
// proposed it (a re-apply records an entry that proposes nothing new).
a.hired = Object.entries(level).filter(([, inst]) => inst.launched && inst.hireDecision && raisedBy.has(inst.hireDecision) && mine(raisedBy.get(inst.hireDecision))).map(([name]) => name);
a.open = [];
for (const h of a.proposedHires) {
if (Object.hasOwn(level, h.name)) continue;
const decision = open.get(JSON.stringify([h.name, a.project ?? null]));
// fromPack: this pack's own hire raised it, so the owner's pack hire replaces and approves it.
if (decision) { a.open.push({ name: h.name, reason: h.reason, state: 'awaiting-owner', decision, ...(raisedBy.has(decision) && mine(raisedBy.get(decision)) ? { fromPack: true } : {}) }); continue; }
const last = events.filter(e => mine(e) && e.hire === h.name).at(-1);
if (last?.operation === 'pack.hire.refused') a.open.push({ name: h.name, reason: h.reason, state: 'refused', code: last.code });
else a.open.push({ name: h.name, reason: h.reason, state: 'proposed' });
}
}
return value;
}
#packHireEvents(business) {
return this.#store.all(`SELECT body FROM events WHERE business=? AND kind='action.allowed' AND json_extract(body,'$.operation') IN ('pack.hire','pack.hire.refused') ORDER BY seq`, business)
.map(r => JSON.parse(r.body));
}
// One pass over the latest packs entry of `pack` (and project): each proposed hire is raised and
// approved in the same act through #raiseHire and #hireConsole, the path an inbox approval takes, so
// each agent leaves the same decision record. A hire whose instance exists, or that an open hire
// decision already names, is skipped, so a re-apply hires nothing twice. The owner approves every
// line. A member approves only the unflagged hires; each flagged or credentialed one is recorded
// after them, pinned to the final revision, as an open decision for the owner's inbox. A hire that
// refuses records action.allowed pack.hire.refused with its code, and the loop goes on.
async #packHires(s, callerOf, { pack, project = null }) {
if (!this.#organization || !this.#hires) fail('not-configured');
if (this.#packHiring.has(s.business)) fail('organization-busy');
this.#packHiring.add(s.business);
try { return await this.#packHireLoop(s, callerOf, { pack, project }); }
finally { this.#packHiring.delete(s.business); }
}
async #packHireLoop(s, callerOf, { pack, project }) {
const caller = callerOf();
const read = () => this.#organization(s.business, 'pack.state', { pack, project }, caller.actor, {});
const state = read();
if (!state?.entry) return { hires: [] };
const entry = state.entry, provenance = { pack: entry.id, version: entry.version ?? null, origin: entry.origin ?? null, project };
const out = [], deferred = [];
const refused = (name, e) => {
const code = e?.code ?? 'outcome-unknown';
this.#store.transaction(() => this.#event(s, 'action.allowed', { operation: 'pack.hire.refused', pack: entry.id, origin: entry.origin ?? null, project, hire: name, code }, null));
out.push({ name, state: 'refused', code });
};
const open = new Map(this.#openHireDecisions(s.business).map(x => [JSON.stringify([x.hire.name, x.hire.project ?? null]), x.id]));
const raisedBy = new Map(this.#packHireEvents(s.business).filter(e => e.operation === 'pack.hire').map(e => [e.decision, e]));
for (const hire of (Array.isArray(entry.proposedHires) ? entry.proposedHires : []).filter(object)) {
const name = hire.name;
if (state.names.includes(name)) { out.push({ name, state: 'exists' }); continue; }
const openId = open.get(JSON.stringify([name, project]));
// The owner's pack hire approves every line, so an open decision an earlier pack hire of this
// same pack left for the owner is replaced by one approved now (its pin may be stale after the
// hires approved since). The old one is withdrawn only once the new hire has applied: a
// replacement that refuses leaves it open in the owner's inbox. Any other open decision is left alone.
let supersedes = null;
if (openId) {
const by = raisedBy.get(openId);
if (caller.owner !== true || !by || by.pack !== entry.id || (by.origin ?? null) !== (entry.origin ?? null) || (by.project ?? null) !== project) { out.push({ name, state: 'open', decision: openId }); continue; }
supersedes = openId;
}
let input;
try {
if (!state.route) fail('pack-hire-route-missing');
input = { op: 'hire', level: project === null ? 'org' : 'project', ...(project === null ? {} : { project }), name, role: this.#pinRole(state.noHire, hire), route: structuredClone(state.route),
reason: typeof hire.reason === 'string' && hire.reason.trim() ? hire.reason : `Proposed by starter pack ${entry.id}.` };
} catch (e) { refused(name, e); continue; }
let r;
try {
r = await this.#raiseHire(s, input, true, { caller: callerOf, pack: provenance, defer: caller.owner !== true });
} catch (e) { refused(name, e); continue; }
if (r?.deferred) { deferred.push(input); continue; }
if (supersedes) this.#store.transaction(() => {
if (this.#closed(supersedes)) return;
const seq = Number(this.#store.run('INSERT INTO decision_events(decision,at,op,by,choice,note,via) VALUES(?,?,?,?,?,?,?)',
supersedes, this.#now(), 'withdrawn', caller.actor, null, null, caller.via ?? 'console').lastInsertRowid);
this.#event(s, 'human.input', { kind: 'answer', decision: supersedes, op: 'withdrawn', choice: null, via: caller.via ?? 'console', subject: caller.via === 'cli' ? null : s.console, decisionSeq: seq }, supersedes);
});
out.push({ name, state: 'hired', decision: r.id, routeFrom: state.routeFrom, ...(supersedes ? { supersedes } : {}) });
}
for (const input of deferred) {
try {
const r = await this.#raiseHire(s, input, false, { caller: callerOf, pack: provenance, record: true });
out.push({ name: input.name, state: 'awaiting-owner', decision: r.id, routeFrom: state.routeFrom });
} catch (e) { refused(input.name, e); }
}
return { hires: out };
}
// opts (ruling 2026-11-14, a person's pack apply is the hire decision):
// caller: () => the person's hire caller, read again after prepare. Default #hireCaller(s).
// pack: {pack, version, origin, project}; the raise also records action.allowed pack.hire.
// defer: a flagged or credentialed hire a non-owner may not approve returns {deferred: true}
// instead of refusing hire-owner-required, and records nothing.
// record: the person's raise is left open for the owner's inbox (route human, not resolved).
async #raiseHire(s, input, immediate, opts = {}) {
const b=this.#business(s.business);
const human=immediate || opts.record===true;
const callerOf=()=>opts.caller ? opts.caller() : this.#hireCaller(s);
// Change is a replacement composition and uses the existing role.hire grant.
const chiefAllowed=()=>{
this.#agent(s);
@@ -6772,23 +6944,26 @@ export class Broker {
};
// #1873 CB1: a copy credential binding is owner-only. A person raising one in one step must be
// the organization's owner; a member with org.hire is not.
if(immediate) { const c=this.#hireCaller(s); if(!c.rights.includes('org.hire'))fail('right-required'); if(input?.binding!==undefined && c.owner!==true)fail('human-required'); }
if(human) { const c=callerOf(); if(!c.rights.includes('org.hire'))fail('right-required'); if(input?.binding!==undefined && c.owner!==true)fail('human-required'); }
else chiefAllowed();
if(!this.#hires)fail('not-configured');
const did=randomUUID(), at=this.#now(), expiresAt=new Date(Date.parse(at)+7*86400000).toISOString();
const p=await this.#hires.prepare(s.business,structuredClone(input),{id:did,expiresAt});
// Recheck the chief after asynchronous preparation; a released run cannot raise.
if(!immediate)chiefAllowed();
let caller=null;
if(!human)chiefAllowed();
else {
const caller=this.#hireCaller(s);
caller=callerOf();
if(p.binding && caller.owner!==true)fail('human-required');
if(!p.binding && (((p.flagged.length || p.credentialLines.length) && !caller.owner) || (p.level==='root' && !caller.instanceOwner)))fail('hire-owner-required');
const ownerOnly=!p.binding && (((p.flagged.length || p.credentialLines.length) && !caller.owner) || (p.level==='root' && !caller.instanceOwner));
if(ownerOnly && opts.defer===true)return {deferred:true,name:p.name,flagged:p.flagged,credentialLines:p.credentialLines};
if(ownerOnly && opts.record!==true)fail('hire-owner-required');
}
const action=`role.${p.op}`;
const level=decisionAutonomy('authority',b.autonomy);
let route='human';
// A binding always goes to the owner; no autonomy level moves it (design section 4).
if(!immediate && !p.binding && level!=='in the loop' && p.level!=='root' && !p.flagged.length && !p.credentialLines.length) {
if(!human && !p.binding && level!=='in the loop' && p.level!=='root' && !p.flagged.length && !p.credentialLines.length) {
if(level==='out of the loop')fail('authority-limits-unavailable');
if(!this.#budgetCovered(this.#business(s.business)))fail('autonomy-precondition',BUDGET_UNCOVERED);
route=b.autonomy?.authorityResolver;
@@ -6796,12 +6971,17 @@ export class Broker {
if(route===s.role || resolverSubject(b,route,p.name))route='human';
}
this.#store.transaction(()=>{
const raiser=immediate?'human':s.role;
const raiser=human?'human':s.role;
const run=human?(caller?.via==='cli'?'human-cli':'human-console'):s.run;
this.#store.run('INSERT INTO decisions(id,at,business,project,raised_by_role,raised_by_run,class,action,route_to,question,options,recommendation,blocking) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)',
did,at,s.business,p.project??null,raiser,immediate?'human-console':s.run,'gated',action,route,p.binding?`${p.binding.after===null?'unbind':'bind'} credentials for copy ${p.binding.instance}?`:`${p.op} ${p.name} at ${p.level} level?`,JSON.stringify([{key:'yes',text:'Approve'},{key:'no',text:'Deny'}]),'no',0);
did,at,s.business,p.project??null,raiser,run,'gated',action,route,p.binding?`${p.binding.after===null?'unbind':'bind'} credentials for copy ${p.binding.instance}?`:`${p.op} ${p.name} at ${p.level} level?`,JSON.stringify([{key:'yes',text:'Approve'},{key:'no',text:'Deny'}]),'no',0);
this.#event(s,'action.allowed',{operation:'decision.raise',decision:did,action,approvalChoice:'yes',target:p.name,hire:p,autonomyLevel:level},did);
// The pack this hire came from, for the Agents page and the pack card. No target key: the
// decision.raise above is the record the decision readers key on.
if(opts.pack)this.#event(s,'action.allowed',{operation:'pack.hire',pack:opts.pack.pack,version:opts.pack.version??null,origin:opts.pack.origin??null,project:opts.pack.project??null,hire:p.name,decision:did},did);
});
if(immediate) return this.#hireConsole(s,'hire.resolve.console',{id:did,choice:'yes',digest:p.digest});
if(immediate) return this.#hireConsole(s,'hire.resolve.console',{id:did,choice:'yes',digest:p.digest},opts.caller ?? null);
if(human) return {id:did,op:'raised',name:p.name,flagged:p.flagged,credentialLines:p.credentialLines};
return {...this.#decisionView(this.#decision(s,did)),hire:bindingSummary(p)};
}
// The harness fact comes from the host's loaded modules; no host, no account coverage.
@@ -6858,8 +7038,8 @@ export class Broker {
this.#store.transaction(()=>this.#event(s,'action.allowed',{operation:'hire.failed',decision:d.id,code:error.code??'outcome-unknown'},d.id));throw error;
}
}
async #hireConsole(s,verb,a) {
const caller={...this.#hireCaller(s),now:()=>Math.max(Date.now(),this.#clock)};
async #hireConsole(s,verb,a,callerOf=null) {
const caller={...(callerOf ? callerOf() : this.#hireCaller(s)),now:()=>Math.max(Date.now(),this.#clock)};
keys(a,verb==='hire.preview.console'?['id','adjust']:['id','choice','digest','adjust'],verb==='hire.preview.console'?['id']:['id','choice','digest']);
identifier(a.id);
const d=this.#decision(s,a.id), raise=this.#raised(s.business,d.id);
@@ -6882,8 +7062,9 @@ export class Broker {
// A durable close/consume precedes the external file effect. A crash never replays an approval.
this.#store.transaction(()=>{
if(this.#closed(d.id))fail('decision-closed');
this.#store.run('INSERT INTO decision_events(decision,at,op,by,choice,via) VALUES(?,?,?,?,?,?)',d.id,this.#now(),'resolved',caller.actor,a.choice,'console');
this.#event(s,'human.input',{kind:'answer',decision:d.id,op:'resolved',choice:a.choice,via:'console',subject:s.console},d.id);
const via=caller.via ?? 'console';
this.#store.run('INSERT INTO decision_events(decision,at,op,by,choice,via) VALUES(?,?,?,?,?,?)',d.id,this.#now(),'resolved',caller.actor,a.choice,via);
this.#event(s,'human.input',{kind:'answer',decision:d.id,op:'resolved',choice:a.choice,via,subject:callerOf ? caller.subject ?? null : s.console},d.id);
if(a.choice==='yes')this.#event(s,'action.allowed',{operation:'hire.consume',decision:d.id,digest:p.digest,hire:p},d.id);
});
if(a.choice==='no')return {id:d.id,op:'resolved',choice:a.choice};
+13
View File
@@ -218,6 +218,19 @@ group(
"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."
);
// Ruling 2026-11-14: a person's pack apply is the hire decision. The hire action works from the packs
// entry an apply recorded, and each hire runs as the chief's harness and model, else the org
// assistant's (lane pr2).
group(
"pack-not-applied",
"R8: a pack hire works from the packs entry an apply recorded",
"Preview and apply the pack first; its proposed hires come from the packs entry that apply records."
);
group(
"pack-hire-route-missing",
"R3/R8: a hire runs as a harness and model the organization names",
"Neither the chief of staff nor the organization's assistant resolves a harness and a model. Give the chief of staff or the assistant both in its vars, or set them at the organization or system level, then use the pack's hire action again."
);
// #1873 SP8: a saved template carries no credential the bus holds. Save checks every kept string and
// key against the credential store; a hit names the field, never the text.
group(
+61 -3
View File
@@ -300,7 +300,7 @@ const COPY_SWEEP_MS = 60000;
const PACK_AS = /^(definitions|workflows)\.([a-z0-9][a-z0-9._-]{0,63})=([a-z0-9][a-z0-9._-]{0,63})$/;
const SAVED_PREFIX = 'saved:';
// Each op, and whether it writes.
const PACK_OPS = Object.freeze({ packs: false, 'pack.preview': false, 'pack.apply': true, 'board.set': true, 'workflow.set': true });
const PACK_OPS = Object.freeze({ packs: false, 'pack.preview': false, 'pack.state': false, 'pack.apply': true, 'board.set': true, 'workflow.set': true });
const EMPTY_CHIEF = Object.freeze({ tools: [], network: 'none', authority: { withinRole: [], crossRole: [] }, credentials: [] });
function packOps({ writer, rolesDir, dataRoot, system, registryFor, assertClean, configDir }) {
const bad = () => { throw new BusError('invalid-request'); };
@@ -501,9 +501,67 @@ function packOps({ writer, rolesDir, dataRoot, system, registryFor, assertClean,
return `projects.${args.project}.${key}`;
};
const revisionOf = args => { if (typeof args.revision !== 'string' || !/^[0-9a-f]{64}$/.test(args.revision)) bad(); return args.revision; };
// Ruling 2026-11-14 ("If I choose a starter pack and apply it, that is a decision!"): the
// organization facts the broker's pack hire loop and the packs read need, as the file stands.
// `pack` names a packs entry the way apply names the pack: saved:NAME is the owner's saved
// template, any other name the latest shipped or installed entry of that id. The route is a
// resolved harness, model and thinking (system, organization and agent vars): the chief's (the
// launch.by instance), else the org assistant's (lane pr2, lead ruling 2026-11-14), else null.
// routeFrom names which one gave it.
const latestEntries = doc => {
const latest = new Map();
for (const e of doc.packs ?? []) if (isObject(e) && typeof e.id === 'string') latest.set(canonicalJson([e.id, e.origin ?? null, e.project ?? null]), e);
return [...latest.values()];
};
const agentsOf = doc => {
const launched = doc.launch?.instances ?? [];
const one = (entry, isLaunched) => ({ hireDecision: typeof entry?.hireDecision === 'string' ? entry.hireDecision : null, launched: isLaunched });
return {
org: Object.fromEntries(Object.entries(doc.roles ?? {}).map(([name, e]) => [name, one(e, launched.includes(name))])),
projects: Object.fromEntries(Object.entries(doc.projects ?? {}).filter(([, p]) => isObject(p) && p.kind !== 'library')
.map(([project, p]) => [project, Object.fromEntries(Object.entries(p.agents ?? {}).map(([name, e]) => [name, one(e, true)]))])),
};
};
// The org assistant is the instance whose definition is `assistant` (broker.mjs continuationEffect);
// with several, the one named `assistant`, else the first in key order.
const packRoute = (doc, id) => {
const roles = doc.roles ?? {};
let loaded = null;
const routeOf = instance => {
loaded ??= validateBusinessDocument(doc, `${id}.json`, { rolesDir, dataRoot });
const { vars } = resolveInstance({ system, business: loaded, instance });
const pick = k => (typeof vars?.[k] === 'string' && vars[k] ? vars[k] : null);
if (!pick('harness') || !pick('model')) return null;
return { harness: pick('harness'), model: pick('model'), ...(pick('thinking') ? { thinking: pick('thinking') } : {}) };
};
const by = doc.launch?.by;
const chief = typeof by === 'string' && Object.hasOwn(roles, by) ? routeOf(by) : null;
if (chief) return { route: chief, routeFrom: 'chief' };
const assistants = Object.keys(roles).filter(name => isObject(roles[name]) && roles[name].definition === 'assistant');
const assistant = assistants.includes('assistant') ? 'assistant' : assistants[0];
const route = assistant === undefined ? null : routeOf(assistant);
return route ? { route, routeFrom: 'assistant' } : { route: null, routeFrom: null };
};
const applied = doc => latestEntries(doc).map(e => ({ id: e.id, origin: e.origin ?? null, project: e.project ?? null, version: e.version ?? null,
proposedHires: (Array.isArray(e.proposedHires) ? e.proposedHires : []).filter(isObject).map(h => ({ name: h.name, reason: h.reason ?? null })) }));
return {
// Shipped and installed packs only: a saved template is its owner's, read by name.
packs: async () => ({ packs: (await installed()).map(({ id, origin, pack }) => ({ id, origin, pack })) }),
// Shipped and installed packs only: a saved template is its owner's, read by name. `applied` is
// the latest packs entry of each id, origin and project, and `agents` each instance's hire record.
packs: async (id) => {
const doc = writer.read(id).document;
return { packs: (await installed()).map(({ id: pid, origin, pack }) => ({ id: pid, origin, pack })), applied: applied(doc), agents: agentsOf(doc) };
},
'pack.state': (id, args) => {
if (typeof args.pack !== 'string' || !args.pack) bad();
const project = args.project ?? null;
if (project !== null && (typeof project !== 'string' || !/^[a-z0-9][a-z0-9._-]{0,63}$/.test(project))) bad();
const { document: doc, revision } = writer.read(id);
const saved = args.pack.startsWith(SAVED_PREFIX);
const packId = saved ? args.pack.slice(SAVED_PREFIX.length) : args.pack;
const entry = (doc.packs ?? []).filter(e => isObject(e) && e.id === packId && (e.project ?? null) === project && (saved ? e.origin === 'saved' : e.origin !== 'saved')).at(-1) ?? null;
const scope = project === null ? doc.roles ?? {} : doc.projects?.[project]?.agents ?? {};
return { revision, entry: entry ? structuredClone(entry) : null, ...packRoute(doc, id), names: Object.keys(scope), noHire: [...(doc.launch?.noHire ?? [])] };
},
'pack.preview': (id, args, actor, context) => build(id, args, actor, context),
'pack.apply': async (id, args, actor, context) => {
if (typeof args.digest !== 'string') bad();
+1 -1
View File
@@ -89,7 +89,7 @@ function setup(t) {
// #797 / #1839 adds the owner-only artifact read to the existing decision and request controls;
// no unsuffixed verb is ever a console verb.
test('N4: owner Console verbs have an explicit immutable whitelist and exclude decision.resolve', () => {
assert.deepEqual([...CONSOLE_VERBS], ['project.remote.get.console', 'project.remote.set.console', 'autonomy.get.console', 'autonomy.set.console', 'autonomy.review.console', 'decision.confirm.console', 'decision.reverse.console', 'request.workspace.console', 'request.artifact.console', 'decision.preview.console', 'decision.resolve.console', 'hire.preview.console', 'hire.resolve.console', 'hire.start.console', 'hire.templates.console', 'prd.revoke.console', 'request.mode.console', 'request.draft.console',
assert.deepEqual([...CONSOLE_VERBS], ['project.remote.get.console', 'project.remote.set.console', 'autonomy.get.console', 'autonomy.set.console', 'autonomy.review.console', 'decision.confirm.console', 'decision.reverse.console', 'request.workspace.console', 'request.artifact.console', 'decision.preview.console', 'decision.resolve.console', 'hire.preview.console', 'hire.resolve.console', 'hire.start.console', 'hire.templates.console', 'pack.hire.console', 'prd.revoke.console', 'request.mode.console', 'request.draft.console',
'request.get.console', 'request.projects.console', 'request.answer.console', 'request.attach.console', 'request.cancel.console',
// Starter packs SP4 (#1873): the owner's pack list and preview, and the three pack writes.
'packs.console', 'pack.preview.console', 'pack.apply.console', 'board.set.console', 'workflow.set.console',
+367
View File
@@ -0,0 +1,367 @@
// Ruling 2026-11-14 ("If I choose a starter pack and apply it, that is a decision!"): a person's
// apply records one hire decision per proposed hire in the same act and applies it, so the agents
// exist when the call returns. The owner approves every line; a member with org.hire gets the
// unflagged hires and the rest wait in the owner's inbox; an agent's apply never hires.
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 { DatabaseSync } from 'node:sqlite';
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 { templateHash } from '../../organization/src/definitions.mjs';
const SUBJECT = { provider: 'test', iss: 'test', sub: 'owner' };
const ROUTE = { harness: 'pi', model: 'claude-opus-5-5' };
const ASSISTANT_ROUTE = { harness: 'pi', model: 'glm-5.3-flash', thinking: 'low' };
// assistant: false, no assistant instance; {} an assistant with no vars; any other object the
// assistant's vars. Lane pr2: a pack hire falls back to the org assistant's route.
async function harness(t, { route = true, assistant = false, mutate = () => {} } = {}) {
const { startBroker } = await import('../src/runtime.mjs');
const { businessDoc, rolesCopy, systemFor, writeJson } = await import('../../organization/tests/helpers.mjs');
const root = mkdtempSync(join(tmpdir(), 'packhire-'));
const rolesDir = rolesCopy(root);
const doc = businessDoc(root, 'demo');
doc.plugins = { harness: ['pi'] };
if (route) doc.roles.chief.vars = { ...ROUTE };
if (assistant) doc.roles.assistant = { definition: 'assistant', ...(Object.keys(assistant).length ? { vars: { ...assistant } } : {}) };
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: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery', 'organization.change'], crossRole: [] } },
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 consoleOwner = b.bindConsole({ business: 'demo', human: 'jason', subject: SUBJECT });
const human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
const member = b.bindHireConsole({ business: 'demo', actor: 'alice', subject: { provider: 'test', iss: 'test', sub: 'alice' }, rights: ['org.hire', 'board.read'] });
const call = async (verb, args = {}, cap = consoleOwner) => b.request(cap, { verb, args });
const path = join(root, 'businesses/demo.json');
const file = () => JSON.parse(readFileSync(path, 'utf8'));
const decisions = list => b.request(human, { verb: 'decisions', args: { list } });
// The broker's own database, read-only.
const db = (sql, ...a) => { const d = new DatabaseSync(join(data, 'bus/bus.sqlite'), { readOnly: true }); try { return d.prepare(sql).all(...a); } finally { d.close(); } };
const store = { all: db, get: (sql, ...a) => db(sql, ...a)[0] };
const events = () => store.all("SELECT kind, body FROM events WHERE business='demo' ORDER BY seq").map(r => ({ kind: r.kind, body: JSON.parse(r.body) }));
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 });
};
const op = (name) => events().filter(e => e.kind === 'action.allowed' && e.body.operation === name).map(e => e.body);
return { bus, b, root, rolesDir, path, consoleOwner, human, member, call, file, decisions, events, op, store, install };
}
const apply = async (call, args = { pack: 'coding' }, cap) => {
const preview = await call('pack.preview.console', args, cap);
return { preview, out: await call('pack.apply.console', { ...args, digest: preview.digest }, cap) };
};
// The evidence an inbox-approved hire leaves: a decision.raise naming the hire, a console
// resolution yes, a hire.consume and a hire.apply, and the instance's hireDecision.
function assertHireRecord(h, name, decision) {
const raise = h.op('decision.raise').find(r => r.decision === decision);
assert.ok(raise, `decision.raise for ${name}`);
assert.equal(raise.hire.name, name);
assert.equal(raise.action, 'role.hire');
assert.ok(h.op('hire.consume').some(r => r.decision === decision), `hire.consume for ${name}`);
assert.ok(h.op('hire.apply').some(r => r.decision === decision), `hire.apply for ${name}`);
const resolved = h.store.all('SELECT op, choice, via, by FROM decision_events WHERE decision=?', decision);
assert.deepEqual(resolved.map(r => [r.op, r.choice]), [['resolved', 'yes']]);
assert.equal(h.file().roles[name].hireDecision, decision);
}
test('PD the owner applies a pack with flagged hires on a fresh org; the agents exist on return, each with its decision record', async t => {
const h = await harness(t);
const { preview, out } = await apply(h.call);
// The preview lists the flagged and credential lines the owner's apply approves.
assert.ok(preview.hires.some(x => x.flagged.length || x.credentialLines.length), JSON.stringify(preview.hires));
assert.deepEqual(out.hires.map(x => [x.name, x.state]), [['pm-1', 'hired'], ['coder-1', 'hired'], ['reviewer-1', 'hired']]);
const doc = h.file();
for (const x of out.hires) {
assert.ok(Object.hasOwn(doc.roles, x.name), `${x.name} exists`);
assert.ok(doc.launch.instances.includes(x.name), `${x.name} is launched`);
assertHireRecord(h, x.name, x.decision);
}
// The coder's credential lines are approved, not bound: the instance is written with no credential.
assert.equal(doc.roles['coder-1'].credentials, undefined);
// Each hire names its pack.
assert.deepEqual(h.op('pack.hire').map(e => [e.hire, e.pack, e.origin, e.project]), out.hires.map(x => [x.name, 'coding', 'shipped', null]));
// The instances read carries the pack each agent came from.
const inst = await h.call('instances', {}, h.human);
assert.deepEqual(inst.instances['coder-1'].pack, { id: 'coding', origin: 'shipped', project: null });
assert.equal(inst.instances.pm.pack, undefined);
// The packs read counts the hires and lists nothing open.
const packs = await h.call('packs.console');
const applied = packs.applied.find(a => a.id === 'coding');
assert.deepEqual(applied.hired.sort(), ['coder-1', 'pm-1', 'reviewer-1']);
assert.deepEqual(applied.open, []);
});
test('PD a re-apply hires nothing twice', async t => {
const h = await harness(t);
await apply(h.call);
const raised = h.op('decision.raise').length;
const again = await apply(h.call);
// The second apply proposes nothing: each proposal's instance exists.
assert.deepEqual(again.out.hires, []);
assert.deepEqual((await h.call('packs.console')).applied.find(a => a.id === 'coding').hired.sort(), ['coder-1', 'pm-1', 'reviewer-1']);
// The pack action on the same entry skips each instance that exists.
const hired = await h.call('pack.hire.console', { pack: 'coding' });
assert.equal(h.op('decision.raise').length, raised);
assert.ok(hired.hires.every(x => x.state === 'exists'), JSON.stringify(hired));
});
test('PD the owner CLI apply hires through the same path, resolved via cli', async t => {
const h = await harness(t);
const preview = await h.call('pack.preview', { pack: 'coding' }, h.human);
const out = await h.call('pack.apply', { pack: 'coding', digest: preview.digest }, h.human);
assert.deepEqual(out.hires.map(x => x.state), ['hired', 'hired', 'hired']);
for (const x of out.hires) {
assertHireRecord(h, x.name, x.decision);
assert.equal(h.store.get('SELECT via FROM decision_events WHERE decision=?', x.decision).via, 'cli');
assert.equal(h.store.get('SELECT raised_by_run FROM decisions WHERE id=?', x.decision).raised_by_run, 'human-cli');
}
});
test('PD with no chief route the apply records the pack, hires nothing and says why; the hire action on the existing record hires once a route exists', async t => {
const h = await harness(t, { route: false });
const { out } = await apply(h.call);
assert.deepEqual(out.hires.map(x => [x.name, x.state, x.code]), [['pm-1', 'refused', 'pack-hire-route-missing'], ['coder-1', 'refused', 'pack-hire-route-missing'], ['reviewer-1', 'refused', 'pack-hire-route-missing']]);
assert.equal(h.op('decision.raise').length, 0);
const packs = await h.call('packs.console');
assert.deepEqual(packs.applied.find(a => a.id === 'coding').open.map(o => [o.name, o.state, o.code, typeof o.reason]),
[['pm-1', 'refused', 'pack-hire-route-missing', 'string'], ['coder-1', 'refused', 'pack-hire-route-missing', 'string'], ['reviewer-1', 'refused', 'pack-hire-route-missing', 'string']]);
// The owner gives the chief a route; the pack action hires from the packs entry already recorded.
const doc = h.file();
doc.roles.chief.vars = { ...ROUTE };
writeFileSync(h.path, JSON.stringify(doc, null, 2) + '\n');
const hired = await h.call('pack.hire.console', { pack: 'coding' });
assert.deepEqual(hired.hires.map(x => [x.name, x.state]), [['pm-1', 'hired'], ['coder-1', 'hired'], ['reviewer-1', 'hired']]);
for (const x of hired.hires) assertHireRecord(h, x.name, x.decision);
assert.deepEqual((await h.call('packs.console')).applied.find(a => a.id === 'coding').open, []);
// The CLI's pack hire is the same action; nothing is left to hire.
const cli = await h.call('pack.hire', { pack: 'coding' }, h.human);
assert.ok(cli.hires.every(x => x.state === 'exists'));
await assert.rejects(h.call('pack.hire.console', { pack: 'nosuch' }), /pack-not-applied/);
});
// A pack with one hire inside the chief's own grants and two above them.
// An installed pack's own definition narrows a template; the scout keeps only read.
const mixkit = (rolesDir) => {
const SCOUT = { from: { template: 'coder', sha256: templateHash(rolesDir, 'coder') }, role: { tools: ['read'], network: 'none', authority: { withinRole: [], crossRole: [] }, credentials: [] }, instructions: 'Read and report.' };
const p = structuredClone(codingPack());
p.id = 'mixkit';
p.definitions = { scout: SCOUT, coder: p.definitions.coder, reviewer: p.definitions.reviewer };
p.hires = [{ name: 'scout-1', definition: 'scout', reason: 'reads and reports' }, { name: 'coder-1', definition: 'coder', reason: 'implements each change' }, { name: 'reviewer-1', definition: 'reviewer', reason: 'reviews each change' }];
p.workflows = { change: { description: 'One change.', steps: [{ id: 'build', title: 'Build it', roles: ['coder'], state: 'in-progress' }, { id: 'review', title: 'Review it', roles: ['reviewer'], state: 'in-review' }] } };
return p;
};
test('PD a member with org.hire gets the unflagged hires; the flagged ones become open decisions in the owner inbox', async t => {
const h = await harness(t, { route: false });
h.install(mixkit(h.rolesDir));
await apply(h.call, { pack: 'mixkit' });
const doc = h.file();
doc.roles.chief.vars = { ...ROUTE };
writeFileSync(h.path, JSON.stringify(doc, null, 2) + '\n');
const out = await h.call('pack.hire.console', { pack: 'mixkit' }, h.member);
assert.deepEqual(out.hires.map(x => [x.name, x.state]), [['scout-1', 'hired'], ['coder-1', 'awaiting-owner'], ['reviewer-1', 'awaiting-owner']]);
const after = h.file();
assert.ok(after.launch.instances.includes('scout-1'));
assert.equal(Object.hasOwn(after.roles, 'coder-1'), false);
assert.equal(Object.hasOwn(after.roles, 'reviewer-1'), false);
assertHireRecord(h, 'scout-1', out.hires[0].decision);
assert.equal(h.store.get('SELECT via FROM decision_events WHERE decision=?', out.hires[0].decision).via, 'console');
// Each flagged hire is an open decision routed to the human, raised by the member's act.
const waiting = out.hires.filter(x => x.state === 'awaiting-owner');
for (const x of waiting) {
const d = h.store.get('SELECT route_to, raised_by_role, action FROM decisions WHERE id=?', x.decision);
assert.deepEqual([d.route_to, d.raised_by_role, d.action], ['human', 'human', 'role.hire']);
assert.equal(h.store.all('SELECT 1 FROM decision_events WHERE decision=?', x.decision).length, 0);
}
// The packs read lists both as open, awaiting the owner, each with the pack's reason.
const applied = (await h.call('packs.console')).applied.find(a => a.id === 'mixkit');
assert.deepEqual(applied.hired, ['scout-1']);
assert.deepEqual(applied.open.map(o => [o.name, o.state, o.reason, o.decision]), [['coder-1', 'awaiting-owner', 'implements each change', waiting[0].decision], ['reviewer-1', 'awaiting-owner', 'reviews each change', waiting[1].decision]]);
// The member's pack action never re-raises a hire an open decision names.
const again = await h.call('pack.hire.console', { pack: 'mixkit' }, h.member);
assert.deepEqual(again.hires.map(x => [x.name, x.state, x.decision ?? null]), [['scout-1', 'exists', null], ['coder-1', 'open', waiting[0].decision], ['reviewer-1', 'open', waiting[1].decision]]);
// The member cannot approve a flagged hire; the owner can, from the inbox, and the agent exists.
const view = await h.call('hire.preview.console', { id: waiting[0].decision });
await assert.rejects(h.call('hire.resolve.console', { id: waiting[0].decision, choice: 'yes', digest: view.digest }, h.member), /hire-owner-required|human-required/);
await h.call('hire.resolve.console', { id: waiting[0].decision, choice: 'yes', digest: view.digest });
assert.ok(h.file().launch.instances.includes('coder-1'));
// The owner's pack action approves the rest: the open pack decision is withdrawn by the owner and
// replaced by one approved in the same act.
const owner = await h.call('pack.hire.console', { pack: 'mixkit' });
assert.deepEqual(owner.hires.map(x => [x.name, x.state, x.supersedes ?? null]), [['scout-1', 'exists', null], ['coder-1', 'exists', null], ['reviewer-1', 'hired', waiting[1].decision]]);
assert.deepEqual(h.store.all('SELECT op, by FROM decision_events WHERE decision=?', waiting[1].decision).map(r => [r.op, r.by]), [['withdrawn', 'jason']]);
assertHireRecord(h, 'reviewer-1', owner.hires[2].decision);
const done = (await h.call('packs.console')).applied.find(a => a.id === 'mixkit');
assert.deepEqual(done.hired.sort(), ['coder-1', 'reviewer-1', 'scout-1']);
assert.deepEqual(done.open, []);
});
test('PD an agent apply still refuses human-required and hires nothing; the tester and a cli cap on the console name refuse', async t => {
const h = await harness(t);
const agent = h.b.bindLaunch({ business: 'demo', role: 'coder', run: 'coder-run', harness: 'pi', address: 'coder-run' });
h.b.request(agent, { verb: 'role.claim' });
assert.throws(() => h.b.request(agent, { verb: 'pack.apply.console', args: { pack: 'coding', digest: 'a'.repeat(64) } }), /human-required/);
await assert.rejects(Promise.resolve().then(() => h.b.request(agent, { verb: 'pack.hire', args: { pack: 'coding' } })), /human-required/);
await assert.rejects(Promise.resolve().then(() => h.b.request(agent, { verb: 'pack.hire.console', args: { pack: 'coding' } })), /human-required/);
await assert.rejects(Promise.resolve().then(() => h.b.request(h.human, { verb: 'pack.hire.console', args: { pack: 'coding' } })), /human-required/);
await assert.rejects(h.call('pack.hire', { pack: 'coding' }), /human-required/);
for (const args of [{}, { pack: 7 }, { pack: 'coding', extra: 1 }, { pack: 'coding', project: 'Bad Name' }])
await assert.rejects(h.call('pack.hire.console', args), /invalid-request/);
assert.equal(h.op('decision.raise').length, 0);
});
test('PD a project pack hires into projects.<p>.agents', async t => {
const h = await harness(t);
const pack = structuredClone(codingPack());
pack.id = 'projkit'; pack.scope = 'project';
h.install(pack);
const { out } = await apply(h.call, { pack: 'projkit', project: 'stack' });
assert.deepEqual(out.hires.map(x => x.state), ['hired', 'hired', 'hired']);
const agents = h.file().projects.stack.agents;
for (const x of out.hires) { assert.equal(agents[x.name].hireDecision, x.decision); assert.equal(agents[x.name].credentials, undefined); }
assert.equal(Object.hasOwn(h.file().roles, 'coder-1'), false);
assert.deepEqual(h.op('pack.hire').map(e => e.project), ['stack', 'stack', 'stack']);
const applied = (await h.call('packs.console')).applied.find(a => a.id === 'projkit');
assert.deepEqual(applied.hired.sort(), ['coder-1', 'pm-1', 'reviewer-1']);
const again = await h.call('pack.hire.console', { pack: 'projkit', project: 'stack' });
assert.ok(again.hires.every(x => x.state === 'exists'));
});
test('PD the new codes are in the refusal table with a rail and a next step that names no path', async () => {
const { REFUSALS } = await import('../src/refusals.mjs');
for (const c of ['pack-not-applied', 'pack-hire-route-missing', 'hire-owner-required', 'no-chief-of-staff']) {
assert.ok(Object.hasOwn(REFUSALS, c), c);
assert.ok(REFUSALS[c].rail?.length > 5);
assert.ok(REFUSALS[c].nextStep?.length > 20);
assert.ok(!/(^|[\s("'`])(\/|~\/)/.test(REFUSALS[c].nextStep));
}
});
test('PD the owner supersede withdraws an inbox decision only after its replacement hires; a refused replacement leaves it open', async t => {
const h = await harness(t, { route: false });
h.install(mixkit(h.rolesDir));
await apply(h.call, { pack: 'mixkit' });
const setRoute = (vars) => { const doc = h.file(); if (vars) doc.roles.chief.vars = { ...vars }; else delete doc.roles.chief.vars; writeFileSync(h.path, JSON.stringify(doc, null, 2) + '\n'); };
setRoute(ROUTE);
const out = await h.call('pack.hire.console', { pack: 'mixkit' }, h.member);
const waiting = out.hires.filter(x => x.state === 'awaiting-owner');
assert.equal(waiting.length, 2);
// The packs read marks each member-raised line as raised by a pack hire.
const open = (await h.call('packs.console')).applied.find(a => a.id === 'mixkit').open;
assert.deepEqual(open.map(o => [o.name, o.state, o.fromPack]), [['coder-1', 'awaiting-owner', true], ['reviewer-1', 'awaiting-owner', true]]);
// The chief loses its route; the owner's pack hire cannot build a replacement.
setRoute(null);
const owner = await h.call('pack.hire.console', { pack: 'mixkit' });
assert.deepEqual(owner.hires.map(x => [x.name, x.state, x.code ?? null]), [['scout-1', 'exists', null], ['coder-1', 'refused', 'pack-hire-route-missing'], ['reviewer-1', 'refused', 'pack-hire-route-missing']]);
// Both inbox decisions are still open: nothing withdrew them.
for (const x of waiting) assert.deepEqual(h.store.all('SELECT op FROM decision_events WHERE decision=?', x.decision), []);
// With the route back, the owner's pack hire replaces them, withdrawing each only once its replacement applied.
setRoute(ROUTE);
const done = await h.call('pack.hire.console', { pack: 'mixkit' });
assert.deepEqual(done.hires.map(x => [x.name, x.state, x.supersedes ?? null]), [['scout-1', 'exists', null], ['coder-1', 'hired', waiting[0].decision], ['reviewer-1', 'hired', waiting[1].decision]]);
for (const x of waiting) assert.deepEqual(h.store.all('SELECT op, by FROM decision_events WHERE decision=?', x.decision).map(r => [r.op, r.by]), [['withdrawn', 'jason']]);
});
test('PD a second pack hire on the same organization while one runs refuses organization-busy and raises nothing', async t => {
const h = await harness(t, { route: false });
await apply(h.call);
const doc = h.file();
doc.roles.chief.vars = { ...ROUTE };
writeFileSync(h.path, JSON.stringify(doc, null, 2) + '\n');
const first = h.call('pack.hire.console', { pack: 'coding' });
const second = h.call('pack.hire.console', { pack: 'coding' });
await assert.rejects(second, /organization-busy/);
const hired = await first;
assert.deepEqual(hired.hires.map(x => [x.name, x.state]), [['pm-1', 'hired'], ['coder-1', 'hired'], ['reviewer-1', 'hired']]);
// One decision per hire: the refused call raised nothing.
assert.equal(h.op('decision.raise').length, 3);
// Once the loop ends the latch is free again: the next pack hire runs and finds nothing to hire.
const later = await h.call('pack.hire.console', { pack: 'coding' });
assert.ok(later.hires.every(x => x.state === 'exists'), JSON.stringify(later));
});
// Lane pr2 (lead ruling 2026-11-14): a pack hire takes the chief of staff's harness and model, else
// the org assistant's, else refuses pack-hire-route-missing.
test('PR2 with no chief route the apply hires every proposal with the org assistant harness and model', async t => {
const h = await harness(t, { route: false, assistant: ASSISTANT_ROUTE });
const { out } = await apply(h.call);
assert.deepEqual(out.hires.map(x => [x.name, x.state, x.routeFrom]), [['pm-1', 'hired', 'assistant'], ['coder-1', 'hired', 'assistant'], ['reviewer-1', 'hired', 'assistant']]);
const doc = h.file();
for (const x of out.hires) {
assertHireRecord(h, x.name, x.decision);
assert.deepEqual(doc.roles[x.name].vars, ASSISTANT_ROUTE);
assert.deepEqual(h.op('decision.raise').find(r => r.decision === x.decision).hire.route, ASSISTANT_ROUTE);
}
});
test('PR2 a chief route wins over a different assistant route', async t => {
const h = await harness(t, { assistant: ASSISTANT_ROUTE });
const { out } = await apply(h.call);
assert.deepEqual(out.hires.map(x => [x.name, x.state, x.routeFrom]), [['pm-1', 'hired', 'chief'], ['coder-1', 'hired', 'chief'], ['reviewer-1', 'hired', 'chief']]);
for (const x of out.hires) assert.deepEqual(h.file().roles[x.name].vars, ROUTE);
});
test('PR2 an assistant with no harness and model gives no route: the apply refuses as before', async t => {
for (const assistant of [{}, { harness: 'pi' }]) {
const h = await harness(t, { route: false, assistant });
const { out } = await apply(h.call);
assert.deepEqual(out.hires.map(x => [x.name, x.state, x.code]), [['pm-1', 'refused', 'pack-hire-route-missing'], ['coder-1', 'refused', 'pack-hire-route-missing'], ['reviewer-1', 'refused', 'pack-hire-route-missing']]);
assert.equal(h.op('decision.raise').length, 0);
}
});
test('PR2 with several assistants the one named assistant gives the route, else the first in key order', async t => {
const OTHER = { harness: 'pi', model: 'claude-sonnet-5-5' };
const named = await harness(t, { route: false, assistant: ASSISTANT_ROUTE, mutate: doc => { doc.roles = { aide: { definition: 'assistant', vars: { ...OTHER } }, ...doc.roles }; } });
const a = await apply(named.call);
for (const x of a.out.hires) assert.deepEqual(named.file().roles[x.name].vars, ASSISTANT_ROUTE);
const first = await harness(t, { route: false, mutate: doc => { doc.roles.aide = { definition: 'assistant', vars: { ...OTHER } }; doc.roles.zed = { definition: 'assistant', vars: { ...ASSISTANT_ROUTE } }; } });
const b = await apply(first.call);
assert.deepEqual(b.out.hires.map(x => [x.state, x.routeFrom]), [['hired', 'assistant'], ['hired', 'assistant'], ['hired', 'assistant']]);
for (const x of b.out.hires) assert.deepEqual(first.file().roles[x.name].vars, OTHER);
});
test('PR2 a member pack hire on the assistant route marks hired and awaiting-owner lines routeFrom assistant', async t => {
const h = await harness(t, { route: false, assistant: ASSISTANT_ROUTE });
h.install(mixkit(h.rolesDir));
// The member's apply would not run; the owner's apply would hire everything. Record the pack first
// with the assistant route absent, then give it back and let the member hire.
const doc0 = h.file(); delete doc0.roles.assistant.vars; writeFileSync(h.path, JSON.stringify(doc0, null, 2) + '\n');
await apply(h.call, { pack: 'mixkit' });
const doc = h.file(); doc.roles.assistant.vars = { ...ASSISTANT_ROUTE }; writeFileSync(h.path, JSON.stringify(doc, null, 2) + '\n');
const out = await h.call('pack.hire.console', { pack: 'mixkit' }, h.member);
assert.deepEqual(out.hires.map(x => [x.name, x.state, x.routeFrom]), [['scout-1', 'hired', 'assistant'], ['coder-1', 'awaiting-owner', 'assistant'], ['reviewer-1', 'awaiting-owner', 'assistant']]);
assert.deepEqual(h.file().roles['scout-1'].vars, ASSISTANT_ROUTE);
});
test('PR2 a project pack with no chief route hires into the project on the assistant route', async t => {
const h = await harness(t, { route: false, assistant: ASSISTANT_ROUTE });
const pack = structuredClone(codingPack());
pack.id = 'projkit'; pack.scope = 'project';
h.install(pack);
const { out } = await apply(h.call, { pack: 'projkit', project: 'stack' });
assert.deepEqual(out.hires.map(x => [x.state, x.routeFrom]), [['hired', 'assistant'], ['hired', 'assistant'], ['hired', 'assistant']]);
const agents = h.file().projects.stack.agents;
for (const x of out.hires) { assert.equal(agents[x.name].hireDecision, x.decision); assert.deepEqual(h.op('decision.raise').find(r => r.decision === x.decision).hire.route, ASSISTANT_ROUTE); }
});
+1 -1
View File
@@ -90,7 +90,7 @@ const ask = (x, state, extra = {}) => x.call('pm', 'request.question', { request
options: [{ key: 'web', text: 'Web' }, { key: 'cli', text: 'CLI' }], ...extra });
test('the console verb list includes the artifact read and existing owner controls', () => {
assert.deepEqual([...CONSOLE_VERBS], ['project.remote.get.console', 'project.remote.set.console', 'autonomy.get.console', 'autonomy.set.console', 'autonomy.review.console', 'decision.confirm.console', 'decision.reverse.console', 'request.workspace.console', 'request.artifact.console', 'decision.preview.console', 'decision.resolve.console', 'hire.preview.console', 'hire.resolve.console', 'hire.start.console', 'hire.templates.console', 'prd.revoke.console', 'request.mode.console', 'request.draft.console',
assert.deepEqual([...CONSOLE_VERBS], ['project.remote.get.console', 'project.remote.set.console', 'autonomy.get.console', 'autonomy.set.console', 'autonomy.review.console', 'decision.confirm.console', 'decision.reverse.console', 'request.workspace.console', 'request.artifact.console', 'decision.preview.console', 'decision.resolve.console', 'hire.preview.console', 'hire.resolve.console', 'hire.start.console', 'hire.templates.console', 'pack.hire.console', 'prd.revoke.console', 'request.mode.console', 'request.draft.console',
'request.get.console', 'request.projects.console', 'request.answer.console', 'request.attach.console', 'request.cancel.console',
// Starter packs SP4 (#1873): the owner's pack list and preview, and the three pack writes.
'packs.console', 'pack.preview.console', 'pack.apply.console', 'board.set.console', 'workflow.set.console',
+17
View File
@@ -1363,6 +1363,23 @@ message, an answer that never came exits 1, an unknown template exits 2. All of
these verbs, reads included, refuse inside an agent run (exit 3) after their
usage checks (exit 4) and before any config is read.
Starter pack hires (ruling 2026-11-14, #1873): `mosaic org pack apply ID PACK
--digest D [--project P]` is the owner's hire decision. After the pack commits,
the same command hires each proposed agent through the bus's hire-decisions
path (`pack.apply` as the organization's human) and the receipt adds `hires`,
one `{name, state, decision | code}` per proposal: `hired`, `exists` (an
instance by that name is there), `open` (a hire decision already names it),
`awaiting-owner` or `refused` with the hire's code. The owner's apply approves
the flagged and credential lines; a credential line is approved, not bound, so
the agent's launch is refused `credential-unbound` until its services are
bound. `mosaic org pack hire ID PACK [--project P]` (`pack.hire`) runs the same
loop on a pack already applied, for the proposals an earlier apply left open,
and hires none twice; a pack with no packs entry is refused `pack-not-applied`
with the hint to preview and apply it first. A proposal is refused
`pack-hire-route-missing` when neither the chief of staff nor the
organization's assistant has a harness and model to give it; the chief's route
wins when both have one. Like the other pack words, both refuse inside an agent run (exit 3).
`mosaic tasks create|edit|archive` are the task board's three writes. The
arguments pass the board's own check, then go to the bus as the person
(`tasks.create`, `tasks.edit`, `tasks.archive`), so the bus runs its human,
+9 -1
View File
@@ -15,7 +15,7 @@ import { busExit, humanTransport, refuseInsideAgent } from './transport.mjs';
import { definitionsCommand, DEFINITIONS_USAGE } from './org-definitions.mjs';
import { orgOwnerCommand, ORG_OWNER_USAGE, ORG_OWNER_VERBS } from './org-owner.mjs';
// Starter packs SP4 (design section 8): the owner's pack, board and workflow words.
export const PACKS_USAGE = 'mosaic org pack list | pack preview ID PACK [--project P] [--as KIND.NAME=NEW]... | pack apply ID PACK --digest D [--project P] [--as KIND.NAME=NEW]... | board set ID FILE [--project P] | workflow set ID NAME FILE [--project P] | workflow remove ID NAME [--project P]';
export const PACKS_USAGE = 'mosaic org pack list | pack preview ID PACK [--project P] [--as KIND.NAME=NEW]... | pack apply ID PACK --digest D [--project P] [--as KIND.NAME=NEW]... | pack hire ID PACK [--project P] | board set ID FILE [--project P] | workflow set ID NAME FILE [--project P] | workflow remove ID NAME [--project P]';
// #1873 SP8: saving the organization, or one project, as a template; the owner's alone.
export const TEMPLATE_USAGE = 'mosaic org template save ID NAME [--project P] (--preview | --digest D) | template list ID';
export const ORG_USAGE = 'mosaic org lock show|clear ID | reload ID | archive ID --if-revision N [--json] | restore ID --if-revision N [--no-bus] [--json] | delete ID --confirm ID [--if-revision N] [--json] | template render ID [--fixture local] --output FILE | validate FILE | create ID --from FILE | show ID [--json] | list [--json] | update ID --from FILE --if-revision N | queue init ID (--local-only --accept-local-loss-risk | --remote URL) | queue show ID [--json] | project repo init ID PROJECT (--local-only --accept-local-loss-risk | --remote URL) | project repo show ID PROJECT [--json] | project repo set ID PROJECT (--local-only --accept-local-loss-risk | --remote URL) --if-revision N [--json] | ' + DEFINITIONS_USAGE.slice('mosaic org '.length) + ' | ' + ORG_OWNER_USAGE.slice('mosaic org '.length) + ' | ' + PACKS_USAGE.slice('mosaic org '.length) + ' | ' + TEMPLATE_USAGE.slice('mosaic org '.length) + ' | config validate ID | config resolve ID INSTANCE [--project ID]';
@@ -66,6 +66,11 @@ const PACK_REFUSALS = {
'starter-pack-invalid': 'the pack fails its own checks or the composition check; nothing was written',
'starter-pack-template-changed': 'a template the pack names changed since it was loaded; preview again',
'not-configured': 'this organization\'s bus has no organization writer configured',
// Ruling 2026-11-14: apply and pack hire hire the pack's proposals. These come back as a hire's code.
'pack-not-applied': 'that pack has no packs entry in this organization or project; preview and apply it first',
'pack-hire-route-missing': 'neither the chief of staff nor the assistant has a harness and model to give a hire; set both in either one\'s vars, then run pack hire',
'no-chief-of-staff': 'the organization has no chief of staff to raise its hires',
'right-required': 'hiring needs the org.hire right',
};
// E2E6 fix 3: humanTransport throws a CliError whose message and .code are the bus code alone. A code
// a table knows gains its hint, as template save's do; a next line it carried is kept. Any other
@@ -101,6 +106,9 @@ async function packsCommand(args, io, deps) {
if (sub === 'apply' && !values['--digest']) throw new CliError('--digest is required: apply the digest a preview printed', 4);
verb = `pack.${sub}`;
request = { pack: name, ...project, ...(as.length ? { as } : {}), ...(sub === 'apply' ? { digest: values['--digest'] } : {}) };
} else if (word === 'pack' && sub === 'hire' && pos.length === 4) {
// Ruling 2026-11-14: hire the proposals of a pack already applied, the same loop apply runs.
allowed('--project'); verb = 'pack.hire'; request = { pack: name, ...project };
} else if (word === 'board' && sub === 'set' && pos.length === 4) { allowed('--project'); verb = 'board.set'; revised = true; request = { board: await packFile(name), ...project }; }
else if (word === 'workflow' && sub === 'set' && pos.length === 5) { allowed('--project'); verb = 'workflow.set'; revised = true; request = { name, workflow: await packFile(file), ...project }; }
else if (word === 'workflow' && sub === 'remove' && pos.length === 4) { allowed('--project'); verb = 'workflow.set'; revised = true; request = { name, workflow: null, ...project }; }
+19
View File
@@ -130,3 +130,22 @@ test('E2E6 pack, board and workflow refusals from a CliError-shaped transport pr
await assert.rejects(run(['pack', 'preview', 'acme', 'no-such-pack']), (e) => e.code === 'bus-not-running' && e.exitCode === 3
&& e.message === 'bus-not-running: the bus for acme is not running, so the request was not sent' && /mosaic bus start acme/.test(e.next) && /mosaic bus recover acme/.test(e.next));
});
// Ruling 2026-11-14: "If I choose a starter pack and apply it, that is a decision!" The owner's apply
// hires the proposals in the same act; pack hire runs the same loop on the packs entry and hires none twice.
test('PD mosaic org pack apply hires the proposals; pack hire on the same record hires nothing twice', async (t) => {
const s = await setup(t);
assert.ok(PACKS_USAGE.includes('pack hire ID PACK [--project P]'));
const preview = await s.run(['pack', 'preview', 'acme', 'coding']);
const applied = await s.run(['pack', 'apply', 'acme', 'coding', '--digest', preview.digest]);
assert.deepEqual(applied.hires.map((x) => [x.name, x.state]), [['pm-1', 'hired'], ['coder-1', 'hired'], ['reviewer-1', 'hired']]);
for (const x of applied.hires) assert.match(String(x.decision), /^[0-9a-f-]{36}$/);
const doc = s.doc();
for (const x of applied.hires) { assert.equal(doc.roles[x.name].hireDecision, x.decision); assert.ok(doc.launch.instances.includes(x.name), x.name); }
const again = await s.run(['pack', 'hire', 'acme', 'coding']);
assert.deepEqual(again.hires.map((x) => [x.name, x.state]), [['pm-1', 'exists'], ['coder-1', 'exists'], ['reviewer-1', 'exists']]);
assert.deepEqual(s.calls.at(-1), ['pack.hire', ['pack']]);
await assert.rejects(s.run(['pack', 'hire', 'acme', 'ghost-pack']), (e) => e.code === 'pack-not-applied' && /apply it first/.test(e.message));
await assert.rejects(s.run(['pack', 'hire', 'acme']), (e) => e.exitCode === 4 && e.message === PACKS_USAGE);
});
+8
View File
@@ -684,3 +684,11 @@ After the pinned window, use an ordinary new decision.
Policy has no effect executor. Per the Lead ruling, its in and existing out
levels work, while near refuses `policy-reverse-unavailable` and directs the
owner to choose in or out. Policy near and its reverse wait for that executor.
### Pack hires and rights (ruling 2026-11-14, #1873)
`src/org-rights.mjs` maps `pack.hire.console` to `org.hire`: hiring a starter
pack's proposed agents is a hire, so a member with that right hires the
unflagged proposals and the rest wait for the owner (invariant 7). The read
`instanceSettings` returns each instance's `hireDecision`, which the bus's
`instances` read uses to name the pack an agent came from.
@@ -183,6 +183,8 @@ export function instanceSettings({ dir, rolesDir, system, levels = () => null, r
protected: closed(entry.definition),
kept: (launch?.keep ?? []).includes(name),
endProtected: protectedRefusal(document, name, actor) !== null,
// Ruling 2026-11-14: a hired instance names its hire decision; the bus adds the pack it came from.
...(typeof entry.hireDecision === 'string' ? { hireDecision: entry.hireDecision } : {}),
}]));
// Row 194 (D1): the human's forbid lists and the retired names. The bus adds running runs.
return {
+3
View File
@@ -94,6 +94,9 @@ export const VERB_RIGHTS = record({
'packs.console': 'board.read',
'pack.preview.console': null,
'pack.apply.console': null,
// Ruling 2026-11-14: hiring a pack's proposed agents is a hire; a member with org.hire gets the
// unflagged ones and the rest wait for the owner (invariant 7).
'pack.hire.console': 'org.hire',
'board.set.console': null,
'workflow.set.console': null,
// #1873 SP8: saving a template, and listing the saved ones, are the owner's alone: a template is a
@@ -86,6 +86,7 @@ const VERBS = {
'packs.console': 'board.read',
'pack.preview.console': null,
'pack.apply.console': null,
'pack.hire.console': 'org.hire',
'board.set.console': null,
'workflow.set.console': null,
// Starter packs SP8 (#1873): saving a template and listing the saved ones are the owner's alone.
+33
View File
@@ -25,6 +25,7 @@ baseline CLI.
| Restart the Console | Stop and run `mosaic console start` again | Enabled (F3) when `mosaic console start` runs it in the foreground, or with `--dev-local-owner --detach` under a detached supervisor (D1): Settings > Console; the owner only; trailed. Other Consoles answer `not-supervised` |
| Save, review, confirm or reverse an organization's autonomy | Read, save and review: `mosaic org autonomy ORG`, `mosaic org autonomy ORG set --revision REV [--cross-role|--merge|--policy|--authority in|near|out] [--notices on|off] [--reverse-window-hours 1-168] [--authority-resolver NAME|""]`, `mosaic org autonomy ORG review CLASS` (E2, through this route and its revision check); confirm and reverse act on inbox decisions and have no verb yet | Enabled (D1) on a `--dev-local-owner` Console: the owner only, on the selected organization's bus, behind a fence that passes only the four console verbs `autonomy.set.console`, `autonomy.review.console`, `decision.confirm.console` and `decision.reverse.console` |
| Hire, fire or change an agent; read role templates | `mosaic org role-templates ORG`; `mosaic org hire|change ORG --name NAME --template TEMPLATE --harness H --model M [--thinking T] [--level org|project|root] [--project P] --reason TEXT`; `mosaic org fire ORG --name NAME [--level ...] --reason TEXT` (E2, through this route) | Enabled (D4) for the owner on a selected organization's bus: one decision raised and approved through `hire.start.console`, with the bus's hire checks |
| Apply a starter pack; hire its proposed agents; fire an agent from its card | `mosaic org pack preview|apply ID PACK [--digest D] [--project P]`; `mosaic org pack hire ID PACK [--project P]`; `mosaic org fire ORG --name NAME --reason TEXT` | Enabled for the owner (ruling 2026-11-14): applying is the hire decision; see "Starter pack hires" |
| Create, edit or archive a board task | `mosaic tasks create --title T --requirement REQ [--project P] [--state S] [--assignees R,...] [--priority 0-5] [--due ISO|none]`; `mosaic tasks edit task:N --expect DIGEST ...`; `mosaic tasks archive task:N --expect DIGEST` (E2: the board's own check, then the person's `tasks.create|edit|archive` on the bus) | Enabled for the owner on an organization with the built-in task store |
| Create chat project linked to a configured repository | `mosaic project create <name> --repo <project> --org <id>` | Enabled |
| Create a thread | `mosaic thread create <project> <name> --org <id>` | Planned enabled |
@@ -70,6 +71,38 @@ as the owner's page can. The startup warning says so. Use a dev instance for UI
testing only. The real boundary is sign-in (`--sso`); a refusal for local agent
peers in dev mode is a recorded follow-up.
### Starter pack hires (ruling 2026-11-14, #1873)
"Damnit. If I choose a starter pack and apply it, that is a decision!"
`POST /api/orgs/<id>/packs/apply` (`pack.apply.console`) hires the pack's
proposed agents in the same act, and its receipt is `{revision, added, pack,
hires}`; when the apply committed but its hire loop did not run, `hires` is
empty and `hireError` names the code, and the card says the hires did not run.
`POST /api/orgs/<id>/packs/hire` with `{pack, project?}`
(`pack.hire.console`) runs the same loop on a pack already applied and answers
`{pack, hires}`; `pack-not-applied` is 404 and `right-required` 403. Each hire
line is `{name, state, decision | code}`, and a `hired` or `awaiting-owner`
line adds `routeFrom: 'chief' | 'assistant'`, whose harness and model the hire
got (the chief of staff's, else the organization assistant's). `GET /api/orgs/<id>/packs` adds, to
each applied entry, `hired` (the agents its hires created) and `open` (each
proposal not hired, with its reason, its state and its decision or code, and
`fromPack: true` on an `awaiting-owner` line this pack's own hire raised). The
packs routes stay the owner's: a member's pack hire is the bus verb's
(`pack.hire.console` needs `org.hire`, and the flagged hires wait for the
owner), but no member page calls it yet.
The pack card says what applying does before the click ("applying is the hire
decision, and approves the flagged and credential lines below"), shows "N
agents hired" and the open proposals with their reasons after it, and offers
"Hire the N proposed agents" for proposals not waiting for the owner, and for
the owner's own `fromPack` lines, which the owner's hire replaces and approves. Apply,
hire and fire dispatch `mosaic-org-changed`, so the Agents page and the
organization page redraw in the same cycle instead of at the next 10-second
poll. Each agent card a pack hire made says "from pack X". The owner's Agents
page offers Fire on each agent but the chief of staff and the assistant, with a confirm step;
Fire sends `{op: 'fire'}` through `/api/decisions/hire/start`, removes the
card at once and leaves a receipt naming the decision.
### Route registration
A module may export `registerRoutes(table)` and be included with one entry in
+29 -12
View File
@@ -2,10 +2,14 @@
// selected organization. Owner Console transports only; no caller-supplied business, actor, subject
// or origin reaches the bus.
//
// GET /api/orgs/<id>/packs {packs, writable}: packs.console, the shipped and installed
// packs (never a saved template). /api/packs on a root Console.
// GET /api/orgs/<id>/packs {packs, applied, writable}: packs.console, the shipped and installed
// packs (never a saved template), and each applied entry with the
// agents its hires created and its open proposals. /api/packs on a root Console.
// POST /api/orgs/<id>/packs/preview {pack, project?, as?}: pack.preview.console. Writes nothing.
// POST /api/orgs/<id>/packs/apply {pack, digest, project?, as?}: pack.apply.console
// POST /api/orgs/<id>/packs/apply {pack, digest, project?, as?}: pack.apply.console. Ruling 2026-11-14:
// the owner's apply is the hire decision; the receipt carries `hires`
// POST /api/orgs/<id>/packs/hire {pack, project?}: pack.hire.console, the proposals of a pack already
// applied, hired through the same loop. {pack, hires}
// POST /api/orgs/<id>/board {revision, board, project?}: board.set.console (board null removes it)
// POST /api/orgs/<id>/workflows/<name> {revision, workflow, project?}: workflow.set.console
// DELETE /api/orgs/<id>/workflows/<name> {revision, project?}: workflow.set.console with workflow null
@@ -15,17 +19,17 @@
// file revision a workflow write sends back
//
// `read` is the owner's Console call (consoleCall), used for the two verbs that write nothing. `write`
// is the owner's Console call fenced to the three write verbs (packsCaller), present only for the
// is the owner's Console call fenced to the four write verbs (packsCaller), present only for the
// owner's own session under --send. The broker answers every .console verb to the business human's
// console cap alone, and runs the merge, the digest and the held-credential check itself. `workflows`
// (SP6) is the owner's read of the organization file as the writer reads it ({document, revision,
// templates}), the same read `mosaic org workflow set` takes its revision from; no bus verb answers it.
export const PACKS_ROUTE = /^\/api\/(?:packs(?:\/(preview|apply))?|board|workflows(?:\/([^/]+))?)$/;
export const PACKS_ROUTE = /^\/api\/(?:packs(?:\/(preview|apply|hire))?|board|workflows(?:\/([^/]+))?)$/;
// Which requests the pack route answers. GET and HEAD /api/board stay the Board read (the Console's own task
// board through its upstream); every other method on /api/board is this route's (POST writes, the rest 405).
// serve.mjs dispatches on this, so the HEAD pin in packs-http.test.mjs tests the predicate it uses.
export const packsServes = (path, method) => PACKS_ROUTE.test(path) && !(path === '/api/board' && (method === 'GET' || method === 'HEAD'));
export const PACK_WRITE_VERBS = Object.freeze(['pack.apply.console', 'board.set.console', 'workflow.set.console']);
export const PACK_WRITE_VERBS = Object.freeze(['pack.apply.console', 'pack.hire.console', 'board.set.console', 'workflow.set.console']);
export const packsCaller = call => async (verb, args, options) => {
if (!PACK_WRITE_VERBS.includes(verb)) throw new Error(`the pack writer does not call ${verb}`);
return call(verb, args, options);
@@ -38,9 +42,9 @@ const REVISION = /^[a-f0-9]{64}$/;
const LIMIT = 65536;
const codes = {
'invalid-request': 400, 'credential-leak': 400, 'starter-pack-invalid': 400, 'definition-invalid': 400,
'starter-pack-not-found': 404, 'project-not-found': 404,
'starter-pack-not-found': 404, 'project-not-found': 404, 'pack-not-applied': 404,
'pack-plan-stale': 409, 'revision-conflict': 409, 'organization-busy': 409,
'human-required': 403, 'read-only': 403,
'human-required': 403, 'read-only': 403, 'right-required': 403,
'not-configured': 503, 'no-bus-host': 503,
};
const one = (req, key) => req.headersDistinct[key]?.length === 1 ? req.headersDistinct[key][0] : null;
@@ -89,7 +93,7 @@ export async function packsHTTP(req, res, { path, owner, read, write, workflows
let verb, args = {};
if (list) verb = 'packs.console';
else if (step !== null) {
const value = await body(req, step === 'apply' ? ['pack', 'digest'] : ['pack'], ['project', 'as']);
const value = await body(req, step === 'apply' ? ['pack', 'digest'] : ['pack'], step === 'hire' ? ['project'] : ['project', 'as']);
if (value === 'too-large') return refuse(413, 'invalid-request');
if (!value || typeof value.pack !== 'string' || !(NAME.test(value.pack) || SAVED.test(value.pack))
|| (step === 'apply' && (typeof value.digest !== 'string' || !REVISION.test(value.digest)))
@@ -124,16 +128,29 @@ export async function packsHTTP(req, res, { path, owner, read, write, workflows
if (!plain(result)) return refuse(503, 'outcome-unknown');
if (list) {
if (!Array.isArray(result.packs)) return refuse(503, 'outcome-unknown');
return json(res, 200, { packs: result.packs, writable: Boolean(write) });
const applied = Array.isArray(result.applied) ? result.applied.filter(plain).map(a => ({ id: a.id, origin: a.origin ?? null, project: a.project ?? null,
hired: Array.isArray(a.hired) ? a.hired.filter(n => typeof n === 'string') : [],
open: Array.isArray(a.open) ? a.open.filter(plain).map(o => ({ name: o.name, reason: typeof o.reason === 'string' ? o.reason : '', state: o.state, ...(typeof o.code === 'string' ? { code: o.code } : {}), ...(typeof o.decision === 'string' ? { decision: o.decision } : {}), ...(o.fromPack === true ? { fromPack: true } : {}) })) : [] })) : [];
return json(res, 200, { packs: result.packs, applied, writable: Boolean(write) });
}
if (step === 'preview') {
if (!REVISION.test(result.digest ?? '')) return refuse(503, 'outcome-unknown');
return json(res, 200, result);
}
// A write admitted may already have committed: its receipt is returned even if the session ended
// meanwhile, never reported as not done.
// meanwhile, never reported as not done. Each hire line is its name, state and decision or code,
// and for a raised hire routeFrom, whose harness and model it got (lane pr2).
const hires = Array.isArray(result.hires) ? result.hires.filter(plain).map(h => ({ name: h.name, state: h.state,
...(typeof h.decision === 'string' ? { decision: h.decision } : {}), ...(typeof h.code === 'string' ? { code: h.code } : {}),
...(['chief', 'assistant'].includes(h.routeFrom) ? { routeFrom: h.routeFrom } : {}) })) : null;
if (step === 'hire') {
if (!hires) return refuse(503, 'outcome-unknown');
return json(res, 200, { pack: result.pack, hires });
}
if (!REVISION.test(result.revision ?? '')) return refuse(503, 'outcome-unknown');
return json(res, 200, { revision: result.revision, added: Array.isArray(result.added) ? result.added : [], ...(step === 'apply' ? { pack: result.pack } : {}) });
// hireError: the apply committed but its hire loop did not run (organization-busy, outcome-unknown).
const hireError = step === 'apply' && typeof result.hireError === 'string' && /^[a-z0-9-]{1,64}$/.test(result.hireError) ? { hireError: result.hireError } : {};
return json(res, 200, { revision: result.revision, added: Array.isArray(result.added) ? result.added : [], ...(step === 'apply' ? { pack: result.pack, hires: hires ?? [], ...hireError } : {}) });
}
// SP6: the workflow list and editor's read. Only workflows, the names a step role may resolve to and the
+51 -1
View File
@@ -469,7 +469,56 @@
else if(res?.ok && !templatesOnly && json?.op==='resolved'){hireStatus=`Recorded decision ${json.id}.`;}
else {const r=decideRefusal(res,json,templatesOnly?'confirmation-unavailable':'outcome-unknown');hireStatus=r.message;hireCode=r.code;}
paintHireStart();
// A hire, fire or change applied: the Agents page and any pane showing this organization draw it now.
if(res?.ok && !templatesOnly && json?.op==='resolved' && hireOrg())document.dispatchEvent(new CustomEvent('mosaic-org-changed',{detail:{org:hireOrg()}}));
}
// Fire from an agent card on the Agents page (ruling 2026-11-14, visibility): the same hire decision the
// form above raises with op fire, after an inline confirmation. On success the card goes at once, a
// receipt names the decision, and the page reads the organization again. The confirmation and the
// receipt live here so a redraw keeps them; workspace.js draws them from MosaicDecide.
const FIRE_REASON='Fired from the Agents page.';
let fireAsk=null,fireBusy=false,fireReceipt=null;
const fireKey=(org,name)=>`${org}\u0000${name}`;
function fireNote(){const org=hireOrg();const r=fireReceipt&&fireReceipt.org===org?fireReceipt:null;
return r?`<p class="receipt${r.failed?' failed':''}" data-agents-receipt-text>${txt(r.text)}${r.code?` (<code>${txt(r.code)}</code>)`:''}</p>`:'';}
function paintFireNote(){for(const e of document.querySelectorAll('[data-agents-receipt]'))e.innerHTML=fireNote();}
async function fireAgent(name){
const org=hireOrg();
if(fireBusy||!org||out()||!canDecide)return;
fireBusy=true;
for(const b of document.querySelectorAll('[data-agent-fire-yes],[data-agent-fire-no]'))b.disabled=true;
const {res,json}=await decidePost('/api/decisions/hire/start',{business:org,hire:{op:'fire',level:'org',name,reason:FIRE_REASON}});
fireBusy=false;fireAsk=null;
if(res?.type==='opaqueredirect'){signOut();return;}
if(res?.ok && json?.op==='resolved'){
for(const c of document.querySelectorAll(`[data-org="${CSS.escape(org)}"] [data-agent-card="${CSS.escape(name)}"]`))c.remove();
for(const r of document.querySelectorAll(`[data-agents-org="${CSS.escape(org)}"] tr[data-agent="${CSS.escape(name)}"]`))r.remove();
fireReceipt={org,text:`Fired ${name}. Recorded decision ${json.id}.`};
paintFireNote();announce(fireReceipt.text);
document.dispatchEvent(new CustomEvent('mosaic-org-changed',{detail:{org}}));
} else {
const r=decideRefusal(res,json,'outcome-unknown');
fireReceipt={org,failed:true,text:`${name} was not fired: ${r.message}`,code:r.code};
paintFireNote();
for(const c of document.querySelectorAll(`[data-agent-fire-confirm="${CSS.escape(name)}"]`))c.outerHTML=fireButton(name);
}
}
const fireButton=name=>`<button type="button" class="btn small" data-agent-fire="${esc(name)}" data-focus-key="${esc(`fire:${name}`)}" aria-label="Fire ${esc(name)}">Fire</button>`;
const fireConfirm=name=>`<span class="ws-fire-confirm" role="group" aria-label="Fire ${esc(name)}?" data-agent-fire-confirm="${esc(name)}">Fire ${txt(name)}? <button type="button" class="btn small primary" data-agent-fire-yes="${esc(name)}" data-focus-key="${esc(`fire-yes:${name}`)}"${fireBusy?' disabled':''}>Confirm fire</button><button type="button" class="btn small" data-agent-fire-no="${esc(name)}"${fireBusy?' disabled':''}>Cancel</button></span>`;
// What the agent card shows for one agent: nothing without the decide right or outside the page's own organization.
function fireControl(org,name){
if(!canDecide||isTester||org!==hireOrg())return '';
return fireAsk===fireKey(org,name)?fireConfirm(name):fireButton(name);
}
document.addEventListener('click',e=>{
const t=e.target.closest?.('[data-agent-fire],[data-agent-fire-yes],[data-agent-fire-no]');
if(!t)return;
const org=hireOrg();
if(t.hasAttribute('data-agent-fire')){const name=t.dataset.agentFire;fireAsk=fireKey(org,name);t.outerHTML=fireConfirm(name);document.querySelector(`[data-agent-fire-yes="${CSS.escape(name)}"]`)?.focus();}
else if(t.hasAttribute('data-agent-fire-no')){const name=t.dataset.agentFireNo;fireAsk=null;t.closest('[data-agent-fire-confirm]').outerHTML=fireButton(name);announce('Cancelled. Nothing was fired.');}
else void fireAgent(t.dataset.agentFireYes);
});
window.MosaicDecide=Object.freeze({fireControl,fireNote});
document.addEventListener('click',e=>{if(e.target.closest?.('[data-hire-load]'))hireSubmit(true);});
document.addEventListener('submit',e=>{if(e.target.id==='hire-start-form'){e.preventDefault();hireSubmit();}});
const answerChoices = c => ['decision','hire'].includes(c.kind)
@@ -1934,7 +1983,8 @@
// E1 (P1-5): after Restart bus the organization dashboard draws its bus line now, not at the next poll.
// E2E6 P2-2: a write on the organization page that changes other sections (a pack apply) asks for a
// fresh read of the page; editors in progress are kept by holdLive.
document.addEventListener('mosaic-org-changed', e => { if (window.MosaicOrg && e.detail?.org === window.MosaicOrg.id && routeOf().split('?')[0] === '/organization') render(false); });
// Ruling 2026-11-14: an apply, hire or fire redraws the Agents page in the same response cycle, not at the next poll.
document.addEventListener('mosaic-org-changed', e => { if (window.MosaicOrg && e.detail?.org === window.MosaicOrg.id && ['/organization', '/agents'].includes(routeOf().split('?')[0])) render(false); });
document.addEventListener('mosaic-config', e => { if (e.detail && typeof e.detail === 'object') { applyConfig(e.detail); if (window.MosaicOrg && routeOf().split('?')[0] === '/organization') render(false); } });
// The cross-organization pages ask for a repaint after a filter, sort or selection change,
// and report the inbox count across organizations for the sidebar.
+4
View File
@@ -17,3 +17,7 @@
:is([data-packs],[data-workflows]) .btn{min-height:44px;margin:4px 8px 4px 0}
:is([data-packs],[data-workflows]) :is(button,select,input,textarea):focus-visible{outline:2px solid var(--focus);outline-offset:2px}
:is([data-packs],[data-workflows]) [role=status]{overflow-wrap:anywhere}
.pack-applied{border-left:3px solid var(--border);padding:4px 0 4px 12px;margin:8px 0}
.pack-applied p{margin:0 0 6px}
.pack-open{list-style:none;margin:6px 0 8px;padding:0}
.pack-open li{padding:4px 0;overflow-wrap:anywhere}
+70 -7
View File
@@ -3,6 +3,10 @@
// pack.preview (POST .../packs/preview, writes nothing) and applies the previewed plan by its digest
// (POST .../packs/apply). The bus builds the plan, the marks, the flagged and credential lines and the
// noHire marks; this page only shows them. Every pack- or organization-supplied string goes through esc.
// Ruling 2026-11-14 ("If I choose a starter pack and apply it, that is a decision!"): the owner's apply
// hires the proposals in the same act. Each pack card says how many agents it hired and lists the
// proposals still open with their reasons; "Hire the N proposed agents" (POST .../packs/hire) runs the
// same hire loop on the recorded entry.
(() => {
'use strict';
const esc = x => String(x ?? '').replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
@@ -25,7 +29,23 @@
'not-configured': 'This Console has no bus connection for packs.',
'no-bus-host': 'The organization bus is not running.',
'invalid-request': 'The bus refused the request as malformed.',
'pack-not-applied': 'Apply this pack first; its hires come from the applied entry.',
'right-required': 'Hiring needs the org.hire right.',
};
// Why a hire did not happen, by the code the bus recorded. An unknown code is shown as itself.
const HIRE_WHY = {
'pack-hire-route-missing': 'neither the chief of staff nor the assistant has a harness and model to give it; set them under Settings, Routing',
'no-chief-of-staff': 'the organization has no chief of staff',
'hire-owner-required': 'it has flagged or credential lines only the owner approves',
'definition-not-hireable': 'its role is on the no-hire list',
'starter-pack-proposal-stale': 'the pack changed since it was applied; apply it again',
'human-required': 'only a person can hire',
'organization-busy': 'another pack hire on this organization was running',
'outcome-unknown': 'the bus did not say why',
};
const whyText = code => HIRE_WHY[code] ?? code;
const OPEN_STATE = { 'awaiting-owner': 'waiting for the owner in the inbox', proposed: 'proposed, not hired yet' };
const plainObj = x => x !== null && typeof x === 'object' && !Array.isArray(x);
const MARKS = {
add: 'will be added',
same: 'already the same',
@@ -43,10 +63,11 @@
// pack and the last receipt. A kept preview applies by its digest, so a stale one is refused by the bus.
async function load(org) {
const prev = states.get(org);
const s = { packs: null, writable: false, error: null, projects: null, preview: prev?.preview ?? null, status: prev?.status ?? '', picks: prev?.picks ?? {} };
const s = { packs: null, applied: [], writable: false, error: null, projects: null, preview: prev?.preview ?? null, status: prev?.status ?? '', picks: prev?.picks ?? {} };
try {
const value = await call(`${base(org)}/packs`);
s.packs = Array.isArray(value?.packs) ? value.packs.filter(p => p && typeof p === 'object' && p.pack && typeof p.pack === 'object') : [];
s.applied = Array.isArray(value?.applied) ? value.applied.filter(a => plainObj(a) && typeof a.id === 'string' && Array.isArray(a.hired) && Array.isArray(a.open)) : [];
s.writable = value?.writable === true;
} catch (error) { s.error = error.message; }
// A project pack needs a code project. The workflow read lists them; when it fails, a project pack
@@ -72,7 +93,11 @@
const creds = Array.isArray(h.credentialLines) && h.credentialLines.length
? `<p>Credential lines:</p><ul data-pack-credentials>${h.credentialLines.map(l => `<li><code>${esc(l?.service)}</code>${l?.scope === undefined ? '' : ` ${esc(l.scope)}`}</li>`).join('')}</ul>`
: '<p class="note">No credential lines.</p>';
const mark = h.mark === 'dropped' ? 'Not proposed: an agent or open hire decision already has this name.' : 'Recorded on apply for the chief to raise as a hire decision.';
const hasCreds = Array.isArray(h.credentialLines) && h.credentialLines.length > 0;
const owner = (Array.isArray(h.flagged) && h.flagged.length) || h.flagged === null || hasCreds;
// The binding sentence only where the hire has a credential line to bind.
const mark = h.mark === 'dropped' ? 'Not proposed: an agent or open hire decision already has this name.'
: `Hired when you apply: applying is the hire decision${owner ? ', and approves the flagged and credential lines below' : ''}.${hasCreds ? ' A credential line is approved, not bound: the agent waits for a binding before it launches.' : ''}`;
const noHire = h.noHire ? '<p class="pack-nohire" data-pack-nohire>On this organization\'s no-hire list: the decision will say so.</p>' : '';
return `<li class="pack-hire" data-pack-hire="${esc(h.name)}"><strong>${esc(h.name)}</strong> <span class="muted">role ${esc(h.definition)}</span><p>${esc(mark)}</p>${noHire}${flagged}${creds}</li>`;
}
@@ -87,6 +112,23 @@
const apply = s.writable ? `<button class="btn" type="button" data-pack-apply data-focus-key="pack-apply">Apply this plan</button>` : '<p class="note">This Console was started without send. Packs can be previewed, not applied.</p>';
return `<div class="pack-preview" data-pack-preview="${esc(p.pack)}"><h4>Preview: ${esc(v.pack?.label ?? p.pack)}${v.project ? ` <span class="muted">for project ${esc(v.project)}</span>` : ''}</h4>${items}${hires}${skills}${chief}${apply}</div>`;
}
// The pack's applied entries (one per project for a project pack): the agents it hired and its open proposals.
function appliedBlock(org, s, entry) {
const mine = s.applied.filter(a => a.id === entry.id && (a.origin ?? null) === (entry.origin ?? null));
if (!mine.length) return '';
return mine.map(a => {
const where = a.project ? ` <span class="muted">in project ${esc(a.project)}</span>` : '';
const n = a.hired.length;
const hired = `<p data-pack-hired="${esc(a.project ?? '')}"><strong>${n === 0 ? 'No agents hired yet' : `${n} ${n === 1 ? 'agent' : 'agents'} hired`}</strong>${where}${n ? `: ${a.hired.map(x => `<code>${esc(x)}</code>`).join(', ')}` : ''}</p>`;
const open = a.open.filter(plainObj);
const lines = open.length ? `<ul class="pack-open" data-pack-open>${open.map(o => `<li data-pack-open-hire="${esc(o.name)}" data-pack-open-state="${esc(o.state)}"><code>${esc(o.name)}</code>: ${esc(o.reason || 'No reason given.')} <span class="muted">${esc(o.state === 'refused' ? `not hired: ${whyText(o.code)}` : OPEN_STATE[o.state] ?? o.state)}</span></li>`).join('')}</ul>` : '';
// The page is the owner's, and the owner's pack hire also approves a line this pack's own hire
// left in the inbox (fromPack). A decision raised some other way stays the inbox's.
const hireable = open.filter(o => o.state !== 'awaiting-owner' || o.fromPack === true).length;
const button = hireable && s.writable ? `<button class="btn" type="button" data-pack-hire-button data-pack-hire-project="${esc(a.project ?? '')}" data-focus-key="${esc(`pack-hire:${org}:${entry.id}:${a.project ?? ''}`)}">Hire the ${hireable} proposed ${hireable === 1 ? 'agent' : 'agents'}</button>` : '';
return `<div class="pack-applied">${hired}${lines}${button}</div>`;
}).join('');
}
function packRow(org, s, entry) {
const pack = entry.pack;
const project = pack.scope === 'project'
@@ -96,13 +138,13 @@
: '';
const canPreview = pack.scope !== 'project' || (s.projects && s.projects.length);
return `<li class="pack-row" data-pack="${esc(entry.id)}"><strong>${esc(pack.label ?? entry.id)}</strong> <span class="muted"><code>${esc(entry.id)}</code> ${esc(pack.version)} · ${esc(entry.origin)} · ${pack.scope === 'project' ? 'project pack' : 'organization pack'}</span>
<p>${esc(pack.description)}</p>${project}${canPreview ? `<button class="btn" type="button" data-pack-preview-button data-focus-key="${esc(`pack-preview:${org}:${entry.id}`)}">Preview</button>` : ''}</li>`;
<p>${esc(pack.description)}</p>${appliedBlock(org, s, entry)}${project}${canPreview ? `<button class="btn" type="button" data-pack-preview-button data-focus-key="${esc(`pack-preview:${org}:${entry.id}`)}">Preview</button>` : ''}</li>`;
}
function section(org) {
const s = states.get(org);
if (!s || s.error) return `<section class="panel" data-packs data-packs-org="${esc(org)}" data-live-key="${esc(`packs:${org}`)}"><h3>Starter packs</h3><p>${esc(s?.error ?? 'Starter packs could not be read.')}</p></section>`;
const list = s.packs.length ? `<ul class="pack-list">${s.packs.map(e => packRow(org, s, e)).join('')}</ul>` : '<p class="note">No starter packs are installed.</p>';
return `<section class="panel" data-packs data-packs-org="${esc(org)}" data-live-key="${esc(`packs:${org}`)}"><h3>Starter packs</h3><p>A pack adds roles, a board and workflows to the organization. Preview shows what it would change; apply adds only what is missing, and a hire it proposes is only raised by the chief, as a decision.</p>${list}${previewBlock(s)}<p role="status" data-packs-status>${esc(s.status)}</p></section>`;
return `<section class="panel" data-packs data-packs-org="${esc(org)}" data-live-key="${esc(`packs:${org}`)}"><h3>Starter packs</h3><p>A pack adds roles, a board and workflows to the organization. Preview shows what it would change; apply adds only what is missing and hires the agents it proposes. Applying is the hire decision.</p>${list}${previewBlock(s)}<p role="status" data-packs-status>${esc(s.status)}</p></section>`;
}
function redraw(org) {
const el = [...document.querySelectorAll('[data-packs-org]')].find(n => n.dataset.packsOrg === org);
@@ -114,14 +156,19 @@
if (s) s.picks[select.closest('[data-pack]').dataset.pack] = select.value;
});
document.addEventListener('click', async e => {
const button = e.target.closest('[data-pack-preview-button],[data-pack-apply]');
const button = e.target.closest('[data-pack-preview-button],[data-pack-apply],[data-pack-hire-button]');
if (!button) return;
const root = button.closest('[data-packs-org]'), org = root?.dataset.packsOrg, s = states.get(org);
if (!s) return;
button.disabled = true;
let applied = false;
try {
if (button.hasAttribute('data-pack-preview-button')) {
if (button.hasAttribute('data-pack-hire-button')) {
const pack = button.closest('[data-pack]').dataset.pack, project = button.dataset.packHireProject;
const r = await call(`${base(org)}/packs/hire`, { pack, ...(project ? { project } : {}) });
s.status = hireSummary(r.hires);
applied = true;
} else if (button.hasAttribute('data-pack-preview-button')) {
const row = button.closest('[data-pack]'), pack = row.dataset.pack, project = row.querySelector('[data-pack-project]')?.value;
const body = { pack, ...(project ? { project } : {}) };
s.preview = { pack, project: project ?? null, value: await call(`${base(org)}/packs/preview`, body) };
@@ -131,15 +178,31 @@
const r = await call(`${base(org)}/packs/apply`, { pack: p.pack, digest: p.value.digest, ...(p.project ? { project: p.project } : {}) });
const added = Array.isArray(r.added) && r.added.length ? r.added.join(', ') : 'nothing new';
s.preview = null;
s.status = `Applied. Added ${added}. Proposed hires are recorded for the chief to raise as decisions.`;
s.status = `Applied. Added ${added}. ${typeof r.hireError === 'string' ? `The hires did not run: ${whyText(r.hireError)}. Use Hire on the pack card to run them.` : hireSummary(r.hires)}`;
applied = true;
}
} catch (error) { s.status = error.message; }
// An apply or a hire reads the packs again, so the card's hired count and open proposals are the bus's now.
if (applied) await load(org);
// A preview the page cannot draw says so, instead of leaving the button disabled with nothing shown.
try { redraw(org); } catch { s.preview = null; s.status = 'The preview could not be shown.'; redraw(org); }
// An apply changes the workflows, the board and the roles too: the page and any pane showing this
// organization read every section again. The receipt above is kept in state, so it survives that read.
if (applied) document.dispatchEvent(new CustomEvent('mosaic-org-changed', { detail: { org } }));
});
// One line for the hires an apply or a hire made, from the bus's answer.
function hireSummary(hires) {
const list = Array.isArray(hires) ? hires.filter(plainObj) : [];
if (!list.length) return 'The pack proposes no hires.';
const names = state => list.filter(h => h.state === state).map(h => h.name);
const parts = [];
const hired = names('hired'), waiting = names('awaiting-owner'), exists = names('exists'), open = names('open');
if (hired.length) parts.push(`Hired ${hired.join(', ')}.`);
if (waiting.length) parts.push(`Sent to the owner's inbox: ${waiting.join(', ')}.`);
if (open.length) parts.push(`Already waiting as a decision: ${open.join(', ')}.`);
if (exists.length) parts.push(`Already hired: ${exists.join(', ')}.`);
for (const h of list.filter(h => h.state === 'refused')) parts.push(`Not hired: ${h.name}, ${whyText(h.code)}.`);
return parts.join(' ');
}
window.MosaicPacks = { load, section };
})();
+5
View File
@@ -550,6 +550,11 @@ html[data-organizations] .ws-levels .mlabel{display:none;align-items:center;gap:
html[data-organizations] .ws-agent{display:inline-flex;align-items:center;gap:6px;border:1px solid var(--line);border-radius:999px;padding:2px 10px 2px 8px;font-size:12.5px;font-weight:600;background:var(--canvas);margin:2px 4px 2px 0;color:var(--strong)}
html[data-organizations] .ws-agent small{color:var(--muted);font-weight:500;font-size:11px}
html[data-organizations] .ws-agent .ws-dot b{font-size:11px;width:10px}
html[data-organizations] .ws-agent{flex-wrap:wrap}
html[data-organizations] .ws-agent .btn{min-height:24px;padding:0 10px;font-size:11.5px;border-radius:999px}
html[data-organizations] .ws-fire-confirm{display:inline-flex;flex-wrap:wrap;align-items:center;gap:6px;font-weight:500}
html[data-organizations] [data-agents-receipt]:empty{display:none}
html[data-organizations] [data-agents-receipt] .receipt{margin:0 0 12px}
html[data-organizations] .ws-rules{display:grid;gap:10px;font-size:13px}
html[data-organizations] .ws-rules p{display:flex;flex-wrap:wrap;align-items:baseline;gap:6px;margin:0}
html[data-organizations] .ws-psel{display:inline-flex;align-items:center;gap:6px;border:1px solid var(--line);background:var(--surface);border-radius:var(--r);padding:0 0 0 8px;font-size:13px;color:var(--strong);white-space:nowrap;max-width:100%}
+15 -5
View File
@@ -367,7 +367,7 @@
if (!set && inst === null) return '<p class="ws-panel tight note">Agents unavailable. The bus did not answer the instance read.</p>';
if (!rows.length) return '<p class="ws-panel tight note">No agents configured</p>';
const where = a => (a.level === 'root' ? 'Your root configuration' : a.level === 'org' ? `${label(selOrg)} file` : `${names(a.project)} project`);
const sub = a => [a.definition && a.definition !== a.name ? a.definition : '', a.overrides?.length ? `replaces the ${a.overrides.map(o => (o.level === 'org' ? 'organization' : o.level)).join(' and ')} ${a.overrides.length > 1 ? 'definitions' : 'definition'}` : '', a.narrowed?.length ? `narrowed here: ${a.narrowed.join(', ')}` : ''].filter(Boolean).join(' · ');
const sub = a => [a.definition && a.definition !== a.name ? a.definition : '', a.level === 'org' && typeof inst?.[a.name]?.pack?.id === 'string' ? `from pack ${inst[a.name].pack.id}` : '', a.overrides?.length ? `replaces the ${a.overrides.map(o => (o.level === 'org' ? 'organization' : o.level)).join(' and ')} ${a.overrides.length > 1 ? 'definitions' : 'definition'}` : '', a.narrowed?.length ? `narrowed here: ${a.narrowed.join(', ')}` : ''].filter(Boolean).join(' · ');
const status = a => {
if (a.assistant) return dot('s-unknown', '?', 'status not reported', true);
if (a.level !== 'org') {
@@ -417,7 +417,16 @@
const n = Math.max(valid.length, 1);
const cell = (org, inner, cls = '') => `<div class="ws-o${cls}" style="${colours(org)}" data-org="${esc(org.id)}"><span class="mlabel">${av(org, 'xs')}${esc(label(org))}</span>${inner}</div>`;
const endc = i => (i === results.length - 1 ? ' endc' : '');
const pill = (name, item, agents) => { const cos = item?.definition === 'chief-of-staff' || name === 'chief'; const [acls, aglyph, aword] = activity(name, item, agents); return `<span class="ws-agent${cos ? ' cos' : ''}">${dot(acls, aglyph, aword)}${esc(name)}${cos && name !== 'chief' ? ' <small>chief</small>' : ''}</span>`; };
// Ruling 2026-11-14: a card names the pack that hired its agent, and the page's own organization
// offers Fire on every card but the chief's and the assistant's (bus.js draws the control and its confirmation).
const pill = (org, name, item, agents) => {
const cos = item?.definition === 'chief-of-staff' || name === 'chief';
const [acls, aglyph, aword] = activity(name, item, agents);
const from = typeof item?.pack?.id === 'string' ? ` <small data-agent-pack="${esc(item.pack.id)}">from pack ${esc(item.pack.id)}</small>` : '';
// No Fire the bus would refuse: the assistant (and any end-protected instance) is never fired.
const fire = cos || item?.definition === 'assistant' || item?.endProtected === true ? '' : window.MosaicDecide?.fireControl(org.id, name) ?? '';
return `<span class="ws-agent${cos ? ' cos' : ''}" data-agent-card="${esc(name)}">${dot(acls, aglyph, aword)}${esc(name)}${cos && name !== 'chief' ? ' <small>chief</small>' : ''}${from}${fire}</span>`;
};
// A root or project copy in one organization's cell: its status from that organization's bus, and
// why it can't launch when it can't. No answer from the bus reads as unavailable, never as idle.
const copyPill = (copies, agents, name, project, small) => {
@@ -439,7 +448,7 @@
const on = kept.filter(a => !own(a)), replaced = kept.filter(own);
return cell(org, `${on.map(a => copyPill(copies, agents, a.name, null, 'copy')).join('') || '<span class="muted">none</span>'}${replaced.length ? `<span class="note ws-off">${esc(replaced.map(a => a.name).join(', '))} replaced by ${esc(label(org))}'s own</span>` : ''}${off.length ? `<span class="note ws-off">${esc(off.map(a => a.name).join(', '))} turned off for ${esc(label(org))}</span>` : ''}`, endc(i));
}).join('');
const orgRow = results.map(({ org, inst, agents }, i) => cell(org, inst === null ? '<span class="muted">Agents unavailable</span>' : Object.keys(inst).length ? Object.entries(inst).map(([k, v]) => pill(k, v, agents)).join('') : '<span class="muted">No agents configured</span>', endc(i))).join('');
const orgRow = results.map(({ org, inst, agents }, i) => cell(org, inst === null ? '<span class="muted">Agents unavailable</span>' : Object.keys(inst).length ? Object.entries(inst).map(([k, v]) => pill(org, k, v, agents)).join('') : '<span class="muted">No agents configured</span>', endc(i))).join('');
// Project: the agents a project defines itself. Narrowing and exclusion show in the table.
const projRow = results.map(({ org, lv, rows, copies, agents }, i) => {
if (lv?.state !== 'ok') return cell(org, `<span class="muted">${lv?.state === 'invalid' ? 'Project agents unavailable: the organization file was refused' : 'Project agents unavailable'}</span>`, ` last${endc(i)}`);
@@ -464,8 +473,9 @@
const title = projectIds.length ? 'Available in a project' : `Agents in ${esc(label(selOrg))}`;
const table = !mine ? '<p class="ws-panel tight note">The selected organization is not on the list.</p>' : levelsTable(ctx, pick);
const rules = `<div class="ws-panel tight ws-rules"><p><span class="ws-lvl root">root</span>You define root agents in agents.json in your configuration directory. The chief cannot change them.</p><p><span class="ws-lvl org">org</span>Agents come from the organization file. Add, change or remove them under Settings, Agents.</p><p><span class="ws-lvl proj">project</span>A project in the organization file can add an agent, narrow an inherited agent's limits or exclude one. It never widens them.</p><p><span class="ws-lvl proj">override</span>The most specific level wins. A row says which definition it replaces.</p><p class="muted">An agent's harness, model and thinking are set under Settings, Routing.</p></div>`;
return `<section class="ws ws-agents" data-org-agents>${head}${band}${levelsGrid}
<div class="ws-split ws-split-agents"><section><div class="ws-sec-h"><h2>${title}</h2>${chooser}</div><div data-agents-available>${table}</div></section><section>${secH('Who changes what')}${rules}</section></div>
const receipt = selected === window.MosaicOrg?.id ? `<div data-agents-receipt role="status">${window.MosaicDecide?.fireNote() ?? ''}</div>` : '';
return `<section class="ws ws-agents" data-org-agents>${head}${receipt}${band}${levelsGrid}
<div class="ws-split ws-split-agents"><section><div class="ws-sec-h"><h2>${title}</h2>${chooser}</div><div data-agents-available data-agents-org="${esc(selected)}">${table}</div></section><section>${secH('Who changes what')}${rules}</section></div>
</section>`;
}
document.addEventListener('change', e => {
@@ -0,0 +1,186 @@
// Ruling 2026-11-14: "If I choose a starter pack and apply it, that is a decision!" Driven in Chromium
// against stand-in pack, instance and decision verbs: the owner's apply hires the pack's proposals, the
// pack card says how many agents it hired and lists the open proposals with their reasons, and "Hire the
// N proposed agents" runs the hire again. The Agents page shows the new cards, each naming its pack, in
// the same response cycle as the write, and Fire removes a card at once with a receipt. Each wait is
// at most three seconds, well inside the page's ten-second poll, so the write's own redraw shows them.
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdir } from 'node:fs/promises';
import { join } from 'node:path';
import { startServer } from '../src/serve.mjs';
import { createDevOwner } from '../src/dev-owner.mjs';
import { browser } from './browser.mjs';
import { close } from './fixture.mjs';
const REV = n => String(n).repeat(64);
const waitFor = (b, expression, what, tries = 100) => b.evaluate(`(async()=>{for(let i=0;i<${tries};i++){if(${expression})return true;await new Promise(r=>setTimeout(r,30))}throw Error(${JSON.stringify(what)}+': '+document.body.innerText.slice(0,2000))})()`);
const PACKS = [{ id: 'software-team', origin: 'shipped', pack: { label: 'Software team', description: 'Roles, a board and a review workflow.', version: '1.0.0', scope: 'organization' } }];
const FROM = { id: 'software-team', origin: 'shipped', project: null }; // the bus's instances read shape (pack-hire.test.mjs)
const PREVIEW = {
pack: { id: 'software-team', version: '1.0.0', label: 'Software team', origin: 'shipped', scope: 'organization' },
project: null, revision: REV('c'),
items: [{ item: 'board', name: 'board', target: 'board', path: 'board', mark: 'add' }],
hires: [
{ name: 'coder-1', definition: 'coder', mark: 'propose', pin: null, flagged: [{ field: 'tools', value: 'bash', reasons: ['above-chief'] }], credentialLines: [{ service: 'gitea', scope: 'repo:write' }], noHire: false },
{ name: 'reviewer-1', definition: 'reviewer', mark: 'propose', pin: null, flagged: [], credentialLines: [], noHire: false },
{ name: 'scout-1', definition: 'scout', mark: 'propose', pin: null, flagged: [], credentialLines: [], noHire: false },
],
skills: { notEnabled: [] }, chief: true, plan: {}, digest: REV('d'),
};
// busy: the bus's apply committed but its hire loop did not run (hireError), and the packs read lists one line a
// member's pack hire left in the owner's inbox (fromPack), one raised some other way and one refused.
async function fixture(t, { busy = false } = {}) {
const verbs = [], decisions = [];
const hired = new Map(); // name -> its pack provenance, as the bus's instances read reports it
let applied = null, route = false, instanceReads = 0;
const instances = () => ({ chief: { definition: 'chief-of-staff', running: [] }, assistant: { definition: 'assistant', running: [], protected: true, endProtected: true }, ...Object.fromEntries([...hired].map(([n, pack]) => [n, { definition: n.split('-')[0], running: [], pack }])) });
// The bus's hire loop, stood in: with a route every open proposal is hired; without one scout-1 is refused.
const hireLoop = () => PREVIEW.hires.map(h => {
if (hired.has(h.name)) return { name: h.name, state: 'exists' };
if (!route && h.name === 'scout-1') return { name: h.name, state: 'refused', code: 'pack-hire-route-missing' };
hired.set(h.name, FROM); const decision = `d-${hired.size}`; decisions.push(decision);
return { name: h.name, state: 'hired', decision };
});
const listed = () => applied ? [{ id: 'software-team', origin: 'shipped', project: null, hired: [...hired.keys()],
open: PREVIEW.hires.filter(h => !hired.has(h.name)).map(h => ({ name: h.name, reason: `Proposed by starter pack software-team for ${h.definition} work.`,
...(!busy || h.name === 'scout-1' ? { state: 'refused', code: 'pack-hire-route-missing' } : { state: 'awaiting-owner', decision: `inbox-${h.name}`, ...(h.name === 'coder-1' ? { fromPack: true } : {}) }) })) }] : [];
const read = async (verb, args) => {
verbs.push([verb, args]);
if (verb === 'packs.console') return { packs: PACKS, applied: listed() };
if (verb === 'pack.preview.console') return PREVIEW;
if (verb === 'autonomy.get.console') return { revision: REV('e'), autonomy: {}, levels: {} };
throw Object.assign(new Error('not-configured'), { code: 'not-configured' });
};
const write = async (verb, args) => {
verbs.push([verb, args]);
if (verb === 'pack.apply.console') { applied = true; return { revision: REV('f'), added: ['board'], pack: { id: args.pack, version: '1.0.0' }, ...(busy ? { hires: [], hireError: 'organization-busy' } : { hires: hireLoop() }) }; }
if (verb === 'pack.hire.console') return { pack: args.pack, hires: hireLoop() };
throw Object.assign(new Error('not-configured'), { code: 'not-configured' });
};
const decisionWrite = async (verb, args) => {
verbs.push([verb, args]);
if (verb === 'hire.start.console' && args.op === 'fire' && hired.has(args.name)) { hired.delete(args.name); return { id: 'fire-1', op: 'resolved', choice: 'yes' }; }
throw Object.assign(new Error('invalid-request'), { code: 'invalid-request' });
};
const server = await startServer({
port: 0, sso: createDevOwner(), send: async () => ({}), log: () => {},
organizations: {
list: async () => [{ id: 'acme', label: 'Acme', busHealthy: true }],
resolve: async () => ({
busHealthy: true, decisionWrite, requestRead: read, requestWrite: read, packsWrite: write,
workflowsRead: async () => ({ revision: REV('a'), document: { workflows: {}, projects: {}, definitions: {} }, templates: [] }), send: async () => ({}),
reads: { business: async () => ({ business: { id: 'acme', projects: [], instances: [] } }), settings: async () => ({}), queue: async () => ({ rows: [] }) },
bus: { instances: async () => { instanceReads++; return { revision: REV('b'), instances: instances(), definitions: {} }; }, agents: async () => [], 'thread.claims': async () => [], tasks: async () => [], inbox: async () => [], messages: async () => ({ messages: [] }) },
chat: { projects: async () => [], threads: async () => [] },
}),
},
});
t.after(() => close(server));
const b = await browser({ machine: false, args: ['--disable-extensions'] });
t.after(() => b.close());
await b.viewport(1440, 900);
return { b, verbs, decisions, port: server.address().port, reads: () => instanceReads, setRoute: v => { route = v; } };
}
const shot = async (b, name, selector) => {
if (!process.env.MOSAIC_UX_SHOTS) return;
await mkdir(process.env.MOSAIC_UX_SHOTS, { recursive: true });
await b.evaluate(`document.querySelector(${JSON.stringify(selector)})?.scrollIntoView({block:'start'});true`);
await b.screenshot(join(process.env.MOSAIC_UX_SHOTS, `${name}.png`));
};
test('PD apply hires the proposals, the pack card counts them, Hire the N proposed agents hires the rest, Agents shows and fires them without a poll', { timeout: 90000 }, async t => {
const f = await fixture(t);
const { b, verbs } = f;
await b.navigate(`http://127.0.0.1:${f.port}/#/orgs/acme/agents`);
await waitFor(b, `document.querySelector('[data-org-agents] [data-agent-card="chief"]')`, 'agents before');
assert.equal(await b.evaluate(`document.querySelectorAll('[data-org-agents] [data-agent-card]').length`), 2);
// Fire is offered on neither the chief nor the assistant: the bus would refuse both.
assert.equal(await b.evaluate(`document.querySelectorAll('[data-org-agents] [data-agent-fire]').length`), 0);
assert.equal(await b.evaluate(`document.querySelector('[data-agent-card="chief"] [data-agent-fire]')`), null, 'the chief has no Fire');
await shot(b, 'agents-before-1440', '[data-org-agents]');
await b.navigate(`http://127.0.0.1:${f.port}/#/orgs/acme/organization`);
await waitFor(b, `document.querySelector('[data-pack="software-team"] [data-pack-preview-button]')`, 'pack list');
assert.equal(await b.evaluate(`document.querySelector('[data-pack="software-team"] [data-pack-hired]')`), null, 'nothing applied yet');
await b.evaluate(`document.querySelector('[data-pack="software-team"] [data-pack-preview-button]').click();true`);
await waitFor(b, `document.querySelector('[data-pack-preview]')`, 'preview');
// The preview says the apply is the hire decision and that the owner's apply approves the flagged and credential lines.
assert.match(await b.evaluate(`document.querySelector('[data-pack-hire="coder-1"]').textContent`), /Hired when you apply: applying is the hire decision, and approves the flagged and credential lines below\. A credential line is approved, not bound/);
// A hire with no credential line says nothing about binding one.
assert.match(await b.evaluate(`document.querySelector('[data-pack-hire="reviewer-1"] p').textContent`), /^Hired when you apply: applying is the hire decision\.$/);
assert.doesNotMatch(await b.evaluate(`document.querySelector('[data-pack-hire="reviewer-1"]').textContent`), /flagged and credential lines/);
await shot(b, 'pack-card-before-1440', '[data-pack="software-team"]');
await b.evaluate(`document.querySelector('[data-pack-apply]').click();true`);
await waitFor(b, `/^Applied\\./.test(document.querySelector('[data-packs-status]')?.textContent??'')`, 'apply status');
assert.equal(await b.evaluate(`document.querySelector('[data-packs-status]').textContent`), 'Applied. Added board. Hired coder-1, reviewer-1. Not hired: scout-1, neither the chief of staff nor the assistant has a harness and model to give it; set them under Settings, Routing.');
await waitFor(b, `document.querySelector('[data-pack="software-team"] [data-pack-hired]')`, 'hired count');
assert.equal(await b.evaluate(`document.querySelector('[data-pack-hired]').textContent`), '2 agents hired: coder-1, reviewer-1');
assert.deepEqual(await b.evaluate(`[...document.querySelectorAll('[data-pack-open-hire]')].map(l=>[l.dataset.packOpenHire,l.dataset.packOpenState,l.textContent])`),
[['scout-1', 'refused', 'scout-1: Proposed by starter pack software-team for scout work. not hired: neither the chief of staff nor the assistant has a harness and model to give it; set them under Settings, Routing']]);
assert.equal(await b.evaluate(`document.querySelector('[data-pack-hire-button]').textContent`), 'Hire the 1 proposed agent');
await shot(b, 'pack-card-after-1440', '[data-pack="software-team"]');
// The route is set; the card's action hires the remaining proposal, the same loop, and nothing twice.
f.setRoute(true);
await b.evaluate(`document.querySelector('[data-pack-hire-button]').click();true`);
await waitFor(b, `document.querySelector('[data-packs-status]').textContent.startsWith('Hired scout-1.')`, 'hire status');
assert.equal(await b.evaluate(`document.querySelector('[data-packs-status]').textContent`), 'Hired scout-1. Already hired: coder-1, reviewer-1.');
assert.deepEqual(verbs.filter(([v]) => v === 'pack.hire.console').map(([, a]) => a), [{ pack: 'software-team' }]);
await waitFor(b, `document.querySelector('[data-pack-hired]').textContent.startsWith('3 agents hired')`, 'hired count 3');
assert.equal(await b.evaluate(`document.querySelector('[data-pack-hire-button]')`), null, 'nothing left to hire');
assert.equal(f.decisions.length, 3);
// The Agents page: three new cards, each naming its pack.
await b.navigate(`http://127.0.0.1:${f.port}/#/orgs/acme/agents`);
await waitFor(b, `document.querySelectorAll('[data-org-agents] [data-agent-card]').length===5`, 'agents after');
assert.deepEqual(await b.evaluate(`[...document.querySelectorAll('[data-org-agents] [data-agent-card] [data-agent-pack]')].map(s=>[s.closest('[data-agent-card]').dataset.agentCard,s.textContent])`),
[['coder-1', 'from pack software-team'], ['reviewer-1', 'from pack software-team'], ['scout-1', 'from pack software-team']]);
assert.match(await b.evaluate(`document.querySelector('[data-agents-available] tr[data-agent="coder-1"]')?.textContent ?? ''`), /from pack software-team/);
assert.equal(await b.evaluate(`document.documentElement.scrollWidth<=innerWidth`), true);
await shot(b, 'agents-after-1440', '[data-org-agents]');
// Fire asks first; Cancel fires nothing.
const card = `[data-org-agents] [data-agent-card="reviewer-1"]`;
await b.evaluate(`document.querySelector('${card} [data-agent-fire]').click();true`);
await waitFor(b, `document.querySelector('${card} [data-agent-fire-no]')`, 'fire confirm');
await b.evaluate(`document.querySelector('${card} [data-agent-fire-no]').click();true`);
await waitFor(b, `document.querySelector('${card} [data-agent-fire]')`, 'kept');
assert.equal(verbs.filter(([v]) => v === 'hire.start.console').length, 0);
// Confirm: the card goes at once, the receipt names the decision, and the page reads the bus again.
await b.evaluate(`document.querySelector('${card} [data-agent-fire]').click();true`);
await waitFor(b, `document.querySelector('${card} [data-agent-fire-yes]')`, 'fire confirm again');
const before = f.reads();
await b.evaluate(`document.querySelector('${card} [data-agent-fire-yes]').click();true`);
await waitFor(b, `document.querySelector('[data-agents-receipt]')?.textContent.includes('Fired reviewer-1')`, 'fire receipt');
assert.equal(await b.evaluate(`document.querySelector('${card}')`), null);
assert.equal(await b.evaluate(`document.querySelector('[data-agents-available] tr[data-agent="reviewer-1"]')`), null);
assert.equal(await b.evaluate(`document.querySelector('[data-agents-receipt]').textContent`), 'Fired reviewer-1. Recorded decision fire-1.');
assert.deepEqual(verbs.filter(([v]) => v === 'hire.start.console').map(([, a]) => a), [{ op: 'fire', level: 'org', name: 'reviewer-1', reason: 'Fired from the Agents page.' }]);
// The redraw the fire asked for reads the instances again and keeps the receipt.
for (let i = 0; i < 100 && f.reads() <= before; i++) await new Promise(r => setTimeout(r, 30));
assert.ok(f.reads() > before, 'the fire redraws the page from a fresh read');
await waitFor(b, `document.querySelector('[data-agents-receipt]')?.textContent.includes('Fired reviewer-1')&&document.querySelectorAll('[data-org-agents] [data-agent-card]').length===4`, 'redrawn');
assert.equal(await b.evaluate(`document.querySelectorAll('[data-org-agents] [data-agent-card]').length`), 4);
assert.equal(await b.evaluate(`document.querySelector('[data-agents-receipt]').textContent`), 'Fired reviewer-1. Recorded decision fire-1.');
});
test('PD an apply whose hire loop did not run says so; the owner card counts a pack-raised inbox line as hireable and leaves the inbox its own', { timeout: 90000 }, async t => {
const f = await fixture(t, { busy: true });
const { b } = f;
await b.navigate(`http://127.0.0.1:${f.port}/#/orgs/acme/organization`);
await waitFor(b, `document.querySelector('[data-pack="software-team"] [data-pack-preview-button]')`, 'pack list');
await b.evaluate(`document.querySelector('[data-pack="software-team"] [data-pack-preview-button]').click();true`);
await waitFor(b, `document.querySelector('[data-pack-apply]')`, 'preview');
await b.evaluate(`document.querySelector('[data-pack-apply]').click();true`);
await waitFor(b, `/^Applied\\./.test(document.querySelector('[data-packs-status]')?.textContent??'')`, 'apply status');
assert.equal(await b.evaluate(`document.querySelector('[data-packs-status]').textContent`), 'Applied. Added board. The hires did not run: another pack hire on this organization was running. Use Hire on the pack card to run them.');
await waitFor(b, `document.querySelector('[data-pack="software-team"] [data-pack-open]')`, 'open lines');
assert.deepEqual(await b.evaluate(`[...document.querySelectorAll('[data-pack-open-hire]')].map(l=>[l.dataset.packOpenHire,l.dataset.packOpenState])`),
[['coder-1', 'awaiting-owner'], ['reviewer-1', 'awaiting-owner'], ['scout-1', 'refused']]);
// coder-1 was left in the inbox by a pack hire, so the owner's pack hire approves it; reviewer-1 is the inbox's.
assert.equal(await b.evaluate(`document.querySelector('[data-pack-hire-button]').textContent`), 'Hire the 2 proposed agents');
});
+79 -5
View File
@@ -25,12 +25,14 @@ import { close } from './fixture.mjs';
const FLOW = (title) => ({ description: `${title}.`, steps: [{ title, state: 'todo', roles: ['human'] }] });
const BOARD = { columns: [{ state: 'todo', label: 'Backlog' }, { state: 'in-progress', label: 'Building' }, { state: 'in-review', label: 'Review' }, { state: 'blocked', label: 'Blocked' }, { state: 'done', label: 'Merged' }], layout: 'kanban' };
async function fixture(t, { send = true, workflows = true } = {}) {
async function fixture(t, { send = true, workflows = true, route = false, tap = null } = {}) {
const undo = []; t.after(async () => { for (const step of undo.reverse()) await step(); });
const root = mkdtempSync(join(tmpdir(), 'pk-'));
const rolesDir = rolesCopy(root), system = systemFor(root);
const doc = businessDoc(root, 'demo');
doc.plugins = { ...(doc.plugins ?? {}), harness: ['pi'] };
// Ruling 2026-11-14: a hire runs as the chief's harness and model, else the assistant's (lane pr2); this fixture has no assistant, so without a chief route it refuses pack-hire-route-missing.
if (route) doc.roles.chief.vars = { harness: 'pi', model: 'claude-opus-5-5' };
const file = writeJson(join(root, 'businesses/demo.json'), doc);
mkdirSync(join(root, 'data'), { mode: 0o700 });
const business = loadBusiness('demo', { dir: root, rolesDir });
@@ -55,7 +57,9 @@ async function fixture(t, { send = true, workflows = true } = {}) {
const upstream = `http://127.0.0.1:${board.address().port}`;
const consoleCall = kind => async (verb, args, ctx) => { calls.push([kind, verb, Object.keys(args).sort()]); return new Client({ path, console: { subject: ctx.subject } }).call(verb, args); };
const requestRead = consoleCall('read');
const packsWrite = packsCaller(consoleCall('write'));
// tap: a test's view of each write's bus result before the route shapes it.
const writer = packsCaller(consoleCall('write'));
const packsWrite = tap ? async (verb, args, ctx) => tap(verb, await writer(verb, args, ctx)) : writer;
const session = { provider: 'fixture', iss: 'https://issuer.example', sub: 'owner', name: 'jason', rootOwner: true };
let active = true;
const sso = { publicOrigin: '', publicHost: '', formTargets: () => [], session: () => active ? session : null, handle: () => {}, health: () => ({}) };
@@ -69,7 +73,7 @@ async function fixture(t, { send = true, workflows = true } = {}) {
sso.publicOrigin = base; sso.publicHost = new URL(base).host;
undo.push(() => close(web));
const onDisk = () => JSON.parse(readFileSync(file, 'utf8'));
return { base, file, root, calls, onDisk, flowReads, boardHits, failFlows: () => { flowFails = true; }, signOut: () => { active = false; }, revokeOwner: () => { session.rootOwner = false; session.user = 'legacy-user'; } };
return { base, file, root, calls, onDisk, broker: runtime.broker, flowReads, boardHits, failFlows: () => { flowFails = true; }, signOut: () => { active = false; }, revokeOwner: () => { session.rootOwner = false; session.user = 'legacy-user'; } };
}
function call(f, path, method = 'GET', body, headers = {}) {
@@ -166,8 +170,8 @@ test('SP4 the admission checks refuse before any bus call; a read-only Console s
assert.deepEqual((await call(f, `${ORG}/packs/preview`, 'POST', { pack: 'no-such-pack' })).map((x, i) => i ? x.error : x), [404, 'starter-pack-not-found']);
const [badRole, badRoleBody] = await call(f, `${ORG}/workflows/three`, 'POST', { revision: revisionNow, workflow: { description: 'x.', steps: [{ title: 'x', state: 'todo', roles: ['ghost'] }] } });
assert.deepEqual([badRole, badRoleBody.error], [400, 'invalid-request']);
// The fenced writer calls nothing but the three write verbs.
assert.deepEqual(PACK_WRITE_VERBS, ['pack.apply.console', 'board.set.console', 'workflow.set.console']);
// The fenced writer calls nothing but the four write verbs.
assert.deepEqual(PACK_WRITE_VERBS, ['pack.apply.console', 'pack.hire.console', 'board.set.console', 'workflow.set.console']);
await assert.rejects(packsCaller(async () => ({}))('packs.console', {}, {}), /does not call packs\.console/);
f.signOut();
assert.deepEqual((await call(f, `${ORG}/packs`))[0], 401);
@@ -372,3 +376,73 @@ test('SP6 the pack picker and workflow files are the owner\'s: the tester listen
assert.ok((await res.text()).length > 100, path);
}
});
// Ruling 2026-11-14: "If I choose a starter pack and apply it, that is a decision!" The owner's apply
// hires the proposals in the same act; the packs read names the agents hired and the open proposals;
// the hire route runs the same loop on the recorded entry and hires nothing twice.
test('PD the owner apply hires the proposals; the packs read lists them; the hire route hires nothing twice', async t => {
const f = await fixture(t, { route: true });
const [, preview] = await call(f, `${ORG}/packs/preview`, 'POST', { pack: 'coding' });
const [applied, receipt] = await call(f, `${ORG}/packs/apply`, 'POST', { pack: 'coding', digest: preview.digest });
assert.equal(applied, 200, JSON.stringify(receipt));
assert.deepEqual(receipt.hires.map(h => [h.name, h.state, h.routeFrom]), [['pm-1', 'hired', 'chief'], ['coder-1', 'hired', 'chief'], ['reviewer-1', 'hired', 'chief']]);
for (const h of receipt.hires) assert.match(h.decision, /^[0-9a-f-]{36}$/);
const disk = f.onDisk();
for (const h of receipt.hires) { assert.equal(disk.roles[h.name].hireDecision, h.decision); assert.ok(disk.launch.instances.includes(h.name)); }
const [, list] = await call(f, `${ORG}/packs`);
assert.deepEqual(list.applied.map(a => [a.id, a.origin, a.project, a.hired.sort(), a.open]), [['coding', 'shipped', null, ['coder-1', 'pm-1', 'reviewer-1'], []]]);
const [hired, again] = await call(f, `${ORG}/packs/hire`, 'POST', { pack: 'coding' });
assert.equal(hired, 200, JSON.stringify(again));
assert.deepEqual(again, { pack: 'coding', hires: [{ name: 'pm-1', state: 'exists' }, { name: 'coder-1', state: 'exists' }, { name: 'reviewer-1', state: 'exists' }] });
assert.deepEqual(f.calls.at(-1), ['write', 'pack.hire.console', ['pack']]);
const asked = f.calls.length;
assert.deepEqual((await call(f, `${ORG}/packs/hire`, 'POST', { pack: 'coding', as: [] })).map((x, i) => i ? x.error : x), [400, 'invalid-request']);
assert.deepEqual((await call(f, `${ORG}/packs/hire`, 'GET')).map((x, i) => i ? x.error : x), [405, 'method-not-allowed']);
assert.equal(f.calls.length, asked);
assert.deepEqual((await call(f, `${ORG}/packs/hire`, 'POST', { pack: 'research' })).map((x, i) => i ? x.error : x), [404, 'pack-not-applied']);
});
test('PD with no chief route the apply records the pack and each hire says pack-hire-route-missing', async t => {
const f = await fixture(t);
const [, preview] = await call(f, `${ORG}/packs/preview`, 'POST', { pack: 'coding' });
const [applied, receipt] = await call(f, `${ORG}/packs/apply`, 'POST', { pack: 'coding', digest: preview.digest });
assert.equal(applied, 200, JSON.stringify(receipt));
assert.deepEqual(receipt.hires.map(h => [h.name, h.state, h.code]), [['pm-1', 'refused', 'pack-hire-route-missing'], ['coder-1', 'refused', 'pack-hire-route-missing'], ['reviewer-1', 'refused', 'pack-hire-route-missing']]);
const [, list] = await call(f, `${ORG}/packs`);
assert.deepEqual(list.applied[0].hired, []);
assert.deepEqual(list.applied[0].open.map(o => [o.name, o.state, o.code, typeof o.reason]), [['pm-1', 'refused', 'pack-hire-route-missing', 'string'], ['coder-1', 'refused', 'pack-hire-route-missing', 'string'], ['reviewer-1', 'refused', 'pack-hire-route-missing', 'string']]);
});
test('PD a member-raised awaiting-owner line reads fromPack; the owner hire route approves it; an apply whose hire loop did not run says hireError', async t => {
const f = await fixture(t);
const [, preview] = await call(f, `${ORG}/packs/preview`, 'POST', { pack: 'coding' });
await call(f, `${ORG}/packs/apply`, 'POST', { pack: 'coding', digest: preview.digest });
const doc = f.onDisk();
doc.roles.chief.vars = { harness: 'pi', model: 'claude-opus-5-5' };
writeFileSync(f.file, JSON.stringify(doc, null, 2) + '\n');
// A member with org.hire runs the pack hire on the bus (no WebUI route serves a member yet).
const member = f.broker.bindHireConsole({ business: 'demo', actor: 'alice', subject: { provider: 'test', iss: 'test', sub: 'alice' }, rights: ['org.hire', 'board.read'] });
const out = await f.broker.request(member, { verb: 'pack.hire.console', args: { pack: 'coding' } });
const waiting = out.hires.filter(x => x.state === 'awaiting-owner').map(x => x.name);
assert.ok(waiting.length > 0, JSON.stringify(out));
const [, list] = await call(f, `${ORG}/packs`);
const open = list.applied[0].open.filter(o => o.state === 'awaiting-owner');
assert.deepEqual(open.map(o => [o.name, o.fromPack]), waiting.map(n => [n, true]));
const [status, hired] = await call(f, `${ORG}/packs/hire`, 'POST', { pack: 'coding' });
assert.equal(status, 200, JSON.stringify(hired));
assert.deepEqual(hired.hires.filter(x => waiting.includes(x.name)).map(x => [x.name, x.state]), waiting.map(n => [n, 'hired']));
assert.deepEqual((await call(f, `${ORG}/packs`))[1].applied[0].open, []);
// The bus answers an apply whose hire loop did not run with hireError; the route passes a code through
// and drops anything that is not one.
for (const [given, shown] of [['organization-busy', 'organization-busy'], ['Not A Code!', undefined], [7, undefined]]) {
const g = await fixture(t, { tap: (verb, result) => verb === 'pack.apply.console' ? { ...result, hires: [], hireError: given } : result });
const [, p] = await call(g, `${ORG}/packs/preview`, 'POST', { pack: 'coding' });
const [code, receipt] = await call(g, `${ORG}/packs/apply`, 'POST', { pack: 'coding', digest: p.digest });
assert.equal(code, 200, JSON.stringify(receipt));
assert.equal(receipt.hireError, shown);
assert.deepEqual(receipt.hires, []);
}
});
@@ -56,7 +56,7 @@ async function fixture(t, { send = true } = {}) {
};
const write = async (verb, args) => {
verbs.push([verb, args]);
if (verb === 'pack.apply.console') return { revision: REV('f'), added: ['board', 'definitions.reviewer'], pack: { id: args.pack, version: '1.0.0' } };
if (verb === 'pack.apply.console') return { revision: REV('f'), added: ['board', 'definitions.reviewer'], pack: { id: args.pack, version: '1.0.0' }, hires: [{ name: 'lead-reviewer', state: 'refused', code: 'definition-not-hireable' }] };
if (verb === 'workflow.set.console') {
assert.equal(args.revision, flows.revision);
const doc = structuredClone(flows.document);
@@ -118,7 +118,7 @@ test('SP6 pack picker: list, preview with marks, flagged, credential and noHire
assert.equal(verbs.filter(([v]) => v.startsWith('pack.apply')).length, 0, 'preview writes nothing');
await b.evaluate(`document.querySelector('[data-pack-apply]').click();true`);
await waitFor(b, `/^Applied\\./.test(document.querySelector('[data-packs-status]').textContent)`, 'apply status');
assert.equal(await b.evaluate(`document.querySelector('[data-packs-status]').textContent`), 'Applied. Added board, definitions.reviewer. Proposed hires are recorded for the chief to raise as decisions.');
assert.equal(await b.evaluate(`document.querySelector('[data-packs-status]').textContent`), 'Applied. Added board, definitions.reviewer. Not hired: lead-reviewer, its role is on the no-hire list.');
assert.deepEqual(verbs.filter(([v]) => v === 'pack.apply.console').map(([, a]) => a), [{ pack: 'software-team', digest: REV('d') }]);
assert.equal(await b.evaluate(`document.querySelector('[data-pack-preview]')`), null);
// A project pack previews against the chosen project.
+2 -1
View File
@@ -67,7 +67,8 @@ test('shipped strings are unchanged, and the web keeps its write boundary', () =
const writes = read('bus.js').toString().match(/fetch\([^;]*method:\s*['"](POST|PUT|PATCH|DELETE)[^;]*;/g) ?? [];
assert.deepEqual(writes, ["fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' });", "fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body), cache: 'no-store', redirect: 'manual' });", "fetch('/api/settings',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});", "fetch('/api/settings',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({revision,reset:[JSON.parse(path)]}),cache:'no-store',redirect:'manual'});", "fetch(url,{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});", "fetch('/api/instances',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});", "fetch('/api/proxy/plugins/set',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({revision:harnessSettings.revision,plugins:{harness:selected}}),cache:'no-store',redirect:'manual'});", "fetch('/api/proxy/plugins/set',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({revision:settings.revision,plugins:{'model-provider':next}}),cache:'no-store',redirect:'manual'});", "fetch('/api/routes',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});", "fetch('/api/project-file', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(sent), cache: 'no-store', redirect: 'manual' });", "fetch('/api/bus/message', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ to, body: text }), cache: 'no-store', redirect: 'manual' });"]);
assert.equal(read('bus.js').toString().split(/method:/).length - 1, 11);
assert.deepEqual([...read('bus.js').toString().matchAll(/decidePost\(([^,]*),/g)].map(m => m[1]), ["`/api/decisions/hire/${templatesOnly?'templates':'start'}`", 'url', '`/api/decisions/${enc(a.id)}/confirmation`', '`/api/decisions/${enc(a.id)}/resolve`']);
// Ruling 2026-11-14 (#1873): Fire on an Agents card is a second hire.start caller, {op:'fire'}.
assert.deepEqual([...read('bus.js').toString().matchAll(/decidePost\(([^,]*),/g)].map(m => m[1]), ["`/api/decisions/hire/${templatesOnly?'templates':'start'}`", "'/api/decisions/hire/start'", 'url', '`/api/decisions/${enc(a.id)}/confirmation`', '`/api/decisions/${enc(a.id)}/resolve`']);
// Row 155 (#1649): the one fetch(url, ...) posts only to these three routes, each a literal.
// Row 176 (#1674): the tester name form adds a second /api/name caller.
assert.deepEqual([...read('bus.js').toString().matchAll(/assistantPost\(([^,]*),/g)].map(m => m[1]), ['url', "'/api/settings'", "'/api/name'", "'/api/assistant-stop'", "'/api/name'"]);