fix(bus,cli,webui): a pack hire falls back to the org assistant's harness and model (#1873, lane pr2)

pack.state gave a pack hire only the chief of staff's resolved route, so an
org whose chief has no model refused every hire pack-hire-route-missing. The
route is now the chief's (launch.by), else the org assistant's (the instance
whose definition is assistant; the one named assistant when there are several,
else the first in key order), else null. Both resolve through resolveInstance,
need harness and model, and carry thinking when set. pack.state adds routeFrom,
and each hired or awaiting-owner receipt line carries it. Refusal next step,
CLI hint, WebUI hire reason and the bus, CLI and TOOLS docs say the refusal now
means neither has a route.
This commit is contained in:
jason.woltje committed 2026-11-14 18:32:01 -06:00
1 parent 3e53af3941
commit bfd2cc5e79
11 files changed
+89 -26

No files matched your search

+1 -1
View File
@@ -346,7 +346,7 @@ 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`. 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`). 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 proposal refuses `pack-hire-route-missing` when the chief of staff has no harness and model. 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. 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`). 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. 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.
+6 -2
View File
@@ -1150,8 +1150,12 @@ Each proposal goes through `#raiseHire` and `hire.resolve.console`, the path an
inbox approval takes: the decision row, its resolved event, `human.input`, inbox approval takes: the decision row, its resolved event, `human.input`,
`hire.consume`, the hire's commit and `hire.apply` event, plus `hire.consume`, the hire's commit and `hire.apply` event, plus
`action.allowed {operation: 'pack.hire', pack, version, origin, project, hire, `action.allowed {operation: 'pack.hire', pack, version, origin, project, hire,
decision}`. The route is the chief of staff's resolved harness and model; with decision}`. The route is the chief of staff's resolved harness and model (the
none the proposal refuses `pack-hire-route-missing`. The entry is written with `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`. 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 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 `open`, so nothing is hired twice. The owner approves every line, and replaces
+2 -2
View File
@@ -6914,12 +6914,12 @@ export class Broker {
supersedes, this.#now(), 'withdrawn', caller.actor, null, null, caller.via ?? 'console').lastInsertRowid); 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); 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, ...(supersedes ? { supersedes } : {}) }); out.push({ name, state: 'hired', decision: r.id, routeFrom: state.routeFrom, ...(supersedes ? { supersedes } : {}) });
} }
for (const input of deferred) { for (const input of deferred) {
try { try {
const r = await this.#raiseHire(s, input, false, { caller: callerOf, pack: provenance, record: true }); 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 }); out.push({ name: input.name, state: 'awaiting-owner', decision: r.id, routeFrom: state.routeFrom });
} catch (e) { refused(input.name, e); } } catch (e) { refused(input.name, e); }
} }
return { hires: out }; return { hires: out };
+3 -2
View File
@@ -219,7 +219,8 @@ group(
"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." "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 // 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. // entry an apply recorded, and each hire runs as the chief's harness and model, else the org
// assistant's (lane pr2).
group( group(
"pack-not-applied", "pack-not-applied",
"R8: a pack hire works from the packs entry an apply recorded", "R8: a pack hire works from the packs entry an apply recorded",
@@ -228,7 +229,7 @@ group(
group( group(
"pack-hire-route-missing", "pack-hire-route-missing",
"R3/R8: a hire runs as a harness and model the organization names", "R3/R8: a hire runs as a harness and model the organization names",
"Give the chief of staff a harness and a model in its vars, or set them at the organization or system level, then use the pack's hire action again." "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 // #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. // key against the credential store; a hit names the field, never the text.
+23 -11
View File
@@ -504,9 +504,10 @@ function packOps({ writer, rolesDir, dataRoot, system, registryFor, assertClean,
// Ruling 2026-11-14 ("If I choose a starter pack and apply it, that is a decision!"): the // 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. // 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 // `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 the // template, any other name the latest shipped or installed entry of that id. The route is a
// chief's resolved harness, model and thinking (system, organization and agent vars), or null // resolved harness, model and thinking (system, organization and agent vars): the chief's (the
// when the chief resolves no harness or no model. // 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 latestEntries = doc => {
const latest = new Map(); 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); 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);
@@ -521,14 +522,25 @@ function packOps({ writer, rolesDir, dataRoot, system, registryFor, assertClean,
.map(([project, p]) => [project, Object.fromEntries(Object.entries(p.agents ?? {}).map(([name, e]) => [name, one(e, true)]))])), .map(([project, p]) => [project, Object.fromEntries(Object.entries(p.agents ?? {}).map(([name, e]) => [name, one(e, true)]))])),
}; };
}; };
const chiefRoute = (doc, id) => { // 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 by = doc.launch?.by;
if (typeof by !== 'string' || !Object.hasOwn(doc.roles ?? {}, by)) return null; const chief = typeof by === 'string' && Object.hasOwn(roles, by) ? routeOf(by) : null;
const loaded = validateBusinessDocument(doc, `${id}.json`, { rolesDir, dataRoot }); if (chief) return { route: chief, routeFrom: 'chief' };
const { vars } = resolveInstance({ system, business: loaded, instance: by }); const assistants = Object.keys(roles).filter(name => isObject(roles[name]) && roles[name].definition === 'assistant');
const pick = k => (typeof vars?.[k] === 'string' && vars[k] ? vars[k] : null); const assistant = assistants.includes('assistant') ? 'assistant' : assistants[0];
if (!pick('harness') || !pick('model')) return null; const route = assistant === undefined ? null : routeOf(assistant);
return { harness: pick('harness'), model: pick('model'), ...(pick('thinking') ? { thinking: pick('thinking') } : {}) }; 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, 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 })) })); proposedHires: (Array.isArray(e.proposedHires) ? e.proposedHires : []).filter(isObject).map(h => ({ name: h.name, reason: h.reason ?? null })) }));
@@ -548,7 +560,7 @@ function packOps({ writer, rolesDir, dataRoot, system, registryFor, assertClean,
const packId = saved ? args.pack.slice(SAVED_PREFIX.length) : args.pack; 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 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 ?? {}; const scope = project === null ? doc.roles ?? {} : doc.projects?.[project]?.agents ?? {};
return { revision, entry: entry ? structuredClone(entry) : null, route: chiefRoute(doc, id), names: Object.keys(scope), noHire: [...(doc.launch?.noHire ?? [])] }; 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.preview': (id, args, actor, context) => build(id, args, actor, context),
'pack.apply': async (id, args, actor, context) => { 'pack.apply': async (id, args, actor, context) => {
+46 -1
View File
@@ -15,8 +15,11 @@ import { templateHash } from '../../organization/src/definitions.mjs';
const SUBJECT = { provider: 'test', iss: 'test', sub: 'owner' }; const SUBJECT = { provider: 'test', iss: 'test', sub: 'owner' };
const ROUTE = { harness: 'pi', model: 'claude-opus-5-5' }; const ROUTE = { harness: 'pi', model: 'claude-opus-5-5' };
const ASSISTANT_ROUTE = { harness: 'pi', model: 'glm-5.3-flash', thinking: 'low' };
async function harness(t, { route = true, mutate = () => {} } = {}) { // 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 { startBroker } = await import('../src/runtime.mjs');
const { businessDoc, rolesCopy, systemFor, writeJson } = await import('../../organization/tests/helpers.mjs'); const { businessDoc, rolesCopy, systemFor, writeJson } = await import('../../organization/tests/helpers.mjs');
const root = mkdtempSync(join(tmpdir(), 'packhire-')); const root = mkdtempSync(join(tmpdir(), 'packhire-'));
@@ -24,6 +27,7 @@ async function harness(t, { route = true, mutate = () => {} } = {}) {
const doc = businessDoc(root, 'demo'); const doc = businessDoc(root, 'demo');
doc.plugins = { harness: ['pi'] }; doc.plugins = { harness: ['pi'] };
if (route) doc.roles.chief.vars = { ...ROUTE }; if (route) doc.roles.chief.vars = { ...ROUTE };
if (assistant) doc.roles.assistant = { definition: 'assistant', ...(Object.keys(assistant).length ? { vars: { ...assistant } } : {}) };
mutate(doc); mutate(doc);
writeJson(join(root, 'businesses/demo.json'), doc); writeJson(join(root, 'businesses/demo.json'), doc);
const data = join(root, 'data'); const data = join(root, 'data');
@@ -296,3 +300,44 @@ test('PD a second pack hire on the same organization while one runs refuses orga
const later = await h.call('pack.hire.console', { pack: 'coding' }); const later = await h.call('pack.hire.console', { pack: 'coding' });
assert.ok(later.hires.every(x => x.state === 'exists'), JSON.stringify(later)); 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);
});
+3 -2
View File
@@ -1376,8 +1376,9 @@ 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, 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` 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 with the hint to preview and apply it first. A proposal is refused
`pack-hire-route-missing` when the chief of staff has no harness and model to `pack-hire-route-missing` when neither the chief of staff nor the
give it. Like the other pack words, both refuse inside an agent run (exit 3). 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 `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 arguments pass the board's own check, then go to the bus as the person
+1 -1
View File
@@ -68,7 +68,7 @@ const PACK_REFUSALS = {
'not-configured': 'this organization\'s bus has no organization writer configured', '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. // 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-not-applied': 'that pack has no packs entry in this organization or project; preview and apply it first',
'pack-hire-route-missing': 'the chief of staff has no harness and model to give a hire; set them in its vars, then run pack hire', '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', 'no-chief-of-staff': 'the organization has no chief of staff to raise its hires',
'right-required': 'hiring needs the org.hire right', 'right-required': 'hiring needs the org.hire right',
}; };
+1 -1
View File
@@ -34,7 +34,7 @@
}; };
// Why a hire did not happen, by the code the bus recorded. An unknown code is shown as itself. // Why a hire did not happen, by the code the bus recorded. An unknown code is shown as itself.
const HIRE_WHY = { const HIRE_WHY = {
'pack-hire-route-missing': 'the chief of staff has no harness and model to give it; set them under Settings, Routing', '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', 'no-chief-of-staff': 'the organization has no chief of staff',
'hire-owner-required': 'it has flagged or credential lines only the owner approves', 'hire-owner-required': 'it has flagged or credential lines only the owner approves',
'definition-not-hireable': 'its role is on the no-hire list', 'definition-not-hireable': 'its role is on the no-hire list',
@@ -116,11 +116,11 @@ test('PD apply hires the proposals, the pack card counts them, Hire the N propos
await b.evaluate(`document.querySelector('[data-pack-apply]').click();true`); await b.evaluate(`document.querySelector('[data-pack-apply]').click();true`);
await waitFor(b, `/^Applied\\./.test(document.querySelector('[data-packs-status]')?.textContent??'')`, 'apply status'); 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, the chief of staff has no harness and model to give it; set them under Settings, Routing.'); 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'); 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.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])`), 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: the chief of staff has no harness and model to give it; set them under Settings, Routing']]); [['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'); 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"]'); await shot(b, 'pack-card-after-1440', '[data-pack="software-team"]');
+1 -1
View File
@@ -31,7 +31,7 @@ async function fixture(t, { send = true, workflows = true, route = false, tap =
const rolesDir = rolesCopy(root), system = systemFor(root); const rolesDir = rolesCopy(root), system = systemFor(root);
const doc = businessDoc(root, 'demo'); const doc = businessDoc(root, 'demo');
doc.plugins = { ...(doc.plugins ?? {}), harness: ['pi'] }; doc.plugins = { ...(doc.plugins ?? {}), harness: ['pi'] };
// Ruling 2026-11-14: a hire runs as the chief's harness and model; without them it refuses pack-hire-route-missing. // 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' }; if (route) doc.roles.chief.vars = { harness: 'pi', model: 'claude-opus-5-5' };
const file = writeJson(join(root, 'businesses/demo.json'), doc); const file = writeJson(join(root, 'businesses/demo.json'), doc);
mkdirSync(join(root, 'data'), { mode: 0o700 }); mkdirSync(join(root, 'data'), { mode: 0o700 });