#!/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 is a // root step between the two runs; the script changes no owner. install runs as , checks each // staged file against the manifest and C2-08's fence, and writes // /personas//.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: /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): , platforms. and // gateway.platforms., 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 (chown -R : ${o.staging}), then as :`); say(` scripts/case2-persona-import.mjs install --staging ${o.staging} --config-dir --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));