gosalyn: row 221 (C2-20, #1722) Bean and Chet persona import on 213 r4 2b3e67e36: stage and install script, personaTextFault exported from bundle.mjs, yaml 2.9.0 exact, fixture Hermes home and read trace, 18 singles 417/0 (sage-1836, sage-1840, sage-1851, sage-1863)

This commit is contained in:
2026-10-21 05:06:24 -05:00
parent 62ddc96cc7
commit 48f044ccc2
22 changed files with 872 additions and 6 deletions
+141
View File
@@ -0,0 +1,141 @@
# Row 221 build notes, C2-20 Bean and Chet persona import (#1722, Vikunja #416)
Brief: docs/plans/2026-10-20_case2-rows.md "C2-20", blob 16847e94ae17 (sage-1834). Claimed rev 2736.
Rulings: sage-1836 (fence, pairing) and sage-1840 (YAML parser, handoff).
## Base
Built on Morgana's row 213 r4 tip 2b3e67e36 (origin/morgana/row213-r4), since the brief says "After:
C2-08" and 221 exports 213's fence check. Before review it goes onto 213's landing under a new branch
name, and its tests run again there.
## What changed
- `scripts/case2-persona-import.mjs`, new. Verbs `stage` and `install`, usage in its header.
- `packages/cli/tests/case2-persona-import.test.mjs`, new, and `packages/cli/tests/fixtures/case2-persona-import/`,
a fixture Hermes home plus `trace.mjs`, the read trace.
- `packages/harness/src/bundle.mjs`: the three text rules in `readPersona` move into an exported
`personaTextFault(text)`, which returns null or the reason, and `readPersona` calls it. Nothing it
checks changes and the messages are byte for byte the same, so 213's creative tests hold. There is
one copy of the rules and the import calls it (sage-1836).
- `package.json` and `package-lock.json`: `yaml` 2.9.0 becomes an exact direct dependency (sage-1840 Q3).
## Rulings recorded
Profile to name (sage-1836, answers DESIGN.md:1322 as an implementation ruling, not a PRD change).
Hermes at df5700ebe has no field holding a profile's own name. `profile.yaml` has only `description`
and `description_auto` (hermes_cli/profiles.py:798-850), and `agent_name` at cli.py:3494 is skin
branding with the default "Hermes Agent". So the human passes the pairing as `--pair default=Bean
--pair chet=Chet`. The script checks both profile directories exist, each name is exactly Bean or Chet
and the two differ, then prints the pairing with each SOUL.md's byte count and sha256 before it writes.
The brief's "confirm profile to name" is met by the human supplying and confirming the pairing.
The persona is `<profile dir>/SOUL.md`, where Hermes loads it (agent/prompt_builder.py:1829-1855).
The default profile's directory is the Hermes root, and others are `profiles/<name>`, with names
checked against hermes_cli/profiles.py:37. A missing or blank SOUL.md is "no persona", as in Hermes.
The file is staged byte for byte. Hermes strips it and runs its own scan before use, but this import
copies her text unchanged.
Handoff between the steps (sage-1840 Q4). `stage` writes 0600 files owned by whoever ran it. `install`
runs as `<user>` and refuses a staging file that isn't its own or has a group or other bit. The script
changes no owner. It prints the root step, `chown -R <user>: <staging>`, as an instruction. **Item for
Jason's go:** who runs stage on `<host>`, and the root chown between the two steps.
## Word list
69 tool names: every literal `name="..."` in a `registry.register(` call under hermes-agent's
`tools/*.py` at df5700ebe, plus Hermes and Discord. Two more registrations, in tools/mcp_tool.py:4997
and :5034, and plugin tools (hermes_cli/plugins.py:430) get their names at run time, so a fixed list
can't hold them. Matching is whole word, any case. `memory`, `terminal`, `todo`, `process`, `patch` and
`clarify` are ordinary words, so a persona line using them in prose counts, and the count leans high.
It is a hint for Jason and changes nothing in the import.
## Channel prompts
Hermes takes a platform's block from `<platform>` at the top level of config.yaml, else from
`platforms.<platform>` or `gateway.platforms.<platform>` (gateway/config.py:1159-1178), and its
prompts from `channel_prompts` (:1233). The script lists all three by platform, channel id and byte
count, and says "not text" for a value that isn't a string. The dry run doesn't read config.yaml.
yaml parses with `intAsBigInt`, so an unquoted 18 or 19 digit Discord id prints exactly instead of
rounding as a double, and with `uniqueKeys: false`, so a repeated key keeps the last value as Python's
`safe_load` does. `logLevel: "silent"` stops yaml's warnings, which quote config text. yaml reads YAML
1.2 and Hermes's PyYAML reads 1.1. They differ on scalars such as `yes` and `0123`, but neither
changes a channel prompt's key or its byte count for a quoted or decimal id.
## yaml dependency (sage-1840 Q3)
Made with `npm install --save-exact [email protected] --package-lock-only --ignore-scripts` at the worktree
root. `git config core.hooksPath` was unset before and after. npm moved nothing else:
```
package-lock.json | 3 ++-
package.json | 3 ++-
2 files changed, 4 insertions(+), 2 deletions(-)
```
```
@@ -10,7 +10,8 @@
"license": "UNLICENSED",
"dependencies": {
"@earendil-works/pi-coding-agent": "0.85.1",
- "oauth4webapi": "3.8.8"
+ "oauth4webapi": "3.8.8",
+ "yaml": "2.9.0"
}
},
```
node_modules/yaml is 2.9.0, from the pinned pi packages (package-lock.json:502 and :556).
Where it runs. `scripts/case2-persona-import.mjs` isn't in stack-copy's COPY_PATHS, and a copy carries
no node_modules of its own beyond its `npm ci`. So the script runs from a checkout at the reviewed
commit after `npm ci`, where `import "yaml"` resolves through the root package.json and
`../packages/harness/src/bundle.mjs` is beside it. Whoever runs `stage` needs read access to that
checkout and to her Hermes home, which is part of the item for Jason's go above.
## Tests
Round 1, on 213 r4 2b3e67e36, slot grants sage-1851 and sage-1863. One `node --test` per file.
Run 1 started at 08:19:44Z. A 600 s tool timeout on my wrapper killed it at about 08:29:45Z, inside
stack-release-external, after stack-release took 494 s. One test in this row's file failed in run 1.
Its filter `includes("per-channel prompt")` also matched the "profile chet: no per-channel prompts"
line that the next assert checks on its own. That was a test defect, and the script's output was
right. The filter is now `includes(": per-channel prompt ")`. The kill orphaned one fixture
holder.cjs, which I killed, and I removed its tmp dirs. Sage counts this as one failure under mos-0823.
The rerun, 08:31:11Z to 08:33:31Z, covered this row's file, stack-release-external, stack-upgrade
and the fixture dry run. The 15 files that run 1 finished green stand on code the fix didn't touch. Their counts come from run 1.
| File | pass | fail |
|---|---|---|
| packages/business/tests/launch-settings.test.mjs | 16 | 0 |
| packages/cli/tests/stack-copy.test.mjs | 16 | 0 |
| packages/cli/tests/stack-release.test.mjs | 195 | 0 |
| packages/conversation/tests/cohort.test.mjs | 37 | 0 |
| packages/harness/tests/bundle.test.mjs | 22 | 0 |
| packages/harness/tests/claude-gate.test.mjs | 3 | 0 |
| packages/harness/tests/claude-session.test.mjs | 8 | 0 |
| packages/harness/tests/creative.test.mjs | 7 | 0 |
| packages/harness/tests/mcp-server.test.mjs | 5 | 0 |
| packages/harness/tests/pi-session.test.mjs | 8 | 0 |
| packages/harness/tests/skills.test.mjs | 10 | 0 |
| packages/plugins/tests/harness.test.mjs | 9 | 0 |
| packages/seat/tests/sandbox.test.mjs | 10 | 0 |
| packages/webui/tests/editor.test.mjs | 5 | 0 |
| packages/webui/tests/pinned-copy.test.mjs | 10 | 0 |
| packages/cli/tests/case2-persona-import.test.mjs | 11 | 0 |
| packages/cli/tests/stack-release-external.test.mjs | 25 | 0 |
| packages/cli/tests/stack-upgrade.test.mjs | 20 | 0 |
Total over the 18 files: 417 pass, 0 fail, 0 cancelled, summed by awk.
The dry run on the fixture, `stage --hermes-root packages/cli/tests/fixtures/case2-persona-import/hermes
--pair default=Bean --pair chet=Chet --dry-run`, exited 0 and printed:
```
files 2
profile default: lines 3, lines starting "# " or "## " 0, lines naming a Hermes tool or channel 0
profile chet: lines 2, lines starting "# " or "## " 0, lines naming a Hermes tool or channel 0
```
+2 -1
View File
@@ -10,7 +10,8 @@
"license": "UNLICENSED",
"dependencies": {
"@earendil-works/pi-coding-agent": "0.85.1",
"oauth4webapi": "3.8.8"
"oauth4webapi": "3.8.8",
"yaml": "2.9.0"
}
},
"node_modules/@anthropic-ai/sdk": {
+2 -1
View File
@@ -6,6 +6,7 @@
"license": "UNLICENSED",
"dependencies": {
"@earendil-works/pi-coding-agent": "0.85.1",
"oauth4webapi": "3.8.8"
"oauth4webapi": "3.8.8",
"yaml": "2.9.0"
}
}
@@ -0,0 +1,336 @@
// Case 2 row C2-20 (#1722): scripts/case2-persona-import.mjs takes Bean's and Chet's personas from
// two Hermes profiles into Mosaic persona files. Brief: docs/plans/2026-10-20_case2-rows.md "C2-20".
// Every case runs on a scratch copy of fixtures/case2-persona-import/hermes, a scratch staging
// directory and a scratch config directory. Nothing here reads or writes ~/.hermes, ~/.mosaic-dev or
// ~/.config/mosaic-dev, and no fixture value is real. Fixture strings starting "marker-" must never
// reach the script's output.
import { test } from "node:test";
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { createHash } from "node:crypto";
import { chmodSync, cpSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { PERSONA_CHARS, personaTextFault } from "../../harness/src/bundle.mjs";
import { tmp } from "./helpers.mjs";
const REPO = fileURLToPath(new URL("../../../", import.meta.url));
const SCRIPT = join(REPO, "scripts", "case2-persona-import.mjs");
const FIXTURE = join(REPO, "packages", "cli", "tests", "fixtures", "case2-persona-import");
const TRACE = join(FIXTURE, "trace.mjs");
const PAIRS = ["--pair", "default=Bean", "--pair", "chet=Chet"];
const sha256 = (data) => createHash("sha256").update(data).digest("hex");
const mode = (path) => statSync(path).mode & 0o777;
// A scratch Hermes home from the fixture, with the two .env files git won't carry, and empty
// staging and config parents.
function scratch(t) {
const root = tmp(t, "mosaic-persona-");
const hermes = join(root, "hermes");
cpSync(join(FIXTURE, "hermes"), hermes, { recursive: true });
writeFileSync(join(hermes, ".env"), "DISCORD_BOT_TOKEN=marker-env-default-3b58\n");
writeFileSync(join(hermes, "profiles", "chet", ".env"), "DISCORD_BOT_TOKEN=marker-env-chet-7e44\n");
const configDir = join(root, "config");
mkdirSync(configDir, { mode: 0o700 });
chmodSync(configDir, 0o700);
return { root, hermes, configDir, staging: join(root, "staging"), trace: join(root, "trace.log") };
}
function run(argv, { trace } = {}) {
const pre = trace ? ["--import", TRACE] : [];
const env = { ...process.env, ...(trace ? { PERSONA_TRACE: trace } : {}) };
const r = spawnSync(process.execPath, [...pre, SCRIPT, ...argv], { encoding: "utf8", env, timeout: 30_000 });
return { status: r.status, out: r.stdout, err: r.stderr, all: r.stdout + r.stderr };
}
const stage = (s, extra = [], opts) => run(["stage", "--hermes-root", s.hermes, ...PAIRS, "--staging", s.staging, ...extra], opts);
const install = (s, business = "case2") => run(["install", "--staging", s.staging, "--config-dir", s.configDir, "--business", business]);
// Every fixture marker and every line of fixture text the script reads.
function assertNoText(s, output) {
const markers = new Set();
const walk = (dir) => {
for (const e of readdirSync(dir, { withFileTypes: true })) {
const p = join(dir, e.name);
if (e.isDirectory()) walk(p);
else for (const m of readFileSync(p, "utf8").match(/marker-[a-z]+(?:-[a-z]+)*-[0-9a-f]{4}/g) ?? []) markers.add(m);
}
};
walk(s.hermes);
assert.ok(markers.size >= 20, `only ${markers.size} markers found`);
for (const m of markers) assert.ok(!output.includes(m), `output holds ${m}`);
for (const line of ["You speak warmly", "Dry humour", "Help with the novel", "Ünïcode prompt"]) {
assert.ok(!output.includes(line), `output holds "${line}"`);
}
}
function personaTree(s, { bean, chet }) {
if (bean !== undefined) writeFileSync(join(s.hermes, "SOUL.md"), bean);
if (chet !== undefined) writeFileSync(join(s.hermes, "profiles", "chet", "SOUL.md"), chet);
}
test("stage takes each profile's SOUL.md byte for byte and prints the pairing with sizes and digests", (t) => {
const s = scratch(t);
const bean = readFileSync(join(s.hermes, "SOUL.md"));
const chet = readFileSync(join(s.hermes, "profiles", "chet", "SOUL.md"));
const r = stage(s);
assert.equal(r.status, 0, r.all);
assert.deepEqual(readFileSync(join(s.staging, "bean.md")), bean);
assert.deepEqual(readFileSync(join(s.staging, "chet.md")), chet);
assert.ok(r.out.includes(`pairing: profile default is Bean (instance bean), SOUL.md ${bean.length} bytes, sha256 ${sha256(bean)}`), r.out);
assert.ok(r.out.includes(`pairing: profile chet is Chet (instance chet), SOUL.md ${chet.length} bytes, sha256 ${sha256(chet)}`), r.out);
// The pairing comes before the first file written.
assert.ok(r.out.indexOf("pairing:") < r.out.indexOf("staged:"), r.out);
const manifest = JSON.parse(readFileSync(join(s.staging, "manifest.json"), "utf8"));
assert.deepEqual(manifest, {
version: 1,
personas: [
{ instance: "bean", name: "Bean", bytes: bean.length, sha256: sha256(bean) },
{ instance: "chet", name: "Chet", bytes: chet.length, sha256: sha256(chet) },
],
});
assert.match(r.out, /next, as root: give .* to <user>/);
assertNoText(s, r.all);
});
// gateway/config.py at hermes-agent df5700ebe: a platform's block is <platform> at the top level
// (:1159), else platforms.<platform> or gateway.platforms.<platform> (:1172-1178), and the prompts are
// its channel_prompts (:1233). The fixture config.yaml has one of each.
test("stage lists the per-channel prompts in all three places by name and byte count only", (t) => {
const s = scratch(t);
const r = stage(s);
assert.equal(r.status, 0, r.all);
const lines = r.out.split("\n").filter((l) => l.includes(": per-channel prompt "));
assert.deepEqual(lines, [
`profile default: per-channel prompt discord 111111111111111111, ${Buffer.byteLength("marker-prompt-top-5d02 Help with the novel.")} bytes`,
`profile default: per-channel prompt discord 222222222222222222, ${Buffer.byteLength("Ünïcode prompt")} bytes`,
// Unquoted in the fixture, and past 2 ** 53: the id prints exactly.
"profile default: per-channel prompt discord 1333333333333333333, 5 bytes",
`profile default: per-channel prompt platforms.slack C0FIXTURE, ${Buffer.byteLength("marker-prompt-platforms-8e3a")} bytes`,
"profile default: per-channel prompt gateway.platforms.telegram -1001, 29 bytes",
"profile default: per-channel prompt gateway.platforms.telegram -1002, not text",
]);
assert.ok(r.out.includes("profile chet: no per-channel prompts"), r.out);
assertNoText(s, r.all);
});
test("a read trace shows stage and the dry run open SOUL.md and config.yaml and never memory, sessions or secrets", (t) => {
const s = scratch(t);
for (const argv of [["--staging", s.staging], ["--dry-run"]]) {
rmSync(s.trace, { force: true });
const r = run(["stage", "--hermes-root", s.hermes, ...PAIRS, ...argv], { trace: s.trace });
assert.equal(r.status, 0, r.all);
const opened = readFileSync(s.trace, "utf8").split("\n").filter(Boolean).map((l) => l.slice(l.indexOf(" ") + 1)).filter((p) => p.startsWith(s.hermes));
// The control: the trace sees the reads the script does make.
assert.ok(opened.includes(join(s.hermes, "SOUL.md")), opened.join("\n"));
assert.ok(opened.includes(join(s.hermes, "profiles", "chet", "SOUL.md")), opened.join("\n"));
const allowed = new Set([
join(s.hermes, "SOUL.md"), join(s.hermes, "config.yaml"),
join(s.hermes, "profiles", "chet", "SOUL.md"), join(s.hermes, "profiles", "chet", "config.yaml"),
]);
for (const p of opened) assert.ok(allowed.has(p), `opened ${p}`);
if (argv[0] === "--dry-run") assert.ok(!opened.some((p) => p.endsWith("config.yaml")), "the dry run read config.yaml");
else assert.ok(opened.includes(join(s.hermes, "config.yaml")), opened.join("\n"));
}
});
test("the pairing refuses anything but two existing profiles naming Bean and Chet once each, and writes nothing", (t) => {
const s = scratch(t);
const cases = [
[["--pair", "default=Bean", "--pair", "chet=Bob"], 1, /profile chet names "Bob", not Bean or Chet/],
[["--pair", "default=bean", "--pair", "chet=Chet"], 1, /names "bean", not Bean or Chet/],
[["--pair", "default=Bean", "--pair", "chet=Bean"], 1, /Bean is named twice/],
[["--pair", "chet=Bean", "--pair", "chet=Chet"], 1, /profile chet is paired twice/],
[["--pair", "default=Bean", "--pair", "nobody=Chet"], 1, /profile nobody: .* doesn't exist/],
[["--pair", "default=Bean", "--pair", "../chet=Chet"], 1, /isn't a Hermes profile name/],
[["--pair", "default=Bean"], 2, /exactly two --pair/],
[["--pair", "default=Bean", "--pair", "chet=Chet", "--pair", "x=Chet"], 2, /exactly two --pair/],
];
for (const [pairs, status, message] of cases) {
const r = run(["stage", "--hermes-root", s.hermes, ...pairs, "--staging", s.staging]);
assert.equal(r.status, status, `${pairs.join(" ")}: ${r.all}`);
assert.match(r.err, message);
assert.ok(!existsSync(s.staging), `${pairs.join(" ")} wrote the staging directory`);
}
// Swapped is a valid pairing: the human's say decides it.
const r = run(["stage", "--hermes-root", s.hermes, "--pair", "default=Chet", "--pair", "chet=Bean", "--staging", s.staging]);
assert.equal(r.status, 0, r.all);
assert.match(r.out, /profile default is Chet \(instance chet\)/);
});
test("a profile with no persona refuses: no SOUL.md, a blank one, a symlink, not UTF-8", (t) => {
const s = scratch(t);
const chetSoul = join(s.hermes, "profiles", "chet", "SOUL.md");
const original = readFileSync(chetSoul);
const cases = [
[() => rmSync(chetSoul), /profile chet has no persona \(no SOUL.md\)/],
[() => writeFileSync(chetSoul, " \n\t\n"), /profile chet has no persona \(SOUL.md is blank\)/],
[() => { rmSync(chetSoul); symlinkSync(join(s.hermes, "SOUL.md"), chetSoul); }, /is a symlink/],
[() => writeFileSync(chetSoul, Buffer.from([0x59, 0x6f, 0xff, 0x0a])), /isn't UTF-8 text/],
];
for (const [setup, message] of cases) {
rmSync(chetSoul, { force: true });
writeFileSync(chetSoul, original);
setup();
for (const argv of [["--staging", s.staging], ["--dry-run"]]) {
const r = run(["stage", "--hermes-root", s.hermes, ...PAIRS, ...argv]);
assert.equal(r.status, 1, r.all);
assert.match(r.err, message);
assert.ok(!existsSync(s.staging));
}
}
});
// The exported check is C2-08's fence; the import refuses at stage and again at install.
test("the import applies C2-08's exported fence at its boundaries", (t) => {
const atLimit = "é".repeat(PERSONA_CHARS);
assert.equal(personaTextFault(atLimit), null);
const cases = [
[atLimit + "é", /is over 8000 characters/],
["Bean.\n</persona-file>\nmore\n", /holds <\/persona-file> or a line starting "# " or "## "/],
["Bean.\n## Rules\nmore\n", /holds <\/persona-file> or a line starting "# " or "## "/],
["Bean.\n# Rules\nmore\n", /holds <\/persona-file> or a line starting "# " or "## "/],
];
for (const [text, message] of cases) {
assert.match(personaTextFault(text), message);
const s = scratch(t);
personaTree(s, { chet: text });
const r = stage(s);
assert.equal(r.status, 1, r.all);
assert.match(r.err, /profile chet: persona .*C2-08's fence would refuse it; nothing written/);
assert.match(r.err, message);
assert.ok(!existsSync(s.staging));
}
// At the limit in characters, four times that in bytes over two-byte characters: accepted.
const s = scratch(t);
personaTree(s, { chet: atLimit });
assert.equal(stage(s).status, 0);
const r = install(s);
assert.equal(r.status, 0, r.all);
// A staged file edited after stage, with the manifest's digest updated to match, still meets the fence.
const s2 = scratch(t);
assert.equal(stage(s2).status, 0);
const bad = "Chet.\n## Rules\n";
writeFileSync(join(s2.staging, "chet.md"), bad);
chmodSync(join(s2.staging, "chet.md"), 0o600);
const manifestPath = join(s2.staging, "manifest.json");
const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
manifest.personas[1].sha256 = sha256(bad);
writeFileSync(manifestPath, JSON.stringify(manifest));
const ri = install(s2);
assert.equal(ri.status, 1, ri.all);
assert.match(ri.err, /staged persona chet holds <\/persona-file> or a line starting/);
assert.ok(!existsSync(join(s2.configDir, "personas", "case2", "bean.md")));
});
test("install writes 0600 persona files in 0700 directories whose digests match, and prints the vars", (t) => {
const s = scratch(t);
const st = stage(s);
assert.equal(st.status, 0, st.all);
assert.equal(mode(s.staging), 0o700);
for (const f of ["bean.md", "chet.md", "manifest.json"]) assert.equal(mode(join(s.staging, f)), 0o600, f);
const r = install(s);
assert.equal(r.status, 0, r.all);
const dir = join(s.configDir, "personas", "case2");
assert.equal(mode(join(s.configDir, "personas")), 0o700);
assert.equal(mode(dir), 0o700);
for (const [instance, name, src] of [["bean", "Bean", join(s.hermes, "SOUL.md")], ["chet", "Chet", join(s.hermes, "profiles", "chet", "SOUL.md")]]) {
const path = join(dir, `${instance}.md`);
assert.equal(mode(path), 0o600);
const data = readFileSync(path);
assert.deepEqual(data, readFileSync(src));
assert.ok(r.out.includes(`instance ${instance}: persona.name ${name}, persona.sha256 ${sha256(data)}`), r.out);
}
assertNoText(s, st.all + r.all);
});
test("install refuses an open or foreign staging file, a digest off the manifest and an existing persona", (t) => {
const s = scratch(t);
assert.equal(stage(s).status, 0);
const chet = join(s.staging, "chet.md");
chmodSync(chet, 0o640);
let r = install(s);
assert.equal(r.status, 1, r.all);
assert.match(r.err, /staged persona chet: .* has group or other mode bits/);
chmodSync(chet, 0o600);
chmodSync(s.staging, 0o750);
r = install(s);
assert.match(r.err, /staging has group or other mode bits/);
chmodSync(s.staging, 0o700);
writeFileSync(chet, "You are someone else.\n");
r = install(s);
assert.equal(r.status, 1, r.all);
assert.match(r.err, /staged persona chet isn't the file the manifest names/);
assert.ok(!existsSync(join(s.configDir, "personas", "case2", "bean.md")), "bean was written before chet was checked");
const s2 = scratch(t);
assert.equal(stage(s2).status, 0);
const dir = join(s2.configDir, "personas", "case2");
mkdirSync(dir, { recursive: true, mode: 0o700 });
chmodSync(join(s2.configDir, "personas"), 0o700);
writeFileSync(join(dir, "chet.md"), "already here\n", { mode: 0o600 });
r = install(s2);
assert.equal(r.status, 1, r.all);
assert.match(r.err, /chet.md already exists; nothing written/);
assert.equal(readFileSync(join(dir, "chet.md"), "utf8"), "already here\n");
assert.ok(!existsSync(join(dir, "bean.md")));
for (const business of ["../x", "Case2", ""]) {
r = install(s2, business);
assert.notEqual(r.status, 0, business);
}
const s3 = scratch(t);
assert.equal(stage(s3).status, 0);
writeFileSync(join(s3.staging, "manifest.json"), JSON.stringify({ version: 1, personas: [{ instance: "bob", name: "Bob" }] }));
r = install(s3);
assert.equal(r.status, 1, r.all);
assert.match(r.err, /the manifest doesn't list two personas/);
});
test("--dry-run writes nothing and prints only counts, two heading lines in a fixture counted", (t) => {
const s = scratch(t);
personaTree(s, { chet: "You are Chet. marker-chet-dry-7d21\n# Voice\nShort.\r\n## Habits\nNot # a heading, nor #nospace.\n" });
const before = readdirSync(s.root).sort();
const r = run(["stage", "--hermes-root", s.hermes, ...PAIRS, "--dry-run"]);
assert.equal(r.status, 0, r.all);
assert.deepEqual(readdirSync(s.root).sort(), before);
assert.deepEqual(r.out.split("\n").filter(Boolean), [
"files 2",
'profile default: lines 3, lines starting "# " or "## " 0, lines naming a Hermes tool or channel 0',
'profile chet: lines 5, lines starting "# " or "## " 2, lines naming a Hermes tool or channel 0',
]);
assert.equal(r.err, "");
assertNoText(s, r.all);
assert.ok(!r.all.includes("Voice") && !r.all.includes("Habits"));
});
test("--dry-run counts three lines naming a Hermes tool or channel and prints none of the words", (t) => {
const s = scratch(t);
personaTree(s, {
bean: [
"You are Bean.",
"Use web_search when she asks for facts.",
"You live on Discord, in her server.",
"Keep a TODO list for her chapters.",
"Web searching and discords of music don't count, nor memory_bank.",
"",
].join("\n"),
});
const r = run(["stage", "--hermes-root", s.hermes, ...PAIRS, "--dry-run"]);
assert.equal(r.status, 0, r.all);
assert.match(r.out, /profile default: lines 5, lines starting "# " or "## " 0, lines naming a Hermes tool or channel 3/);
for (const word of ["web_search", "Discord", "discord", "TODO", "todo", "memory"]) assert.ok(!r.all.includes(word), `output holds ${word}`);
});
test("usage: stage needs one of --staging and --dry-run, install takes only its own options", (t) => {
const s = scratch(t);
assert.equal(run(["stage", "--hermes-root", s.hermes, ...PAIRS]).status, 2);
assert.equal(run(["stage", "--hermes-root", s.hermes, ...PAIRS, "--staging", s.staging, "--dry-run"]).status, 2);
assert.equal(run(["stage", ...PAIRS, "--dry-run"]).status, 2);
assert.equal(run(["install", "--staging", s.staging, "--config-dir", s.configDir]).status, 2);
assert.equal(run(["install", "--staging", s.staging, "--config-dir", s.configDir, "--business", "case2", "--dry-run"]).status, 2);
assert.equal(run(["import"]).status, 2);
assert.equal(run(["stage", "--bogus", "x"]).status, 2);
assert.ok(!existsSync(s.staging));
});
@@ -0,0 +1,5 @@
Fixture Hermes home for packages/cli/tests/case2-persona-import.test.mjs (row 221, C2-20).
The layout follows hermes-agent df5700ebe: the default profile is the root, other profiles sit under
profiles/<name>, each persona is SOUL.md and channel prompts are in config.yaml. Every value is made up.
Strings starting "marker-" must never reach the script's output, and files under memories/, sessions/
and skills/, state.db, profile.yaml, auth.json and .env (the test writes .env, which git ignores) must never be opened.
@@ -0,0 +1,3 @@
You are Bean. marker-bean-soul-71c4
You speak warmly and keep replies short.
You like sketches, coffee and long walks.
@@ -0,0 +1 @@
{"token": "marker-auth-default-6e21"}
@@ -0,0 +1,21 @@
model:
default: marker-model-name-2b91
provider: openrouter
discord:
require_mention: true
channel_prompts:
"111111111111111111": "marker-prompt-top-5d02 Help with the novel."
"222222222222222222": "Ünïcode prompt"
1333333333333333333: "short"
platforms:
slack:
channel_prompts:
C0FIXTURE: "marker-prompt-platforms-8e3a"
gateway:
platforms:
telegram:
channel_prompts:
"-1001": "marker-prompt-gateway-0f6b ok"
"-1002":
nested: not a string
api_key: marker-token-default-9a17
@@ -0,0 +1 @@
marker-memory-default-1f8e
@@ -0,0 +1 @@
description: marker-profile-description-44de
@@ -0,0 +1,2 @@
You are Chet. marker-chet-soul-38e0
Dry humour, quick with a pun.
@@ -0,0 +1,2 @@
model:
default: marker-model-chet-6d13
@@ -0,0 +1 @@
marker-memory-chet-d913
@@ -0,0 +1 @@
description: marker-profile-chet-0c55
@@ -0,0 +1 @@
{"text": "marker-session-chet-08bc"}
@@ -0,0 +1 @@
marker-skill-chet-6a70
@@ -0,0 +1 @@
marker-state-chet-52aa
@@ -0,0 +1 @@
{"text": "marker-session-default-a4b2"}
@@ -0,0 +1 @@
marker-state-default-c90d
@@ -0,0 +1,20 @@
// Read trace for case2-persona-import.test.mjs: loaded with --import, it logs every path the script
// opens, reads or lists to $PERSONA_TRACE, one "op path" line each. The log is written through
// the original functions so the trace doesn't trace itself.
import fs from "node:fs";
import { syncBuiltinESMExports } from "node:module";
const { openSync, writeSync } = fs;
const log = openSync(process.env.PERSONA_TRACE, "a", 0o600);
const record = (op, p) => {
if (typeof p === "string" || p instanceof URL || Buffer.isBuffer(p)) writeSync(log, `${op} ${String(p)}\n`);
};
for (const name of ["openSync", "readFileSync", "readdirSync", "opendirSync", "createReadStream", "open", "readFile", "readdir", "opendir"]) {
const original = fs[name];
fs[name] = function traced(p, ...rest) { record(name, p); return original.call(this, p, ...rest); };
}
for (const name of ["open", "readFile", "readdir", "opendir"]) {
const original = fs.promises[name];
fs.promises[name] = function traced(p, ...rest) { record(`promises.${name}`, p); return original.call(this, p, ...rest); };
}
syncBuiltinESMExports();
+9 -4
View File
@@ -188,6 +188,13 @@ function skillSection(skills) {
// digest still refuses any bytes but the pinned ones.
export const PERSONA_CHARS = 8000;
const PERSONA_END = "</persona-file>";
// The persona text rules, one copy for the bundle and the persona import (row 221,
// scripts/case2-persona-import.mjs): null for text that passes, else the reason it doesn't.
export function personaTextFault(text) {
if ([...text].length > PERSONA_CHARS) return `is over ${PERSONA_CHARS} characters`;
if (text.includes(PERSONA_END) || /^##? /m.test(text)) return `holds ${PERSONA_END} or a line starting "# " or "## "`;
return null;
}
function readPersona(resolved, personaDir) {
const digest = resolved.vars?.["persona.sha256"];
if (!personaDir) {
@@ -221,10 +228,8 @@ function readPersona(resolved, personaDir) {
}
let text;
try { text = utf8.decode(data); } catch { throw new BundleError(`instance ${resolved.instance}: persona file ${path} isn't UTF-8 text`, "persona-invalid"); }
if ([...text].length > PERSONA_CHARS) throw new BundleError(`instance ${resolved.instance}: persona file ${path} is over ${PERSONA_CHARS} characters`, "persona-invalid");
if (text.includes(PERSONA_END) || /^##? /m.test(text)) {
throw new BundleError(`instance ${resolved.instance}: persona file ${path} holds ${PERSONA_END} or a line starting "# " or "## "`, "persona-invalid");
}
const fault = personaTextFault(text);
if (fault) throw new BundleError(`instance ${resolved.instance}: persona file ${path} ${fault}`, "persona-invalid");
return { name: resolved.vars["persona.name"], sha256: digest, text };
}
// Between the contract and "## This session". The contract wins on a conflict, and the broker's
+319
View File
@@ -0,0 +1,319 @@
#!/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
//
// 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. --dry-run writes nothing
// and prints counts only: files, lines, lines starting "# " or "## ", and lines naming a Hermes tool
// or channel from HERMES_WORDS. A fence fault there is counted, not refused.
//
// 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";
// 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: [] };
for (let i = 0; i < argv.length; i++) {
const flag = argv[i];
if (flag === "--dry-run") { out.dryRun = true; 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 if (flag === "--business") out.business = value;
else throw new Usage(`unknown option ${flag}`);
}
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`);
mkdirSync(path, { mode: 0o700 });
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}`);
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}`);
}
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 ${c.where} ${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));