Files
stack/scripts/case2-persona-import.mjs
jason.woltje a8d9eecf65 organization: move packages/business to packages/organization (rename r2 stage B1)
git mv packages/business -> packages/organization (@mosaic/organization). The
old package stays as one-line re-export shims of each moved module, with
src/business.mjs re-exporting src/organization-config.mjs and src/cli.mjs
forwarding to the moved executable. BusinessError is OrganizationConfigError
(one constructor, name kept BusinessError); OrganizationError stays distinct.
The bus adapter gains organization-context.mjs (busOrganization), with
business.mjs (busBusiness) as the same function.

Import consumers, manifests, the lockfile, scripts/mosaic, stack-copy.sh
COPY_PATHS (both roots), seat sandbox RUNNER_FILES and the copy closure
walker (organization-imports.mjs, both roots required) move to the new path.
v1 serialization, v1 writers and the stage A fixtures are unchanged; the stage
A contract tests stay where they were and pass as before.
2026-11-09 11:47:48 -06:00

345 lines
17 KiB
JavaScript
Executable File

#!/usr/bin/env node
// Case 2 row C2-20 (#1722): Bean's and Chet's personas from her two Hermes profiles into Mosaic
// persona files (design 4 and 9.1, brief docs/plans/2026-10-20_case2-rows.md "C2-20").
//
// case2-persona-import.mjs stage --hermes-root DIR --pair PROFILE=NAME --pair PROFILE=NAME
// (--staging DIR | --dry-run)
// case2-persona-import.mjs install --staging DIR --config-dir DIR --business ID
// (--org ID is the same option)
//
// stage runs as whoever can read her Hermes home. Per profile it reads SOUL.md, the persona Hermes
// loads (agent/prompt_builder.py:1829-1855 at df5700ebe), and config.yaml for the per-channel prompt
// names. Nothing else in the profile is opened: no memory store, state.db, session or message.
// Hermes has no field holding a profile's own name (profile.yaml carries only description,
// hermes_cli/profiles.py:798-850), so the human passes the pairing and the script prints it with each
// SOUL.md's byte count and sha256 before anything is written (sage-1836). NAME is Bean or Chet and
// the instance is its lower case. stage writes each persona, byte for byte, to a 0600 file in a new
// 0700 staging directory with a manifest of names and digests. Handing the directory to <user> is a
// root step between the two runs; the script changes no owner. install runs as <user>, checks each
// staged file against the manifest and C2-08's fence, and writes
// <configDir>/personas/<business>/<instance>.md at 0600. It never overwrites.
//
// Output is names, byte counts and sha256, never persona or prompt text. A key from config.yaml
// prints as a JSON string, so it can't start a line of its own. --dry-run writes nothing and prints
// counts only: files, lines, lines starting "# " or "## ", and lines naming a Hermes tool or channel
// from HERMES_WORDS. It applies C2-08's fence as stage does, prints each persona's fault by its
// reason and then refuses. A SOUL.md over four times the fence in bytes refuses before any count.
//
// Exit codes: 0 ok, 1 refused, 2 usage.
import { chmodSync, closeSync, constants, fchmodSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, unlinkSync, writeSync } from "node:fs";
import { createHash } from "node:crypto";
import { join } from "node:path";
import { parse as parseYaml } from "yaml";
import { PERSONA_CHARS, personaTextFault } from "../packages/harness/src/bundle.mjs";
import { selectorScope } from "../packages/organization/src/selector.mjs";
// The tool names registered in hermes-agent's tools/*.py at df5700ebe (literal `name="..."` in
// registry.register calls; the MCP and plugin tools get their names at run time), plus Hermes and
// Discord (design 9.1). Matched as whole words, any case.
const HERMES_WORDS = Object.freeze([
"browser_back", "browser_cdp", "browser_click", "browser_console", "browser_dialog", "browser_get_images",
"browser_navigate", "browser_press", "browser_scroll", "browser_snapshot", "browser_type", "browser_vision",
"clarify", "close_terminal", "computer_use", "cronjob", "delegate_task", "discord", "discord_admin",
"execute_code", "feishu_doc_read", "feishu_drive_add_comment", "feishu_drive_list_comment_replies",
"feishu_drive_list_comments", "feishu_drive_reply_comment", "ha_call_service", "ha_get_state",
"ha_list_entities", "ha_list_services", "image_generate", "kanban_block", "kanban_comment", "kanban_complete",
"kanban_create", "kanban_heartbeat", "kanban_link", "kanban_list", "kanban_show", "kanban_unblock", "memory",
"patch", "process", "project_create", "project_list", "project_switch", "read_file", "read_terminal",
"search_files", "session_search", "skill_manage", "skill_view", "skills_list", "terminal", "text_to_speech",
"todo", "video_analyze", "video_generate", "vision_analyze", "web_extract", "web_search", "write_file",
"x_search", "xai_video_edit", "xai_video_extend", "yb_query_group_info", "yb_query_group_members",
"yb_search_sticker", "yb_send_dm", "yb_send_sticker",
"Hermes", "Discord",
]);
const WORD_LINE = new RegExp(`\\b(?:${HERMES_WORDS.join("|")})\\b`, "i");
const NAMES = Object.freeze(["Bean", "Chet"]);
const PROFILE_ID = /^[a-z0-9][a-z0-9_-]{0,63}$/; // hermes_cli/profiles.py:37
const BUSINESS_ID = /^[a-z0-9][a-z0-9-]{0,63}$/;
const MANIFEST = "manifest.json";
const CONFIG_MAX = 4 * 1024 * 1024;
// The same split the fence's /^...$/m uses, so a counted heading line is one the fence refuses.
const LINE_BREAK = /\r\n|[\n\r\u2028\u2029]/;
const utf8 = new TextDecoder("utf-8", { fatal: true });
const sha256 = (data) => createHash("sha256").update(data).digest("hex");
class Refusal extends Error {}
class Usage extends Error {}
function args(argv) {
const out = { pairs: [] };
// --org and --business are one selector in these options (rename design r2). A missing value or
// one starting "--" is a usage error, as before; an id outside this script's grammar is its
// refusal, now while parsing; a repeated or conflicting selector is a usage error.
let given;
const selector = selectorScope({
valid: (id) => BUSINESS_ID.test(id),
refuse: (kind, why) => {
if (kind === "invalid" || (kind === "flag" && !given.startsWith("--"))) throw new Refusal(`business ${given} isn't a business id`);
throw new Usage(why);
},
});
for (let i = 0; i < argv.length; i++) {
const flag = argv[i];
if (flag === "--dry-run") { out.dryRun = true; continue; }
if (selector.owns(flag)) { given = argv[i + 1]; selector.take(flag, given); i++; continue; }
const value = argv[i + 1];
if (value === undefined || value.startsWith("--")) throw new Usage(`${flag} needs a value`);
i++;
if (flag === "--pair") out.pairs.push(value);
else if (flag === "--hermes-root") out.hermesRoot = value;
else if (flag === "--staging") out.staging = value;
else if (flag === "--config-dir") out.configDir = value;
else throw new Usage(`unknown option ${flag}`);
}
if (selector.given) out.business = selector.value;
return out;
}
// Exactly two pairs, two distinct profiles, Bean and Chet once each.
function pairing(pairs, hermesRoot) {
if (pairs.length !== 2) throw new Usage("give exactly two --pair PROFILE=NAME");
const out = pairs.map((p) => {
const m = /^([^=]+)=(.*)$/.exec(p);
if (!m) throw new Usage(`--pair ${p} isn't PROFILE=NAME`);
const [, profile, name] = m;
if (!PROFILE_ID.test(profile)) throw new Refusal(`profile ${profile} isn't a Hermes profile name`);
if (!NAMES.includes(name)) throw new Refusal(`profile ${profile} names ${JSON.stringify(name)}, not Bean or Chet`);
const dir = profile === "default" ? hermesRoot : join(hermesRoot, "profiles", profile);
const s = lstatSync(dir, { throwIfNoEntry: false });
if (!s) throw new Refusal(`profile ${profile}: ${dir} doesn't exist`);
if (!s.isDirectory()) throw new Refusal(`profile ${profile}: ${dir} isn't a directory`);
return { profile, name, instance: name.toLowerCase(), dir };
});
if (out[0].profile === out[1].profile) throw new Refusal(`profile ${out[0].profile} is paired twice`);
if (out[0].name === out[1].name) throw new Refusal(`${out[0].name} is named twice`);
return out;
}
// One regular file, opened without following a last-component symlink, at most max bytes.
function readFile(path, max, what) {
let fd;
try {
fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
} catch (e) {
if (e.code === "ENOENT") return null;
if (e.code === "ELOOP") throw new Refusal(`${what} ${path} is a symlink`);
throw new Refusal(`${what} ${path} can't be opened (${e.code})`);
}
try {
const s = fstatSync(fd);
if (!s.isFile()) throw new Refusal(`${what} ${path} isn't a regular file`);
if (s.size > max) throw new Refusal(`${what} ${path} is over ${max} bytes`);
return { data: readFileSync(fd), stat: s };
} finally {
closeSync(fd);
}
}
// The persona Hermes loads: <profile dir>/SOUL.md. Hermes treats a missing or blank one as no
// persona, and so does this.
function soul(p) {
const path = join(p.dir, "SOUL.md");
const file = readFile(path, PERSONA_CHARS * 4, `profile ${p.profile}: persona`);
if (!file) throw new Refusal(`profile ${p.profile} has no persona (no SOUL.md)`);
let text;
try { text = utf8.decode(file.data); } catch { throw new Refusal(`profile ${p.profile}: persona ${path} isn't UTF-8 text`); }
if (!text.trim()) throw new Refusal(`profile ${p.profile} has no persona (SOUL.md is blank)`);
return { data: file.data, text };
}
// Per-channel prompts, by platform and channel id, from the three places Hermes takes a platform's
// block (gateway/config.py:1159-1178 at df5700ebe): <platform>, platforms.<platform> and
// gateway.platforms.<platform>, each with channel_prompts (:1233). Names and byte counts only.
function channelPrompts(config) {
const out = [];
const scan = (where, block) => {
if (!block || typeof block !== "object" || Array.isArray(block)) return;
for (const [platform, cfg] of Object.entries(block)) {
const prompts = cfg && typeof cfg === "object" ? cfg.channel_prompts : undefined;
if (!prompts || typeof prompts !== "object" || Array.isArray(prompts)) continue;
for (const [channel, value] of Object.entries(prompts)) {
out.push({ where: `${where}${platform}`, channel: String(channel), bytes: typeof value === "string" ? Buffer.byteLength(value) : null });
}
}
};
if (config && typeof config === "object") {
const { platforms, gateway, ...top } = config;
scan("", top);
scan("platforms.", platforms);
scan("gateway.platforms.", gateway?.platforms);
}
return out;
}
function config(p) {
const path = join(p.dir, "config.yaml");
const file = readFile(path, CONFIG_MAX, `profile ${p.profile}: config`);
if (!file) return [];
let doc;
// A Discord channel id is 18 or 19 digits, past a double's exact range, so unquoted ids parse as
// BigInt. Python's safe_load keeps the last of two equal keys, and so does this. yaml's warnings
// quote config text, so they are off.
try { doc = parseYaml(utf8.decode(file.data), { intAsBigInt: true, uniqueKeys: false, logLevel: "silent" }); } catch { throw new Refusal(`profile ${p.profile}: ${path} isn't YAML`); }
return channelPrompts(doc);
}
function counts(text) {
const lines = text.split(LINE_BREAK);
if (lines.at(-1) === "") lines.pop();
return {
lines: lines.length,
headings: lines.filter((l) => /^##? /.test(l)).length,
words: lines.filter((l) => WORD_LINE.test(l)).length,
};
}
// A new 0700 directory, or a check that an existing one is ours and closed to group and other.
function privateDir(path, { create }) {
const s = lstatSync(path, { throwIfNoEntry: false });
if (!s) {
if (!create) throw new Refusal(`${path} doesn't exist`);
try {
mkdirSync(path, { mode: 0o700 });
} catch (e) {
if (e.code === "EEXIST") throw new Refusal(`${path} was made by something else between the check and mkdir`);
throw e;
}
chmodSync(path, 0o700);
return;
}
if (!s.isDirectory()) throw new Refusal(`${path} isn't a directory`);
if (s.uid !== process.getuid()) throw new Refusal(`${path} isn't owned by uid ${process.getuid()}`);
if (s.mode & 0o077) throw new Refusal(`${path} has group or other mode bits`);
}
// A new 0600 file; refuses one that exists.
function writeNew(path, data) {
let fd;
try {
fd = openSync(path, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
} catch (e) {
if (e.code === "EEXIST") throw new Refusal(`${path} already exists`);
throw new Refusal(`${path} can't be created (${e.code})`);
}
try {
fchmodSync(fd, 0o600);
writeSync(fd, data);
} catch (e) {
closeSync(fd);
unlinkSync(path);
throw e;
}
closeSync(fd);
}
function stage(o, say) {
if (!o.hermesRoot) throw new Usage("stage needs --hermes-root");
if (!o.dryRun === !o.staging) throw new Usage("stage needs exactly one of --staging and --dry-run");
const pairs = pairing(o.pairs, o.hermesRoot);
const personas = pairs.map((p) => ({ ...p, ...soul(p) }));
if (o.dryRun) {
say(`files ${personas.length}`);
const faulted = [];
for (const p of personas) {
const c = counts(p.text);
say(`profile ${p.profile}: lines ${c.lines}, lines starting "# " or "## " ${c.headings}, lines naming a Hermes tool or channel ${c.words}`);
const fault = personaTextFault(p.text);
if (fault) { say(`profile ${p.profile}: persona ${fault}, so C2-08's fence would refuse it`); faulted.push(p.profile); }
}
if (faulted.length) throw new Refusal(`C2-08's fence would refuse the persona of profile ${faulted.join(" and ")}, so stage would write nothing`);
return;
}
for (const p of personas) {
say(`pairing: profile ${p.profile} is ${p.name} (instance ${p.instance}), SOUL.md ${p.data.length} bytes, sha256 ${sha256(p.data)}`);
}
for (const p of pairs) {
const prompts = config(p);
if (!prompts.length) say(`profile ${p.profile}: no per-channel prompts`);
for (const c of prompts) say(`profile ${p.profile}: per-channel prompt ${JSON.stringify(c.where)} ${JSON.stringify(c.channel)}, ${c.bytes === null ? "not text" : `${c.bytes} bytes`}`);
}
for (const p of personas) {
const fault = personaTextFault(p.text);
if (fault) throw new Refusal(`profile ${p.profile}: persona ${fault}, so C2-08's fence would refuse it; nothing written`);
}
privateDir(o.staging, { create: true });
const manifest = { version: 1, personas: [] };
for (const p of personas) {
writeNew(join(o.staging, `${p.instance}.md`), p.data);
manifest.personas.push({ instance: p.instance, name: p.name, bytes: p.data.length, sha256: sha256(p.data) });
}
writeNew(join(o.staging, MANIFEST), `${JSON.stringify(manifest, null, 2)}\n`);
for (const m of manifest.personas) say(`staged: ${join(o.staging, `${m.instance}.md`)}, ${m.bytes} bytes, sha256 ${m.sha256}`);
say(`next, as root: give ${o.staging} and its files to <user> (chown -R <user>: ${o.staging}), then as <user>:`);
say(` scripts/case2-persona-import.mjs install --staging ${o.staging} --config-dir <configDir> --business <business>`);
}
function staged(path, what) {
const file = readFile(path, PERSONA_CHARS * 4 + 64 * 1024, what);
if (!file) throw new Refusal(`${what} ${path} doesn't exist`);
if (file.stat.uid !== process.getuid()) throw new Refusal(`${what} ${path} isn't owned by uid ${process.getuid()}`);
if (file.stat.mode & 0o077) throw new Refusal(`${what} ${path} has group or other mode bits`);
return file.data;
}
function install(o, say) {
if (!o.staging || !o.configDir || !o.business) throw new Usage("install needs --staging, --config-dir and --business");
if (o.pairs.length || o.hermesRoot || o.dryRun) throw new Usage("install takes only --staging, --config-dir and --business");
if (!BUSINESS_ID.test(o.business)) throw new Refusal(`business ${o.business} isn't a business id`);
privateDir(o.staging, { create: false });
let manifest;
try { manifest = JSON.parse(staged(join(o.staging, MANIFEST), "manifest")); } catch (e) {
if (e instanceof Refusal) throw e;
throw new Refusal(`manifest ${join(o.staging, MANIFEST)} isn't JSON`);
}
const entries = manifest?.version === 1 && Array.isArray(manifest.personas) ? manifest.personas : null;
if (!entries || entries.length !== 2) throw new Refusal("the manifest doesn't list two personas");
const seen = new Set();
const personas = entries.map((m) => {
if (!NAMES.includes(m?.name) || m.instance !== m.name.toLowerCase() || seen.has(m.name)) throw new Refusal("the manifest doesn't pair Bean and Chet once each");
seen.add(m.name);
const data = staged(join(o.staging, `${m.instance}.md`), `staged persona ${m.instance}:`);
if (sha256(data) !== m.sha256) throw new Refusal(`staged persona ${m.instance} isn't the file the manifest names`);
let text;
try { text = utf8.decode(data); } catch { throw new Refusal(`staged persona ${m.instance} isn't UTF-8 text`); }
const fault = personaTextFault(text);
if (fault) throw new Refusal(`staged persona ${m.instance} ${fault}, so C2-08's fence would refuse it`);
return { ...m, data };
});
privateDir(o.configDir, { create: false });
const dir = join(o.configDir, "personas", o.business);
privateDir(join(o.configDir, "personas"), { create: true });
privateDir(dir, { create: true });
for (const p of personas) {
const target = join(dir, `${p.instance}.md`);
if (lstatSync(target, { throwIfNoEntry: false })) throw new Refusal(`${target} already exists; nothing written`);
}
for (const p of personas) writeNew(join(dir, `${p.instance}.md`), p.data);
for (const p of personas) {
say(`installed: ${join(dir, `${p.instance}.md`)}, ${p.data.length} bytes`);
say(`instance ${p.instance}: persona.name ${p.name}, persona.sha256 ${p.sha256}`);
}
}
function main(argv, say = (line) => process.stdout.write(`${line}\n`)) {
const [verb, ...rest] = argv;
try {
const o = args(rest);
if (verb === "stage") stage(o, say);
else if (verb === "install") install(o, say);
else throw new Usage("verb is stage or install");
return 0;
} catch (e) {
if (e instanceof Usage) { process.stderr.write(`case2-persona-import: ${e.message}\n`); return 2; }
if (e instanceof Refusal) { process.stderr.write(`case2-persona-import: refused: ${e.message}\n`); return 1; }
throw e;
}
}
process.exitCode = main(process.argv.slice(2));