Files
stack/packages/cli/src/org-owner.mjs
T
jason.woltje 666f4d6864 fix(cli): build review fixes for the parity verbs (#1873, P1-3)
- autonomy set: an empty --authority-resolver clears the resolver as the
  panel does; --reverse-window-hours takes 1 to 168, as the page and bus do.
- The default instance gets a one-line refusal (exit 3) naming the named
  instance and --dev-local-owner, not a stack trace.
- tasks create|edit|archive: a value starting with -- is a usage error;
  --business is checked before the agent guard, and the guard also reads
  process.env.
- Drop the unused consoleRefusal export.
- Docs: --business is required, resolver clearing, window range, and the
  named-instance requirement.
2026-11-11 20:47:35 -06:00

214 lines
15 KiB
JavaScript

// E2 (#1873, P1-3, org ruling 6): the owner's org page actions from a terminal. Each verb sends the
// request the WebUI page sends, to the running Console's own org route (org-console.mjs
// consoleOrgRequest), so the same route checks the owner, --send, the origin, the body and the live
// session, and the same bus verb runs on the organization's own bus:
// - role-templates, hire, fire, change: the inbox hire form (public/bus.js hireSubmit), the routes
// /api/orgs/ID/decisions/hire/templates and /start (decision-http.mjs), the bus verbs
// hire.templates.console and hire.start.console.
// - autonomy, autonomy set, autonomy review: the org Autonomy panel (public/autonomy.js), the
// route /api/orgs/ID/autonomy[/review] (autonomy-http.mjs), the bus verbs autonomy.get.console,
// autonomy.set.console (with its revision check) and autonomy.review.console.
// No new authority: a Console that is not the owner's, not started with --send, or not signed in
// refuses exactly as it refuses the page. Usage errors (exit 4) come first; then every verb refuses
// inside an agent run, before any config is read, as `org archive|delete` do (agent guard #1942).
import { configDir } from '../../organization/src/organization-config.mjs';
import { DEV_OWNER, devOwnerInstance } from './dev-local-owner.mjs';
import { loadSystem } from './config.mjs';
import { CliError } from './errors.mjs';
import { consoleOrgRequest } from './org-console.mjs';
import { refuseInsideAgent } from './transport.mjs';
export const ORG_OWNER_VERBS = Object.freeze(['role-templates', 'hire', 'fire', 'change', 'autonomy']);
export const ORG_OWNER_USAGE = 'mosaic org role-templates ID [--json] | hire|change ID --name NAME --template TEMPLATE --harness HARNESS --model MODEL [--thinking LEVEL] [--level org|project|root] [--project PROJECT] --reason TEXT [--json] | fire ID --name NAME [--level org|project|root] [--project PROJECT] --reason TEXT [--json] | autonomy ID [--json] | autonomy ID set --revision REV [--cross-role|--merge|--policy|--authority in|near|out] [--notices on|off] [--reverse-window-hours N] [--authority-resolver NAME|""] [--json] | autonomy ID review cross-role|merge|policy|authority [--json]';
const usage = why => new CliError(`${why}\nusage: ${ORG_OWNER_USAGE}`, 4);
const ORG_ID = /^[a-z0-9][a-z0-9._-]{0,63}$/;
const CLASSES = Object.freeze(['cross-role', 'merge', 'policy', 'authority']);
const LEVELS = Object.freeze({ in: 'in the loop', near: 'near the loop', out: 'out of the loop' });
const HIRE_VALUES = ['--name', '--template', '--harness', '--model', '--thinking', '--level', '--project', '--reason'];
const AUTONOMY_VALUES = ['--revision', ...CLASSES.map(c => `--${c}`), '--notices', '--reverse-window-hours', '--authority-resolver'];
function parse(args, values) {
const pos = [], got = {}, flags = new Set();
for (let i = 0; i < args.length; i++) {
const a = args[i];
if (a === '--json') { if (flags.has(a)) throw usage('duplicate --json'); flags.add(a); }
else if (values.includes(a)) {
if (Object.hasOwn(got, a)) throw usage(`duplicate ${a}`);
if (i + 1 >= args.length || args[i + 1].startsWith('--')) throw usage(`${a} needs one value`);
got[a] = args[++i];
} else if (a.startsWith('--')) throw usage(`unknown option ${a}`);
else pos.push(a);
}
return { pos, values: got, json: flags.has('--json') };
}
// The hire form's body (bus.js hireSubmit), from flags. The bus's proposal checks (hire-decisions.mjs
// prepare) run on it unchanged; these checks only refuse what the form itself would not send.
function hireBody(op, v) {
const need = key => { if (!v[key]?.trim()) throw usage(`${op} needs ${key}`); return v[key]; };
const level = v['--level'] ?? 'org';
if (!['org', 'project', 'root'].includes(level)) throw usage('--level takes org, project or root');
if (level === 'project' ? !v['--project']?.trim() : v['--project'] !== undefined) throw usage(level === 'project' ? '--level project needs --project' : '--project goes with --level project');
const reason = need('--reason');
if (reason.length > 500) throw usage('--reason takes at most 500 characters');
if (op === 'fire') for (const k of ['--template', '--harness', '--model', '--thinking']) if (v[k] !== undefined) throw usage(`fire takes no ${k}`);
const hire = { op, level, name: need('--name'), reason, ...(level === 'project' ? { project: v['--project'] } : {}) };
if (op === 'fire') return { hire, template: null };
const template = need('--template');
const thinking = v['--thinking']?.trim();
return { hire: { ...hire, route: { harness: need('--harness'), model: need('--model'), ...(thinking ? { thinking } : {}) } }, template };
}
function autonomyChanges(v) {
const revision = v['--revision'];
if (revision === undefined) throw usage('autonomy set needs --revision, the revision mosaic org autonomy ID shows');
// The panel's revision is the bus's 64-hex digest of the organization (autonomy.set.console).
if (!/^[a-f0-9]{64}$/.test(revision)) throw usage('--revision takes the 64-hex revision mosaic org autonomy ID shows');
const changes = {};
for (const c of CLASSES) {
const value = v[`--${c}`];
if (value === undefined) continue;
if (!Object.hasOwn(LEVELS, value)) throw usage(`--${c} takes in, near or out`);
changes[c] = LEVELS[value];
}
if (v['--notices'] !== undefined) {
if (!['on', 'off'].includes(v['--notices'])) throw usage('--notices takes on or off');
changes.notices = v['--notices'] === 'on';
}
if (v['--reverse-window-hours'] !== undefined) {
if (!/^[1-9][0-9]{0,2}$/.test(v['--reverse-window-hours']) || Number(v['--reverse-window-hours']) > 168) throw usage('--reverse-window-hours takes a whole number of hours from 1 to 168');
changes.reverseWindowHours = Number(v['--reverse-window-hours']);
}
if (v['--authority-resolver'] !== undefined) changes.authorityResolver = v['--authority-resolver'];
if (!Object.keys(changes).length) throw usage('autonomy set needs a setting to change');
return { revision, changes };
}
// The panel's save sends every class with the level it shows, notices, the reverse window (24 when
// none is set) and the resolver when one is set (autonomy.js). The terminal sends the same document
// with the named settings changed, so an unchanged field keeps the value the page would send.
export function autonomyDocument(current, changes) {
const shown = current?.autonomy ?? {};
const doc = Object.fromEntries(CLASSES.map(c => [c, current?.levels?.[c]]));
doc.notices = shown.notices === true;
doc.reverseWindowHours = shown.reverseWindowHours ?? 24;
if (shown.authorityResolver) doc.authorityResolver = shown.authorityResolver;
const out = { ...doc, ...changes };
// An empty --authority-resolver clears it: the panel leaves an emptied field out of its save.
if (out.authorityResolver === '') delete out.authorityResolver;
return out;
}
// Autonomy codes carry no message from the route; these are the panel's own words (autonomy.js).
const AUTONOMY_GUIDANCE = {
'revision-conflict': 'the organization changed; read it again with mosaic org autonomy ID',
'autonomy-precondition': 'this move needs a separate resolver, a tested reverse, owner notices and an enforced budget covering new agents for authority; out of the loop needs a near-loop decision followed by your review of its trail',
'policy-reverse-unavailable': 'policy has no near-loop setting until a reverse exists',
'authority-limits-unavailable': 'authority cannot move out of the loop',
'owner-only': 'only the owner changes autonomy',
'read-only': 'start the Console with --send to make changes',
'sign-in-required': 'sign in to the Console',
};
// A refusal is exit 3 and changed nothing. outcome-unknown (and a 5xx the route did not name) may
// have changed something, so it is exit 1 and nothing is retried.
function refused(answer, what, unknown, guidance = {}) {
const raw = answer.body?.error;
const code = typeof raw === 'string' && /^[a-z-]{1,64}$/.test(raw) ? raw : `http ${answer.status}`;
if (code === 'outcome-unknown' || (answer.status >= 500 && !raw)) return new CliError(`outcome unknown: ${unknown}`, 1);
const said = typeof answer.body?.message === 'string' ? answer.body.message.slice(0, 500) : guidance[code];
return new CliError(`${what} refused (${code})${said ? `: ${said}` : ''}`, 3);
}
function ok(answer) { return answer.status === 200 && answer.body && typeof answer.body === 'object'; }
export async function orgOwnerCommand(args, io, deps = {}) {
const verb = args[0];
const autonomy = verb === 'autonomy';
const p = parse(args.slice(1), autonomy ? AUTONOMY_VALUES : verb === 'role-templates' ? [] : HIRE_VALUES);
const id = p.pos[0];
if (id === undefined) throw usage(`org ${verb} takes an organization id`);
if (!ORG_ID.test(id)) throw usage('invalid organization id');
let run;
if (verb === 'role-templates') {
if (p.pos.length !== 1) throw usage('role-templates takes one organization id');
run = async ctx => {
const answer = await consoleOrgRequest(ctx, id, { method: 'POST', path: 'decisions/hire/templates', body: { business: id }, unknown: 'the templates read did not finish; nothing was changed' });
if (!ok(answer) || !Array.isArray(answer.body.templates)) throw answer.status === 200 ? new CliError('the Console answered without templates', 1) : refused(answer, 'role-templates', 'the templates read did not finish; nothing was changed');
return { value: answer.body, text: b => b.templates.length ? b.templates.map(t => `${t.name} ${String(t.sha256 ?? '').slice(0, 12)}\n`).join('') : 'role templates: none\n' };
};
} else if (['hire', 'fire', 'change'].includes(verb)) {
if (p.pos.length !== 1) throw usage(`${verb} takes one organization id`);
const { hire, template } = hireBody(verb, p.values);
run = async ctx => {
let body = hire;
// The form chooses a template from the ones it read and sends its name and sha256.
if (template !== null) {
const read = await consoleOrgRequest(ctx, id, { method: 'POST', path: 'decisions/hire/templates', body: { business: id }, unknown: 'the templates read did not finish; nothing was changed' });
if (!ok(read) || !Array.isArray(read.body.templates)) throw refused(read, verb, 'the templates read did not finish; nothing was changed');
const chosen = read.body.templates.find(t => t?.name === template);
if (!chosen) throw new CliError(`${verb}: no role template ${JSON.stringify(template)}; see mosaic org role-templates ${id}`, 2);
const { route, ...rest } = hire;
body = { ...rest, role: { template: chosen.name, sha256: chosen.sha256 }, route };
}
const unknown = `the ${verb} may have been recorded; check mosaic inbox --business ${id} and mosaic trail before trying again`;
const answer = await consoleOrgRequest(ctx, id, { method: 'POST', path: 'decisions/hire/start', body: { business: id, hire: body }, timeoutMs: 120000, unknown });
if (!ok(answer)) throw refused(answer, verb, unknown);
if (typeof answer.body.id !== 'string' || answer.body.op !== 'resolved') throw new CliError(`outcome unknown: ${unknown}`, 1);
return { value: answer.body, text: b => `recorded decision ${b.id}: ${verb} ${hire.name} (${b.op} ${b.choice})\n` };
};
} else if (autonomy) {
const step = p.pos[1] ?? null;
if (step === null) {
if (p.pos.length !== 1 || Object.keys(p.values).length) throw usage('autonomy ID reads; use autonomy ID set to change it');
run = async ctx => {
const answer = await consoleOrgRequest(ctx, id, { path: 'autonomy' });
if (!ok(answer)) throw refused(answer, 'autonomy', 'the autonomy read did not finish; nothing was changed', AUTONOMY_GUIDANCE);
return { value: answer.body, text: formatAutonomy };
};
} else if (step === 'set') {
if (p.pos.length !== 2) throw usage('autonomy ID set takes options only');
const { revision, changes } = autonomyChanges(p.values);
run = async ctx => {
const read = await consoleOrgRequest(ctx, id, { path: 'autonomy' });
if (!ok(read)) throw refused(read, 'autonomy set', 'the autonomy read did not finish; nothing was changed', AUTONOMY_GUIDANCE);
const unknown = `the setting may have been saved; check mosaic org autonomy ${id} before trying again`;
const answer = await consoleOrgRequest(ctx, id, { method: 'POST', path: 'autonomy', body: { revision, autonomy: autonomyDocument(read.body, changes) }, unknown });
if (!ok(answer)) throw refused(answer, 'autonomy set', unknown, AUTONOMY_GUIDANCE);
return { value: answer.body, text: b => `saved autonomy for ${id}: revision ${b.revision ?? 'unknown'}${b.transaction ? `, receipt ${b.transaction}` : ''}\n` };
};
} else if (step === 'review') {
if (p.pos.length !== 3 || !CLASSES.includes(p.pos[2])) throw usage(`autonomy ID review takes one class: ${CLASSES.join(', ')}`);
if (Object.keys(p.values).length) throw usage('autonomy ID review takes no options but --json');
run = async ctx => {
const unknown = `the review may have been recorded; check mosaic trail before trying again`;
const answer = await consoleOrgRequest(ctx, id, { method: 'POST', path: 'autonomy/review', body: { decisionClass: p.pos[2] }, unknown });
if (!ok(answer)) throw refused(answer, 'autonomy review', unknown, AUTONOMY_GUIDANCE);
return { value: answer.body, text: () => `recorded your trail review for ${p.pos[2]} on ${id}\n` };
};
} else throw usage(`autonomy takes set or review, not ${JSON.stringify(step)}`);
} else throw usage(`unknown org verb ${verb}`);
// agent-guard (#1873): after the usage checks, before any config is read; this process's own
// environment as well as io.env, as org archive|delete check.
refuseInsideAgent(process.env);
refuseInsideAgent(io.env);
let instance;
try { instance = devOwnerInstance(io.env); }
catch (e) { throw new CliError(`${e.message}; these verbs need a named instance whose Console runs with --dev-local-owner`, 3); }
const system = deps.system ?? loadSystem({ env: io.env, instance });
const ctx = { configDir: configDir(io.env), dataRoot: system.dataRoot, instance, principal: DEV_OWNER };
const { value, text } = await run(ctx);
io.stdout.write(p.json ? `${JSON.stringify(value, null, 2)}\n` : text(value));
}
function formatAutonomy(b) {
const a = b.autonomy ?? {};
return [
`revision ${b.revision}${b.writable ? '' : ' (read-only: this Console takes no changes)'}`,
...CLASSES.map(c => `${c}: ${b.levels?.[c] ?? 'unknown'}`),
`notices: ${a.notices === true ? 'on' : 'off'}`,
`reverse window: ${a.reverseWindowHours === undefined ? 'not set (the page offers 24 hours)' : `${a.reverseWindowHours} hours`}`,
`authority resolver: ${a.authorityResolver ?? 'none'}`,
].join('\n') + '\n';
}