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.
345 lines
17 KiB
JavaScript
Executable File
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));
|