# 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 `/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/`, 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 `` 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 : `, as an instruction. **Item for Jason's go:** who runs stage on ``, 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 `` at the top level of config.yaml, else from `platforms.` or `gateway.platforms.` (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 yaml@2.9.0 --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 ``` ## Rebase onto 213's landing (sage-1925) 213 landed as 361ba2387, with the same `git patch-id --stable` as r4 2b3e67e36, f313e69b6f7e. The refactor tip was 62ddc96cc. I cherry-picked 2d7a99aee onto it under the new branch gosalyn/c2-20-r2 as 48f044ccc. The patch-id is eb094ae783e9 before and after. None of the 22 paths changed between 2b3e67e36 and 62ddc96cc. The fence export from sage-1836 Q1 was already in the commit: `personaTextFault` from packages/harness/src/bundle.mjs, called by the script. I checked the test list again on the new base by grepping tests and scripts/test-*.sh for each non-test path's basename. The new hits name other packages' package.json files or write lockfiles in temp dirs, so the 18 files stand. 231 landed after that as 29ce1e360. It touches only packages/library and the queue-231 packet, none of these 22 paths. Rerun on 48f044ccc, slot grant sage-1932, 10:18:09Z to 10:30:14Z, after a busy check. All 18 files ran whole, one `node --test` each, every rc 0: 419 pass, 0 fail, 0 cancelled, summed by awk. That's two more than the 417 above, both from the base. stack-release went from 195 to 196 and stack-upgrade from 20 to 21, and rows 238 and 240 changed those test files. Every other file has the count in the table above. The dry run printed the same three lines, rc 0.