Compare commits

..
Author SHA1 Message Date
jason.woltje ed41933168 row 235 (#1741): machine grant row b, raise binding, executor, reconcile, revoke and sweep, with N10 (Gizmoduck, round 1)
Steps 1-6 squashed onto 4ced5e63d (sage-1812, sage-1831), then moved onto 62ddc96cc after row 213
landed (sage-1922). broker.mjs keeps 213's two hunks; packages/cli/README.md is row 228's blob plus
the row 235 lines. Creative roles get no host grant: #hostDefinition refuses host-creative-role at the
raise and the step 1 recheck, and #hostAccess refuses it at host.run, keeping the record (mos-1163,
sage-1908). BUILD.md lists the drift and the tests.
2026-10-21 05:15:33 -05:00
37 changed files with 33 additions and 1562 deletions
-158
View File
@@ -1,158 +0,0 @@
# 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
```
## 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.
-96
View File
@@ -1,96 +0,0 @@
# Row 231 build notes, C2-01b survey escapes, list item lines and attachment embeds (#1733)
Brief: docs/plans/2026-10-20_case2-rows.md "C2-01b survey escapes, list item lines and attachment embeds",
blob 16847e94ae17. These are Webby's row 212 notes N1 to N3 (webby-0041), split out by sage-1517.
## Base
First built as 777f7358a on 212's first round bdf18272a. 212 landed as ee7b2f137 (sage-1886), so the
commit was cherry-picked onto refactor fefd54e13 under the new branch gosalyn/c2-01b-r2 as 3647a5a93.
`git patch-id --stable` is 45911915a9ee before and after. packages/library is the same in bdf18272a
and in 212's landed round a2c72ac95, so nothing the commit touches moved under it.
## What changed
- N1, `packages/library/src/survey.mjs`: JSON strings escape C0 controls but leave DEL, C1 and bidi
controls raw. The new `quote()` escapes those too, and folder names print through it.
- N2, `packages/library/src/reader.mjs`: front matter parsing records each block list item's line, and
a link in a block list carries its item's line instead of its key's.
- N3, `links.mjs` exports `isAttachment` through `index.mjs`. A wikilink or embed whose target's last
segment ends in an extension other than `.md` is an attachment. The reader neither resolves nor
indexes it, and it raises no problem. The survey prints a count of body links to attachments.
- `packages/library/README.md` describes all three, and the links, reader and survey tests cover them.
## Outside readers
`packages/cli/tests/stack-copy.test.mjs:38` checks only that `packages/library/src/index.mjs` exists, and
`scripts/mosaic:41` execs `survey.mjs`. No test pins a hash of a library file.
## Tests
On 3647a5a93, slot grant sage-1889, 09:20:01Z to 09:20:14Z, after a busy check. One `node --test` per
file, every rc 0.
| File | pass | fail |
|---|---|---|
| packages/library/tests/links.test.mjs | 7 | 0 |
| packages/library/tests/mapping.test.mjs | 7 | 0 |
| packages/library/tests/reader.test.mjs | 13 | 0 |
| packages/library/tests/render.test.mjs | 11 | 0 |
| packages/library/tests/survey.test.mjs | 6 | 0 |
| packages/cli/tests/stack-copy.test.mjs | 16 | 0 |
Total: 60 pass, 0 fail, 0 cancelled, summed by awk.
## Round 2, after webby-0070 (CHANGES) and sage-1900
Webby's round 1 review is agents/webby/work/queue-231/REVIEW-r1.md at 387f04dfd, rev 2774.
Ruling sage-1900, recorded here as asked. B2: the brief stays as approved (sage-1517, case2-rows.md
C2-01b N3). Only a `![[x.ext]]` embed can be an attachment. Plain wikilinks and front matter values keep
resolving, and an unresolved one is R4. README's line "A link to a `.canvas` file or an attachment is
R4" is back as it was at fefd54e13. B1: resolve first. An embed is an attachment only when it resolves
to nothing and its target has an extension other than `.md`. So `![[Dr.Quinn]]` with Dr.Quinn.md
indexed resolves, `![[cover.png]]` gives no problem, `![[missing-note]]` is R4 and `![[note.md]]`
resolves. My own design ruling after r11 (28213ac27) also made plain links attachments. The brief
narrowed that, and the brief governs this row.
Changes:
- `links.mjs`: `isAttachment` is true only for a body embed, `link.embed === true && !link.field`,
with the extension test as before. Its comment says it goes by name, so the reader asks it only
after resolving.
- `reader.mjs`: every link resolves first. An unresolved one is R4 unless `isAttachment` holds.
- `survey.mjs`: `quote()` also escapes U+061C (Webby N1). The attachment count now covers body embeds
only, since it uses `isAttachment`.
- Webby N2, my choice of Sage's two options: the survey keeps a count by name and the README says so,
"the count of body embeds whose target has an extension other than `.md`", and that `![[Dr.Quinn]]`
counts there even when Dr.Quinn.md exists. Checking against the survey's path list would mean a second
resolver in the survey, for a count that only guides the mapping.
- Tests. links: embeds only, with `[[cover.png]]`, `[[Board.canvas]]`, `[[Dr.Quinn]]`, a front matter
value and a markdown link all false, and `![[Dr.Quinn]]` true by name. reader: the Shore case now
gives R4 for the front matter `"![[portrait.JPG]]"` (line 3), for `[[Board.canvas]]` (line 5) and for
`![[missing-note]]` (line 6). The new Dr.Quinn case has Dr.Quinn.md indexed and resolves `[[Dr.Quinn]]` in
a block list and in the body, plus `![[Dr.Quinn]]`, with no problem. survey: a U+061C folder name
prints escaped, and the attachment count is 1, since `[[map.PDF]]` is a plain link.
Round 2 tests, one `node --test` per file, each run after a busy check.
Run 1, slot grant sage-1903, 09:39:32Z to 09:39:46Z: 60 pass, 1 fail. The new Dr.Quinn reader test failed
on order alone. All three links resolved to Dr.Quinn.md with the right field, line and embed values, and
there was no problem. The reader lists body links before front matter links, and my expected array put
the `relationships` link first. I stopped and reported it (gosalyn-0098).
Run 2, after the test-only fix sage-1907 approved, which moves that entry to the end of the expected array,
09:43:42Z to 09:43:56Z. Every rc 0.
| File | pass | fail |
|---|---|---|
| packages/library/tests/links.test.mjs | 7 | 0 |
| packages/library/tests/mapping.test.mjs | 7 | 0 |
| packages/library/tests/reader.test.mjs | 14 | 0 |
| packages/library/tests/render.test.mjs | 11 | 0 |
| packages/library/tests/survey.test.mjs | 6 | 0 |
| packages/cli/tests/stack-copy.test.mjs | 16 | 0 |
Total: 61 pass, 0 fail, 0 cancelled, summed by awk.
-149
View File
@@ -1,149 +0,0 @@
# Row 218 draft notes: claims and sources
Drafts for `docs/case2/SETUP-SHEET.md` and `docs/case2/SURVEY-RUN.md`. Repo base: `refactor` at
947101d8a, which holds 29ce1e360 (row 231). "Design" means
`git show 75fb3e10e:agents/gosalyn/work/case2/DESIGN.md`, cited as `D:<line>`. "Brief" means
`docs/plans/2026-10-20_case2-rows.md`. Code paths are relative to the repo root. Nothing was run
except reads, greps and `git merge-base --is-ancestor 29ce1e360 HEAD`.
## SETUP-SHEET.md
| Claim | Source |
|---|---|
| She keeps writing in Obsidian, obsidian-git pushes to git.mosaicstack.dev, nothing changes on her machine, nothing commits to her repo | D:939-940 (7.1) |
| Agents read what she pushed; a push reaches them in about a minute | D:1006-1007 (7.5); D:972-973 (60-second sync timer) |
| Agents read `.md` notes inside book folders only | D:457-461 (1.2); `packages/library/src/scope.mjs:9-19` |
| Not read: top-level notes, `.canvas`, `.base`, attachments, anything outside a book folder | D:461 |
| Other repos under `~/src` are not read | D:489 (1.3), D:494-497 (N4) |
| Agents can't write to her repo: canon, playground, anything | D:389 (D3); D:1008 |
| Folder roles live in the `projects` entry of the business file, hand-edited, agents can't change it | D:409-429, D:443-451 (1.1, M9) |
| Jason asks her to confirm the mapping first | D:431; D:1324-1328 (10 step 7) |
| A folder named in `playground` is her playground | Brief:22; D:434 |
| Each playground prefix sits inside one book folder | D:434-436; `packages/library/src/mapping.mjs:77-80` |
| One per book, e.g. `<book>/playground/`; until then everything mapped is canon | D:438-439 |
| Agents search canon unless they ask for the playground | D:463 |
| Playground may back an idea or question, not a fact, finding or correction; refused before she sees it | D:858 (`playground-cited-as-canon`); D:378-382, D:835-839 (guard runs before store) |
| Only the folder decides; a tag or property can't change scope | D:388 (D2); D:440-441; `scope.mjs:1` |
| Graph colour group via Groups and a `path:` query, her own vault setting, changes nothing for agents | Brief:23; D:941-943. Obsidian UI steps from the task prompt, not the design |
| Path starts with the books repo folder because the vault is `~/src` | D:939 |
| N2: moving a note into a book folder makes it canon, agents may cite it as fact | D:944-945; D:259 |
| Rough drafts outside the playground are canon | Inference from D:460 (scope rule) |
| N4: Obsidian resolves short links across `~/src`, the reader sees one repo, the two can differ, the reader can't tell | D:494-497; D:262-263 |
| A name only in another repo leads nowhere for agents | D:489 (R4); `packages/library/src/links.mjs:61-87` |
| A full-path link resolves the same in both | D:497-498. Hub side: `links.mjs:77-78` (`vault-prefix` step) |
| "New link format: Absolute path in vault" setting | Obsidian UI knowledge. Not in design or code. Reviewer may strike it |
| `appears_in` and `relationships` links are read as links | D:489-490; `packages/library/src/reader.mjs:15`, `:153-162` |
| Quoted flow list `["[[A]]", "[[B]]"]` parses | `reader.mjs:81-96` (flow), `:66-73` (quoted scalar), `:159` |
| Block list with quoted items parses | `reader.mjs:117-122`, `:66-73` |
| Unquoted `[[[A]], [[B]]]` is R3 | Brief:28-29; `reader.mjs:87` (bracket inside a flow list stops parsing), `:137` |
| R3 effect: whole front matter unread, note still indexed by path, name and body, listed as a problem | D:480; `reader.mjs:103-105`, `:193`; `packages/library/README.md:69` |
| Problems show in Mosaic's status | D:473-474; D:999-1000 (7.4 status line) |
| A value without `[[ ]]` is a name | D:489-490; `reader.mjs:159` |
| Bean and Chet keep names and personalities | D:660-661 (Q180) |
| Hermes: free text and story text. Mosaic: short cited items, a prose request gets a question or idea | D:778-781 (design 4) |
| Bean and Chet's item kinds: idea, question, finding, fact; web cites allowed | D:649 (`companion`); D:860 (`citation-unfetched`) |
| Limits 10 items, 5 ideas of 200, others 300 | D:819 (M5) |
| She or Jason can ask for lower limits | D:1329-1330 (10 step 8); D:831 |
| "No cited answer" notice | D:870-871, D:996-997 |
| One item can't mix two worlds | D:859 (`citation-cross-universe`) |
| She talks to them in the Console; Hermes keeps running until she moves over; stopping it is her choice | D:381-382; D:1331 |
| Five more helpers; she chooses the start set and can add one later | D:647-654; D:783-784; D:1327-1328 |
| What Jason will ask | D:1325-1327 (10 step 7) |
## SURVEY-RUN.md
| Claim | Source |
|---|---|
| Runs on `<host>` 10.1.1.54 under design 10 step 7, after step 4 | D:1324; D:1266 (host) |
| Prints names and counts, never note text, property values, file names or link targets | `packages/library/src/survey.mjs:1-6`; README:118-125 |
| Sending to Melanie is Jason's call | Brief:36; D:1324-1328 |
| Token slot `library-gitea` is step 4 | D:1314-1316 |
| Run as `sudo -u mosaic -H` from `localadmin`; `mosaic` has no login shell | D:1195-1197; D:1281-1282 |
| Copy at `/srv/mosaic/.local/share/mosaic-stack/copies/<id>`, `<id>` = commit in Jason's go | D:1286-1292 (2c); `docs/TOOLS.md:417`; home `/srv/mosaic` per D:1314-1315 |
| The copy carries `packages/library/src` | `scripts/stack-copy.sh:36` |
| Row 231 adds the control and bidi escapes | `git log` 29ce1e360; `survey.mjs:78-82`; README:121-122 |
| `<node dir>` is the `--node` directory of step 2c; `scripts/mosaic` execs `node` from PATH | D:1290-1291; `scripts/mosaic:41` |
| Dispatch: `mosaic library survey` goes to `survey.mjs` with `survey <args>` | `scripts/mosaic:37-41` (one `shift` leaves `survey` as argv[0]); `survey.mjs:102-103` |
| Command and flags: `<repo>` plus optional `--mapping <file>` once; anything starting with `-` refuses | `survey.mjs:16`, `:104-110` |
| No fetch; reads `HEAD` | `survey.mjs:5-6`, `:27`; `reader.mjs:22-24` |
| Top folder only: ceiling is the path's parent | `packages/project/src/git.mjs:12`, `:62` |
| Owner must be `mosaic`: git gets no global config | `packages/seat/src/checkout.mjs:79-89` (`GIT_CONFIG_GLOBAL=/dev/null`, `GIT_CONFIG_NOSYSTEM=1`). The dubious-ownership refusal is git's own behaviour |
| Bus mirror path `<dataRoot>/projects/<business>/<project>` | Brief:300; `packages/project/src/paths.mjs:34-40` |
| C2-04 and C2-03a not landed | `grep` of `packages/*/src` finds no library entry handling or `refs/heads/served`; `docs/guides/host-run-sheet.md:269` says "not landed" |
| Served ref `refs/heads/served` | D:963 (7.2); Brief:676 |
| Token kept out of URL, config, argv, logs | D:955-956 (7.2) |
| Mapping file is the entry alone; `remote`, `branch` required | `survey.mjs:114-116`; `mapping.mjs:38-47` |
| Output lines and fields | `survey.mjs:83-97`; data from `:26-76` |
| Folder lines list folders that directly hold an entry, full path | `survey.mjs:30-35` |
| Key names: short identifiers print, others as `(other key)` | `survey.mjs:17-20`, `:47` |
| `unresolved` equals the R4 count | `survey.mjs:72` |
| Scope counts cover all tree entries | `survey.mjs:66` |
| Refusal exit codes 0, 2, 3, 4 | `survey.mjs:99-124`; README:127-129 |
| Mapping checked before repo | `survey.mjs:112-121` |
| stderr format `library survey: <message>` | `survey.mjs:101`; `problems.mjs:3-5` |
| `(project-git)` suffix on git failures | `reader.mjs:18-20`; `paths.mjs:4-6` |
| `library-playground-outside-book` message | `mapping.mjs:79-80` |
| 100-second git timeout | `checkout.mjs:49`, `:52`; `git.mjs:68` |
| Exit 127 when `node` is missing | `scripts/mosaic:18`, `:41` (bash `exec` failure). Not run |
| Other errors rethrow as a stack trace | `survey.mjs:117`, `:123`, `:128-131` |
| Don't work around a refusal | AGENTS.md invariant 6 |
## Code and design disagreements
1. Design 1.1 shows the mapping nested in `"projects": {"books": {...}}` (D:412-429). The survey's
`--mapping` file must be the inner entry alone (`survey.mjs:114-116`). The run sheet follows the code.
2. Design 1.3 calls `vaultPrefix` part of the mapping (D:418). The code makes it optional
(`mapping.mjs:48`, README:30). Without it, a full vault path link, the N4 advice, resolves to
nothing in the hub (`links.mjs:73-87`: `root` fails, `vault-prefix` is skipped, `shortest` refuses a
`/`). The setup sheet's full-path advice holds only when Jason sets `vaultPrefix` to her books repo
folder name.
3. The task prompt calls the unquoted form "refused". R3 is not a refusal. It is a reported problem,
and the note stays indexed (D:473, D:480). The sheet says what happens instead.
## Open questions
1. Where `<repo>` comes from on .54. The survey reads a local repo's `HEAD` and never fetches. The bus
mirror needs C2-04 and C2-03a, neither landed. C2-03a moves `refs/heads/served`, not `HEAD`, so the
mirror's `HEAD` may name no commit and the survey would refuse with exit 2. The sync also needs a
checked mapping with at least one book (`mapping.mjs:59`, `:70`), and the mapping's values are
meant to come from this survey (D:431). Sage needs to rule: a placeholder mapping for the first
sync, a `HEAD` the sync sets, a `--commit` flag on the survey, or another source.
2. The brief says Obsidian's property editor writes `appears_in: ["[[A]]", "[[B]]"]` (Brief:28-29,
webby-0041 N4). I didn't read webby-0041 or check Obsidian's output. Both the flow and the block
quoted forms parse, so the sheet shows both and says only that the editor adds the quotes.
3. Whether `/srv/mosaic/.npm-global/bin/mosaic` exists on .54. The run sheet uses the copy's
`scripts/mosaic` by full path to avoid depending on a wrapper.
## Sage's rulings on the open questions (2026-10-21)
1. Survey source: the bus's bare mirror after the first `library.sync`, the only copy on the host
(design 2.1). The C2-03a brief now requires the mirror's `HEAD` to be a symbolic ref to
`refs/heads/served`, with a test (5c6f73ed7), so the survey reads the served commit with no ref flag.
2. Property list forms: the sheet shows both quoted forms, since both parse. Webby checks the claim
that Obsidian's property editor writes the quoted form.
3. The "New link format: Absolute path in vault" tip stays. It is an Obsidian setting, not a design
claim, and Webby may strike it.
4. The run sheet calls the copy's `scripts/mosaic` by full path. That holds whether or not a wrapper
is on `PATH`.
5. `vaultPrefix`: the run sheet's step 4 tells Jason to set it, because the setup sheet's full-path
link advice depends on it.
## Round 2 (2026-10-21), from Webby's round 1 notes (webby-0075, rev 2848)
- N1, by Mos's ruling mos-1165 (fix before landing): SETUP-SHEET.md now gives the 1,200 character
total of item text per reply (design M5, step 8). The word "Today" is gone. No code enforces these
caps until C2-06 lands, so my ruling is that the sheet goes to Melanie only after C2-06 lands, and
then only on Jason's go (#419). The wording stays as is, because by then it holds.
- N2: SURVEY-RUN.md says tab, newline, carriage return, backspace and form feed print as `\t`, `\n`,
`\r`, `\b` and `\f`, and other control and bidi characters as `\uXXXX` (survey.mjs quote()).
- N3: SURVEY-RUN.md has a refusal row for `library-commit:` (reader.mjs listTree). survey.mjs turns
every LibraryError into exit 2 with its message.
- N4: SURVEY-RUN.md's new step 2 checks `command -v node` under the same sudo and PATH before the
survey runs.
## Round 3 (2026-10-21), from Webby's round 2 note N5 (webby-0077, rev 2856)
- N5: the library-commit row no longer says a moved `HEAD` causes it. The survey reads `HEAD` once,
and listTree raises library-commit only for an id that isn't 40 hex or an object that isn't a
commit (reader.mjs:29, :31). In practice that means a SHA-256 repository, so a rerun doesn't help.
The row now says "for example in a SHA-256 repository" and "Report to Sage".
-2
View File
@@ -1408,5 +1408,3 @@ are never rewritten or removed; corrections are new entries.
2026-10-21T08:37:47Z | Filbert (Opus 5.5, 048f3c63) | row 215 (C2-04) manifest r2 review, herb/row215 2522025af | APPROVE, static: B1-B3 and N1-N2 resolved per sage-1860/1862; build notes on lock file creation, mirror metadata allowlist and flock dependency; sent to Herb and Sage 08:37Z
2026-10-21T08:52:06Z | Darkwing (Opus 5.5, 7525c8e4) | row 195 (#1693) build and manifest round 1 in review (rev 2756, request comment 29985, results 29987) | darkwing/row195-b 37171f65b on 4ced5e63d (ddf7dce6c build: scripts/case1-accept.sh, case1-accept.test.mjs, TOOLS.md entry, DESIGN r7 doc fixes per sage-1817; 37171f65b BUILD.md results); slot: single 22/22 (sage-1829), 12/12 mutants killed and gate cli 662/0 plus scripts 429/0 selftests (sage-1865), green per sage-1870; Duckworth reviews (sage-1872); Gosalyn 0087 Q4/Q5 match W10/W12; flock goes to DEFERRED with 215 (sage-1871); nothing touched .53
2026-10-21T09:20:46Z | Sage (Opus 5.5, 1ef1e4f8) | 212+197 combined landing gate (node 981/0, selftests 429/0, 08:51-09:03Z); 197 lane proof stopped after two failures (try 1 lane-image base inspect, N7; try 3 solo-a process.test.mjs:82 under umask 077), recovered cf48efccf083; mos-1162: row 212 landed alone as ee7b2f137 (paths match a2c72ac95, patch-id ea84d3e35470), #1713 closed 29996, revs 2759-2760; DEFERRED N7 plus brief docs/plans/2026-10-21_gate-env-rows.md at fefd54e13, rows 243 (chmod) and 244 (fixture PI_AUTH_FILE) revs 2761-2762; row 66 rebuilt as sage/row66-r4b 49ad61963 on fefd54e13 (manifest 99cab8275af4, not yet requested); slots: Herb 215 focused 26/0, Gosalyn 231 singles 60/0, Herb 197 r6 focused granted; kept root ga-8I3iZc removed (sage-1827..1893) | in progress
2026-10-21T10:12:45Z | Darkwing (Opus 5.5, 7525c8e4) | row 245 (#1693) built, slot asked (darkwing-0105) | claim rev 2795; darkwing/row245 0590a29fc pushed, on 62ddc96cc: 5273de0b7 cherry-pick of 1e3c4767c (case1-accept.sh, test, TOOLS.md blobs identical) and 0590a29fc notes to queue-245/BUILD.md (DESIGN.md:336-338 superseded by gosalyn-0087, unedited, sage-1877); slot scripts in /mnt/storage/scratch/darkwing-245-out pinned to 0590a29fc, nothing run; told Sage the untracked queue-195 DESIGN.md was r2 (fcbb4aa9b = 818b79696), nothing lost; flagged 245 and 246 both closing #1693
2026-10-21T11:28:49Z | Sage (Opus 5.5, 1ef1e4f8) | 235 landing gate (bus 596/0, business 200/0, cli 666/0, scripts green) and landing b1fece6f0, #1741 closed 30026; 197 r6 D5 proof stopped at solo-a script-release (Compose run has no --no-build, reproduced in lane env), #1695 30031; 221 landed before 197 r7 (sage-1954): gate 11:07-11:19Z (test 11/0, harness 140/0, cli 677/0, scripts green), 300766450, #1722 closed 30033, revs 2812-2813; C2-20b brief and DEFERRED N3 in 335bb3e30, row 247 #1749 briefed; 197 r7 9c3166915 focused run on my slot (red 9/2, green 11/0, mutant killed), request rev 2819 (30036), proof candidates A 2be6d07fa and B cceb258ff ready; slots Herb sage-1956, Darkwing 245 sage-1961 (sage-1894..1962) | in progress
-125
View File
@@ -1,125 +0,0 @@
# Your books in Mosaic: a setup sheet
This sheet is for you, Melanie. You keep writing in Obsidian the way you do now. Mosaic reads your
books repo after obsidian-git pushes it to git.mosaicstack.dev. It changes nothing on your machine and
commits nothing to your repo.
## What the agents read
The agents read your books repo as you last pushed it. A push reaches them in about a minute.
They read notes, the `.md` files, inside your book folders. They don't read:
- notes at the top of the repo, outside any book folder
- `.canvas` and `.base` files, even inside a book folder
- images and other attachments
- anything in your other repos under `~/src`
They can't write to your repo at all. That covers canon, the playground and everything else.
Which folders are book folders, and which hold characters, places, research, your tracker or chapter
summaries, is set in one place. It is the `projects` entry of your business file in Mosaic. Jason
edits it by hand, and the agents can't change it. He will ask you to confirm it before the agents start.
## Your playground
A folder named in the `playground` list of that entry is your playground. Each playground folder sits
inside one book folder, so one per book works well, for example `<book>/playground/`. You choose the
names and tell Jason. Until you have one, every note in a book folder is canon.
What the playground means for the agents:
- They search canon unless they ask for the playground on purpose.
- An agent may quote a playground note in an idea or a question. It may not use one to back a fact,
a finding or a correction. Mosaic refuses that reply before you see it.
- Only the folder decides. A tag or a property can't make a note playground or canon.
### Colour the playground in your graph
This is a setting of your own vault. It changes nothing for the agents.
1. Open the graph view and open its settings panel.
2. Under Groups, choose New group.
3. In the query box, type the playground's path with `path:`. Your vault is `~/src`, so the path
starts with your books repo folder: `path:"<books repo folder>/<book>/<playground folder>"`.
4. Pick a colour.
Add one group per playground folder.
## Two things you might not expect
### Moving a note into a book folder makes it canon
The folder decides, so a move changes what the agents may do with a note. Once you move a note out of
the playground into a book folder and push, it is canon. The agents may then cite it as fact. A note
that isn't settled yet belongs in the playground. Any note in a book folder outside the playground is
canon, rough drafts included.
### A short link can point somewhere else for the agents
Obsidian looks up a short link like `[[Mara]]` across your whole vault, `~/src`. The agents see only
your books repo. Say a note called `Mara` exists in your books repo and also in another of your repos.
Obsidian and the agents can then open different notes for the same link, and the agents can't tell.
If the name exists only in the other repo, the link leads nowhere for the agents.
A link with the full path from the top of your vault reaches the same note in both:
```
[[<books repo folder>/<book>/<characters folder>/Mara]]
```
Obsidian can write new links this way. In Settings, open Files and links and set New link format to
Absolute path in vault.
## Lists of links in properties
The agents read the links in two properties, `appears_in` and `relationships`. Put quotes around each
link in the list:
```yaml
appears_in: ["[[A]]", "[[B]]"]
```
One link per line works too:
```yaml
appears_in:
- "[[A]]"
- "[[B]]"
```
Obsidian's property editor adds the quotes for you. If you type the list by hand, don't leave them out:
```yaml
appears_in: [[[A]], [[B]]]
```
The agents' reader can't read that form. It then ignores every property of that note and lists the
note as a problem in Mosaic's status. It still finds the note by its path, its name and its text. A
value written without `[[ ]]` counts as a plain name, not a link.
## What changes for Bean and Chet
They keep their names and personalities. How they answer changes.
In Hermes they answer in free text and may write story text when you ask. In Mosaic every reply is a
short list of items. Each item is an idea, a question, a finding or a fact. Each one quotes a line
from your repo or from a web page the agent read for you.
They don't write passages. If you ask for prose, you get a question or an idea instead.
A reply holds at most 10 items and at most 1,200 characters of item text in all. It has at most 5
ideas of up to 200 characters each. A question, finding or fact runs up to 300 characters. You or
Jason can ask for lower limits. When an agent can't back an answer with a quote, you see "No cited
answer" instead.
Each book world stays separate. One item can't mix quotes from two worlds.
You talk to them in the Console. Hermes keeps running until you have moved over, and stopping it is
your choice.
Five more helpers can join them: ideas, research, proofreader, fact-checker and continuity. You
choose which ones to start with, and you can add one later.
## What Jason will ask you
He will ask you to confirm your book folders and type folders, which books share a world, the name of
each playground folder, and which helpers to start with and when.
-122
View File
@@ -1,122 +0,0 @@
# Survey run sheet for the library host
This sheet is for whoever runs `mosaic library survey` on `<host>`, 10.1.1.54, under design 10 step
7. The survey reads one commit of a git repo and prints names and counts. It never prints note text,
property values, file names or link targets. Jason fills in the mapping for Melanie's books repo
from its output and her answers.
Out of scope: sending anything to Melanie. Design 10 step 7 makes that Jason's call.
## Before you start
- Jason's go for step 7. Step 4, the bot token in the `library-gitea` slot, comes first.
- You run as `mosaic` with `sudo -u mosaic -H` from `localadmin`. `mosaic` has no login shell.
- The stack copy from step 2 is in place at `/srv/mosaic/.local/share/mosaic-stack/copies/<id>`.
`<id>` is the full commit id in Jason's go. It must hold row 231, which escapes control and bidi
characters in folder names. In a canonical checkout, `git merge-base --is-ancestor 29ce1e360 <id>`
exits 0 when it does.
- `<node dir>`, the directory that holds `node`. It is the directory of the `--node` path given to
`case2-unit.sh` in step 2c. `scripts/mosaic` runs `node` from `PATH`.
- `<repo>`, an absolute path. See the next section.
## The repo to survey
The survey reads a local repo. It doesn't fetch. `<repo>` must:
- be the repo's top folder, or a bare repo's own folder. A folder inside a repo refuses, because the
survey stops git from looking above the path's parent.
- belong to `mosaic`. The survey gives git no global config, so a `safe.directory` entry can't
admit a repo another user owns.
- have a `HEAD` that names a commit. The survey reads `HEAD` and has no flag for another ref.
`<repo>` is the bus's bare mirror at `<dataRoot>/projects/<business>/<project>`, which C2-04 (row 215)
creates and C2-03a (row 222) fetches into. It is the only copy of her repo on the host (design 2.1), and
`mosaic` owns it. Survey it after the first `library.sync` succeeds. The mirror's `HEAD` points at
`refs/heads/served` (C2-03a brief), so the survey reads the commit the agents are served.
Don't survey anything else. A hand clone with the bot token would put the token where design 7.2 keeps
it out, and the mirror's refs belong to the bus. If `HEAD` names no commit, stop and report to Sage.
## Steps
1. `ssh [email protected]`.
2. Check which `node` runs. `PATH` ends in `/usr/bin:/bin`, so a wrong `<node dir>` would quietly run
another `node`:
```sh
cd /
sudo -u mosaic -H env PATH="<node dir>:/usr/bin:/bin" sh -c 'command -v node; node --version'
```
The first line must be `<node dir>/node`. If it isn't, fix `<node dir>` before going on.
3. Run the survey without a mapping:
```sh
cd /
sudo -u mosaic -H env PATH="<node dir>:/usr/bin:/bin" \
/srv/mosaic/.local/share/mosaic-stack/copies/<id>/scripts/mosaic library survey <repo>
echo "exit $?"
```
4. On exit 0, give the output to Jason. On any other exit, see "Refusals" below.
5. When Jason has a draft mapping, run it again with `--mapping`. The file holds the library entry
alone, `{"kind": "library", "remote": ..., "branch": ..., ...}`, without the business file or the
`"projects"` wrapper around it. `remote` and `branch` are required even though the survey doesn't
fetch. `mosaic` must be able to read the file. Set `vaultPrefix` to her books repo's folder name
in her vault. Without it, a link with the full path from the top of her vault resolves to nothing,
and the setup sheet tells her to write links that way.
```sh
sudo -u mosaic -H env PATH="<node dir>:/usr/bin:/bin" \
/srv/mosaic/.local/share/mosaic-stack/copies/<id>/scripts/mosaic library survey <repo> --mapping <file>
echo "exit $?"
```
The usage line is `mosaic library survey <repo> [--mapping <file>]`. `--mapping <file>` is the only
flag, and it may appear once. `--mapping=<file>` and every other flag refuse.
## What the output holds
Plain text on stdout, one line per item:
| Line | What it holds |
|---|---|
| `commit <id>` | the 40-hex commit `HEAD` names |
| `entries <n>` | every entry in that commit's tree |
| `folders (direct children: md canvas base other)` | then one line per folder that holds a file directly: its path as a JSON string and four counts. `(root)` is the repo's top |
| `markdown <n> files, <n> read, ...` | `.md` files, how many were read, how many have no front matter, how many have front matter the reader can't fully parse |
| `front matter key sets` | then `<count> [<keys>]` per set of property names. A name that isn't a short identifier prints as `(other key)` |
| `body links plain .., label .., heading .., embed .., self .., markdown ..; attachment ..` | link forms in note bodies, and embeds of a target with an extension other than `.md` |
| `field appears_in wikilink .., name ..` | values written as links and as plain names. A `relationships` line follows |
With `--mapping` three lines follow:
| Line | What it holds |
|---|---|
| `scopes canon .., playground .., unmapped ..; indexed ..` | tree entries per scope, and notes indexed |
| `resolved root .., vault-prefix .., relative .., shortest .., unresolved ..` | links per resolver step. `unresolved` is the R4 count |
| `problems R1 .., ..., R7 ..` | problems per rule |
Folder paths and short property names are the only words from her repo in the output. In a folder
name, tab, newline, carriage return, backspace and form feed print as `\t`, `\n`, `\r`, `\b` and `\f`.
Any other control or bidi character prints as `\uXXXX`.
## Refusals
A refusal prints nothing on stdout and one line on stderr. The survey checks the mapping before the
repo, so a bad mapping gives exit 3 even when the repo path is wrong too.
| Exit | stderr | Cause | What to do |
|---|---|---|---|
| 4 | `library survey: usage: mosaic library survey <repo> [--mapping <file>]` | no repo, two repos, an unknown flag, `--mapping` without a file or twice | Fix the command |
| 3 | `library survey: library-mapping-invalid: <file> is not a readable JSON file` | missing, unreadable by `mosaic`, or not JSON | Fix the file or its mode |
| 3 | `library survey: library-mapping-invalid: <file> <reason>` | the entry breaks the schema. The reason names the key | Give it to Jason for the draft |
| 3 | `library survey: library-playground-outside-book: <file> universes.<u>.playground "<prefix>" is not inside one of its books` | a playground prefix outside its universe's books | Give it to Jason for the draft |
| 2 | `library survey: library-git: <repo> is not a directory` | wrong path | Check `<repo>` |
| 2 | `library survey: library-git: repository has no readable HEAD commit (project-git)` | not a repo, a folder inside one, another owner, no commits, a `HEAD` with no commit, or no `git` on `PATH` | Check each. If `<repo>` is the bus mirror, stop and report to Sage |
| 2 | `library survey: library-git: <other reason>`, such as `tree listing refused (project-git)` | git failed or ran past 100 seconds | Report to Sage |
| 2 | `library survey: library-commit: <reason>` | `HEAD` names something the reader can't take as a commit, for example in a SHA-256 repository | Report to Sage |
| 127 | a line ending `exec: node: not found` | `node` isn't in `<node dir>` | Fix `<node dir>` |
| other | a Node stack trace | a fault in the survey | Stop and report the exit code to Sage |
A refusal is the answer, not an obstacle. Don't run as another user, change modes on her repo or edit
its refs to get past one.
-52
View File
@@ -593,54 +593,6 @@ gate, along with every `scripts/test-*.sh`.
---
## C2-20b persona import follow-up: printed keys, the dry run fence check and the mkdir race
### Problem
Webby's round 1 review of row 221 (webby-0073, REVIEW-r1 a0a00630e on webby/reviews) approved it with
four notes that don't block. Three are in `scripts/case2-persona-import.mjs` and go in before Jason's
import run, because he runs the script once (sage-1941):
- N1: lines :147 and :237 print channel and platform keys from `config.yaml` as they are. A key with a
newline or a control character could forge an output line.
- N2: the dry run counts but doesn't apply C2-08's 8000-char fence. A persona over the fence and under
32000 bytes passes the dry run and then refuses at staging.
- N4: `privateDir` runs lstat, then mkdir. A directory made between the two gives a raw EEXIST. It still
fails closed, so only the error text is wrong.
N3 (Morgana's 213 `mutants.py:48` B3 pattern no longer matches `bundle.mjs`) isn't in this row. It's in
DEFERRED.
### Owner and reviewer
- Owner: Gosalyn.
- Reviewer: Webby.
- After: row 221 (C2-20) lands.
### Files owned
`scripts/case2-persona-import.mjs`, `packages/cli/tests/case2-persona-import.test.mjs` and its fixture
tree.
### What ships
- N1: every printed config key goes through `JSON.stringify`.
- N2: the dry run calls `personaTextFault` on each persona and prints the fault's name. It still prints
no persona text, no line and no line number.
- N4: an EEXIST from the mkdir refuses with a named reason, not a raw EEXIST.
Tests:
- A fixture key with a newline prints as one quoted line.
- A persona over the fence makes the dry run print the fault, write nothing and exit nonzero.
- The mkdir race refuses with the named reason. A stubbed mkdir that throws EEXIST is enough.
- One mutant for each of the three fixes, each killed by its test.
### Gate
Webby approves the manifest. The test file runs green on its own, and the three mutants are killed on
Sage's slot. The cli suite and every `scripts/test-*.sh` are green on Sage's gate.
---
## C2-03a fetch-only sync
### Problem
@@ -675,10 +627,6 @@ Design 7.2's slice:
- An `https://` URL only.
- The served ref `refs/heads/served`, and `library-diverged`, held until a human `library.sync --accept
<commit>`.
- The mirror's `HEAD` is a symbolic ref to `refs/heads/served`, so `mosaic library survey <mirror>` reads
the served commit. The survey reads `HEAD` and has no ref flag, and design 10 step 7 surveys this mirror
(C2-12 run sheet; Sage, 2026-10-21). Test: after the first sync, `git symbolic-ref HEAD` in the mirror
prints `refs/heads/served` and the survey's `commit` line equals the served commit.
- The sync state file, written atomically inside the mirror, and the 60-second timer.
- `library.sync`, human only. A test reaches a local bare repo only through the module parameter that
replaces `syncCommand`.
-7
View File
@@ -315,13 +315,6 @@ at every gate. Started 2026-09-12 during the control board MVP.
`mosaic`, and F10 uses equality. Darkwing has a follow-up on
`darkwing/row195-n1` 1e3c4767c, not yet run; it gets its own row now
that 195 has landed (b65014647), Duckworth reviews. (2026-10-21)
- **Morgana's row 213 mutation set: the B3 pattern no longer matches `bundle.mjs`.**
(Webby N3 on row 221 round 1, REVIEW-r1 a0a00630e.) Row 221's
candidate changes `packages/harness/src/bundle.mjs`, and the B3
anchor at Morgana's 213 `mutants.py:48` stops matching on that
base. This matters only if someone reruns that set. Fix the anchor
in that tooling before any rerun, and check that a stale anchor
refuses instead of skipping the mutant (sage-1941). (2026-10-21)
## Queue
+1 -2
View File
@@ -10,8 +10,7 @@
"license": "UNLICENSED",
"dependencies": {
"@earendil-works/pi-coding-agent": "0.85.1",
"oauth4webapi": "3.8.8",
"yaml": "2.9.0"
"oauth4webapi": "3.8.8"
}
},
"node_modules/@anthropic-ai/sdk": {
+1 -2
View File
@@ -6,7 +6,6 @@
"license": "UNLICENSED",
"dependencies": {
"@earendil-works/pi-coding-agent": "0.85.1",
"oauth4webapi": "3.8.8",
"yaml": "2.9.0"
"oauth4webapi": "3.8.8"
}
}
@@ -1,336 +0,0 @@
// 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));
});
@@ -1,5 +0,0 @@
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.
@@ -1,3 +0,0 @@
You are Bean. marker-bean-soul-71c4
You speak warmly and keep replies short.
You like sketches, coffee and long walks.
@@ -1 +0,0 @@
{"token": "marker-auth-default-6e21"}
@@ -1,21 +0,0 @@
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
@@ -1 +0,0 @@
marker-memory-default-1f8e
@@ -1 +0,0 @@
description: marker-profile-description-44de
@@ -1,2 +0,0 @@
You are Chet. marker-chet-soul-38e0
Dry humour, quick with a pun.
@@ -1,2 +0,0 @@
model:
default: marker-model-chet-6d13
@@ -1 +0,0 @@
marker-memory-chet-d913
@@ -1 +0,0 @@
description: marker-profile-chet-0c55
@@ -1 +0,0 @@
{"text": "marker-session-chet-08bc"}
@@ -1 +0,0 @@
marker-skill-chet-6a70
@@ -1 +0,0 @@
marker-state-chet-52aa
@@ -1 +0,0 @@
{"text": "marker-session-default-a4b2"}
@@ -1 +0,0 @@
marker-state-default-c90d
@@ -1,20 +0,0 @@
// 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();
+4 -9
View File
@@ -188,13 +188,6 @@ 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) {
@@ -228,8 +221,10 @@ 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"); }
const fault = personaTextFault(text);
if (fault) throw new BundleError(`instance ${resolved.instance}: persona file ${path} ${fault}`, "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");
}
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
+2 -12
View File
@@ -57,8 +57,7 @@ environment and ceiling directory apply. A failed git call refuses `library-git`
It returns `{ commit, files, problems }`, deep-frozen and sorted. Each file has its path, scope,
universe, book, type, numbered lines, `bodyLine`, parsed `frontmatter` or `null`, `frontmatterRaw`
and `links`. Front matter `appears_in` and `relationships` wikilinks count as links. A link in a block
list carries its list item's line. One in a scalar or flow value carries its key's line.
and `links`. Front matter `appears_in` and `relationships` wikilinks count as links.
Problems carry `{ code, path, line, message }`:
@@ -88,11 +87,6 @@ then with `.md` added:
It resolves only against indexed files. A link to a `.canvas` file or an attachment is R4. A link into
another repo resolves to nothing. `extractLinks` skips fenced and inline code.
A `![[...]]` embed resolves like any link. A body embed that resolves to nothing and whose target ends
in an extension other than `.md`, in any case, embeds an attachment, and it is never R4. An extension is
a dot and up to 8 letters and digits, at least one a letter. Any other unresolved embed is R4, and so is
an unresolved front matter value written as an embed (C2-01b N3, sage-1900).
## renderItems
`renderItems(body, { scopeOf, changed })` turns a reply body into the plain text both channels show.
@@ -116,11 +110,7 @@ mosaic library survey <repo> [--mapping <file>]
```
It reads the repo's HEAD commit and prints folder names, file counts by extension, front matter key
sets, link-form counts and the count of body embeds whose target has an extension other than `.md`,
which still count as embeds. That count goes by the name alone, so an embed of `Dr.Quinn.md` written
`![[Dr.Quinn]]` counts there too. Folder names print as JSON strings, with DEL, C1 and bidi controls,
U+061C among them, also as `\uXXXX`. It never prints file text, front matter values, file names or
link targets.
sets and link-form counts. It never prints file text, front matter values, file names or link targets.
A key that is not a short identifier prints as `(other key)`, since it could be her words. With
`--mapping` it adds scope counts, resolver step counts and problem counts by rule.
+1 -1
View File
@@ -2,7 +2,7 @@
export { LibraryError, RULES, problem, sortProblems } from './problems.mjs';
export { checkMapping, TYPES } from './mapping.mjs';
export { classify } from './scope.mjs';
export { extractLinks, parseWikilink, resolver, formOf, isAttachment } from './links.mjs';
export { extractLinks, parseWikilink, resolver, formOf } from './links.mjs';
export { readLibrary, listTree, readTexts, parseFile, parseFrontMatter, headCommit, MAX_BLOB, LINK_FIELDS } from './reader.mjs';
export { renderItems, KINDS, NOTICES, UNREADABLE } from './render.mjs';
export { survey, formatSurvey, surveyMain } from './survey.mjs';
+1 -11
View File
@@ -1,8 +1,6 @@
// Wikilinks and the link resolver of design 1.3. Links are [[target]], [[target|label]] and
// [[target#heading]], with ![[...]] embeds read the same way. Links inside fenced code blocks and
// inline code spans are not links, as in Obsidian. A body embed that resolves to no indexed note and whose
// target ends in an extension other than `.md`, in any case, embeds an attachment (C2-01b N3): the
// reader doesn't index it, and it raises no problem.
// inline code spans are not links, as in Obsidian.
import { posix } from 'node:path';
const WIKILINK = /(!?)\[\[([^\[\]\n]+?)\]\]/g;
@@ -25,14 +23,6 @@ function parts(inner, embed) {
embed,
};
}
// Whether a body embed's target ends in an extension other than `.md`. An extension is a dot and up
// to 8 letters and digits, at least one a letter, at the end of the target's last segment, so
// `Chapter 1.5` and `Mr. Smith` have none and `cover.png` has one. `Dr.Quinn` has one too, so the
// reader asks this only of an embed that resolves to nothing. A plain link or a front matter value is
// never an attachment (sage-1900).
export const isAttachment = link => link.embed === true && !link.field
&& /\.(?=[a-z0-9]*[a-z])[a-z0-9]{1,8}$/i.test(link.file.slice(link.file.lastIndexOf('/') + 1))
&& !/\.md$/i.test(link.file);
// The form a link is counted under in the survey.
export const formOf = link => link.markdown ? 'markdown' : link.embed ? 'embed' : !link.file ? 'self'
: link.label !== null ? 'label' : link.heading !== null ? 'heading' : 'plain';
+10 -14
View File
@@ -5,7 +5,7 @@
// of the commit; the affected file or link is left out of the index.
import { git, gitBytes } from '../../project/src/git.mjs';
import { classify } from './scope.mjs';
import { extractLinks, isAttachment, parseWikilink, resolver } from './links.mjs';
import { extractLinks, parseWikilink, resolver } from './links.mjs';
import { LibraryError, problem, raise, sortProblems } from './problems.mjs';
export const MAX_BLOB = 1024 * 1024;
@@ -103,13 +103,12 @@ function value(raw) {
// Front matter of design 1.3 (R3): scalars, quoted strings, flow lists and block lists of scalars or
// wikilinks. Anything else, a nested map, a block scalar or an anchor for example, leaves the whole
// front matter as raw text with one R3 problem at the line where parsing stopped.
// Returns { frontmatter, raw, bodyLine, stop } with 1-based line numbers. Parsed front matter also
// returns `itemLines`, each block list key's item lines in order.
// Returns { frontmatter, raw, bodyLine, stop } with 1-based line numbers.
export function parseFrontMatter(lines) {
if (lines[0]?.replace(/^/, '').trimEnd() !== '---') return { frontmatter: null, raw: null, bodyLine: 1, stop: null };
const close = lines.findIndex((text, i) => i > 0 && /^(---|\.\.\.)\s*$/.test(text));
if (close < 0) return { frontmatter: null, raw: null, bodyLine: 1, stop: 1 };
const raw = lines.slice(1, close).join('\n'), data = {}, itemLines = {};
const raw = lines.slice(1, close).join('\n'), data = {};
let list = null;
for (let i = 1; i < close; i++) {
const text = lines[i], line = i + 1;
@@ -119,7 +118,6 @@ export function parseFrontMatter(lines) {
const parsed = scalar(item[2] ?? '');
if (!parsed.ok) return { frontmatter: null, raw, bodyLine: close + 2, stop: line };
data[list].push(parsed.value);
itemLines[list].push(line);
continue;
}
const pair = /^([^\s:#\-][^:]*?|"[^"]+"|'[^']+')\s*:(?:\s+(.*))?$/.exec(text);
@@ -128,7 +126,7 @@ export function parseFrontMatter(lines) {
if (Object.hasOwn(data, key) || key === '__proto__') return { frontmatter: null, raw, bodyLine: close + 2, stop: line };
if (pair[2] === undefined || !pair[2].trim()) {
const next = lines[i + 1];
if (i + 1 < close && /^\s*-( |$)/.test(next)) { data[key] = []; itemLines[key] = []; list = key; }
if (i + 1 < close && /^\s*-( |$)/.test(next)) { data[key] = []; list = key; }
else { data[key] = null; list = null; }
continue;
}
@@ -137,14 +135,13 @@ export function parseFrontMatter(lines) {
if (!parsed.ok) return { frontmatter: null, raw, bodyLine: close + 2, stop: line };
data[key] = parsed.value;
}
return { frontmatter: data, raw: null, bodyLine: close + 2, stop: null, itemLines };
return { frontmatter: data, raw: null, bodyLine: close + 2, stop: null };
}
export const splitLines = text => text.replace(/^/, '').split('\n').map(l => l.endsWith('\r') ? l.slice(0, -1) : l);
// One parsed file: its lines (all of them, numbered from 1), front matter and the links it holds.
// Front matter links come from LINK_FIELDS values that are wikilinks; other values are names. A
// link in a block list carries its item's line, one in a scalar or flow value its key's line.
// Front matter links come from LINK_FIELDS values that are wikilinks; other values are names.
export function parseFile(text, { markdown = false } = {}) {
const lines = splitLines(text);
const fm = parseFrontMatter(lines);
@@ -154,11 +151,10 @@ export function parseFile(text, { markdown = false } = {}) {
for (const field of LINK_FIELDS) {
const raw = fm.frontmatter[field];
const values = Array.isArray(raw) ? raw : raw === undefined || raw === null ? [] : [raw];
const items = fm.itemLines[field];
values.forEach((v, i) => {
for (const v of values) {
const link = typeof v === 'string' ? parseWikilink(v.trim()) : null;
if (link) links.push({ ...link, field, line: items ? items[i] : frontMatterLine(lines, fm.bodyLine, field) });
});
if (link) links.push({ ...link, field, line: frontMatterLine(lines, fm.bodyLine, field) });
}
}
}
return { lines: numbered, bodyLine: fm.bodyLine, frontmatter: fm.frontmatter, frontmatterRaw: fm.raw, stop: fm.stop, links };
@@ -201,7 +197,7 @@ export async function readLibrary({ repo, commit, mapping }) {
for (const link of file.links) {
if (!link.file) continue;
const target = resolve(file.path, link.file);
if (!target.path) { if (!isAttachment(link)) problems.push(problem('R4', file.path, link.line)); continue; }
if (!target.path) { problems.push(problem('R4', file.path, link.line)); continue; }
const to = byPath.get(target.path);
if (file.scope === 'canon') {
if (to.scope === 'playground') problems.push(problem('R5', file.path, link.line));
+10 -15
View File
@@ -1,15 +1,14 @@
// `mosaic library survey <repo> [--mapping <file>]` (design 1.1, 10 step 7). It prints what a mapping
// needs: folder names, file counts by extension, front matter key sets, link-form counts and the
// count of body embeds whose target has an extension other than `.md`. It never prints file text,
// front matter values, file names or link targets. With a mapping it adds scope counts, resolver
// step counts and R-problem counts by code. It reads the repo's HEAD commit
// needs: folder names, file counts by extension, front matter key sets and link-form counts. It
// never prints file text, front matter values, file names or link targets. With a mapping it adds
// scope counts, resolver step counts and R-problem counts by code. It reads the repo's HEAD commit
// through the commit reader, never the working tree.
import { readFileSync, realpathSync, statSync } from 'node:fs';
import { resolve as resolvePath } from 'node:path';
import { pathToFileURL } from 'node:url';
import { checkMapping } from './mapping.mjs';
import { classify } from './scope.mjs';
import { formOf, isAttachment } from './links.mjs';
import { formOf } from './links.mjs';
import { headCommit, listTree, parseFile, readable, readLibrary, readTexts } from './reader.mjs';
import { LibraryError, RULES } from './problems.mjs';
@@ -38,7 +37,7 @@ export async function survey({ repo, commit, mapping }) {
const texts = await readTexts(repo, markdown.filter(readable));
const keySets = new Map(), forms = Object.fromEntries(FORMS.map(f => [f, 0]));
const fields = { appears_in: { wikilink: 0, name: 0 }, relationships: { wikilink: 0, name: 0 } };
let noFrontMatter = 0, unparsed = 0, attachments = 0;
let noFrontMatter = 0, unparsed = 0;
for (const text of texts.values()) {
const file = parseFile(text, { markdown: true });
if (file.stop !== null) unparsed++;
@@ -52,14 +51,14 @@ export async function survey({ repo, commit, mapping }) {
fields[field][typeof v === 'string' && /^\s*!?\[\[[^\]]+\]\]\s*$/.test(v) ? 'wikilink' : 'name']++;
}
}
for (const link of file.links) if (!link.field) { forms[formOf(link)]++; if (isAttachment(link)) attachments++; }
for (const link of file.links) if (!link.field) forms[formOf(link)]++;
}
const result = {
commit: head, entries: entries.length,
folders: [...folders].sort(([a], [b]) => a < b ? -1 : 1).map(([name, counts]) => ({ name, ...counts })),
markdown: { files: markdown.length, read: texts.size, noFrontMatter, unparsed },
keySets: [...keySets].sort(([a, x], [b, y]) => y - x || (a < b ? -1 : 1)).map(([keys, count]) => ({ keys, count })),
forms, attachments, fields,
forms, fields,
};
if (mapping) {
const scopes = { canon: 0, playground: 0, unmapped: 0 };
@@ -75,18 +74,14 @@ export async function survey({ repo, commit, mapping }) {
return result;
}
// Folder names are quoted as JSON strings, which escape C0 controls but leave DEL, C1 and bidi
// controls raw. quote() escapes those too, so no name can carry a control sequence to the terminal
// or reorder the line around it.
const RAW_CONTROLS = /[\u007f-\u009f\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/g;
export const quote = name => JSON.stringify(name).replace(RAW_CONTROLS, c => `\\u${c.charCodeAt(0).toString(16).padStart(4, '0')}`);
// Folder names are quoted as JSON strings, so no name can carry a control sequence to the terminal.
export function formatSurvey(s) {
const out = [`commit ${s.commit}`, `entries ${s.entries}`, 'folders (direct children: md canvas base other)'];
for (const f of s.folders) out.push(` ${f.name === '' ? '(root)' : quote(f.name)} ${f.md} ${f.canvas} ${f.base} ${f.other}`);
for (const f of s.folders) out.push(` ${f.name === '' ? '(root)' : JSON.stringify(f.name)} ${f.md} ${f.canvas} ${f.base} ${f.other}`);
out.push(`markdown ${s.markdown.files} files, ${s.markdown.read} read, ${s.markdown.noFrontMatter} without front matter, ${s.markdown.unparsed} with front matter not fully parsed`);
out.push('front matter key sets');
for (const k of s.keySets) out.push(` ${k.count} [${k.keys}]`);
out.push(`body links ${FORMS.map(f => `${f} ${s.forms[f]}`).join(', ')}; attachment ${s.attachments}`);
out.push(`body links ${FORMS.map(f => `${f} ${s.forms[f]}`).join(', ')}`);
for (const [field, c] of Object.entries(s.fields)) out.push(`field ${field} wikilink ${c.wikilink}, name ${c.name}`);
if (s.scopes) {
out.push(`scopes canon ${s.scopes.canon}, playground ${s.scopes.playground}, unmapped ${s.scopes.unmapped}; indexed ${s.indexed}`);
+1 -13
View File
@@ -1,6 +1,6 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { extractLinks, isAttachment, parseWikilink, resolver } from '../src/index.mjs';
import { extractLinks, parseWikilink, resolver } from '../src/index.mjs';
const PATHS = ['Book One/Characters/Mara Vell.md', 'Book One/Characters/Tomas.md', 'Book One/Places/Harbor Town.md',
'Book One/Chapters/Chapter 01.md', 'Book One/Places/Notes.md', 'Book One/Characters/Notes.md', 'Mara Vell.md'];
@@ -48,15 +48,3 @@ test('wikilink forms, embeds, and links skipped inside code', () => {
assert.deepEqual(extractLinks(lines).map(l => [l.file, l.line]), [['One', 1], ['Two', 1], ['Three', 1], ['Four', 1], ['', 1]]);
assert.deepEqual(extractLinks(lines, { markdown: true }).filter(l => l.markdown).map(l => l.file), ['Five.md']);
});
test('only a body embed whose target has an extension other than .md, in any case, is an attachment', () => {
const att = text => isAttachment(parseWikilink(text));
// By name alone: the reader asks only of an embed that resolved to nothing (sage-1900).
for (const s of ['![[cover.png]]', '![[cover.PNG]]', '![[Art/map.pdf|Map]]', '![[Board.canvas]]', '![[clip.mp4#t=5]]', '![[Dr.Quinn]]'])
assert.equal(att(s), true, s);
for (const s of ['![[missing-note]]', '![[note.md]]', '![[Note.MD]]', '![[Chapter 1.5]]', '![[Mr. Smith]]', '![[A.b/Note]]',
'[[cover.png]]', '[[Board.canvas]]', '[[Dr.Quinn]]', '[[#Self]]'])
assert.equal(att(s), false, s);
assert.equal(isAttachment({ ...parseWikilink('![[portrait.jpg]]'), field: 'relationships' }), false, 'a front matter value');
assert.equal(isAttachment({ file: 'pic.png', markdown: true, embed: false }), false, 'a markdown link');
});
+1 -35
View File
@@ -24,7 +24,7 @@ test('the fixture reads clean: every resolver step, appears_in and relationships
assert.deepEqual(mara.frontmatter, { type: 'character', name: 'Mara Vell', appears_in: '[[Book One/Chapters/Chapter 01]]',
relationships: ['[[Tomas]]', 'The Ferryman'] });
assert.deepEqual(mara.links.map(l => [l.field, l.path, l.line]), [
['appears_in', 'Book One/Chapters/Chapter 01.md', 4], ['relationships', 'Book One/Characters/Tomas.md', 6]]);
['appears_in', 'Book One/Chapters/Chapter 01.md', 4], ['relationships', 'Book One/Characters/Tomas.md', 5]]);
assert.deepEqual(file(result, 'Book One/Characters/Tomas.md').links.map(l => [l.field, l.path]),
[['appears_in', 'Book One/Chapters/Chapter 01.md']]);
assert.equal(mara.bodyLine, 9);
@@ -97,40 +97,6 @@ test('R4: a link to no file, to more than one, or into another repo is reported
assert.deepEqual(file(result, 'Book One/Chapters/Links.md').links.map(l => l.path), ['Book One/Characters/Tomas.md']);
});
test('a block list link carries its item line, a scalar or flow link its key line, and problems follow', async t => {
const r = repo(t, { ...BASE, 'Book One/Characters/Ana.md': ['---', 'appears_in: ["[[Nobody]]"]', 'relationships:', ' - "[[Tomas]]"',
' - Plain Name', ' - "[[Ghost]]"', ' - "[[What If]]"', '---', ''].join('\n') });
const result = await readLibrary({ repo: r.repo, commit: r.commit, mapping: mapping() });
assert.deepEqual(file(result, 'Book One/Characters/Ana.md').links.map(l => [l.field, l.path, l.line]), [
['relationships', 'Book One/Characters/Tomas.md', 4], ['relationships', 'Book One/Playground/What If.md', 7]]);
assert.deepEqual(result.problems.filter(p => p.path === 'Book One/Characters/Ana.md').map(p => [p.code, p.line]),
[['R4', 2], ['R4', 6], ['R5', 7]]);
});
// sage-1900: only an unresolved body embed with an extension other than .md is an attachment. A plain
// link to one and a front matter value stay R4 when they resolve to nothing.
test('an unresolved attachment embed gives no problem; a link or front matter value to one is R4; a note embed resolves', async t => {
const r = repo(t, { ...BASE, 'Book One/Places/note.md': 'n\n', 'Book One/Places/Shore.md': ['---',
'relationships:', ' - "![[portrait.JPG]]"', '---', '![[cover.png]] [[Board.canvas]] ![[map.pdf|Map]]',
'![[missing-note]] ![[note.md]] ![[Harbor Town]]', ''].join('\n') });
const result = await readLibrary({ repo: r.repo, commit: r.commit, mapping: mapping() });
assert.deepEqual(result.problems.filter(p => p.path === 'Book One/Places/Shore.md').map(p => [p.code, p.line]),
[['R4', 3], ['R4', 5], ['R4', 6]]);
assert.deepEqual(file(result, 'Book One/Places/Shore.md').links.map(l => [l.path, l.embed]),
[['Book One/Places/note.md', true], ['Book One/Places/Harbor Town.md', true]]);
});
// webby-0070 B1: a note whose name looks like it has an extension resolves before the attachment test.
test('an indexed Dr.Quinn.md resolves as a body link, a block list link and an embed', async t => {
const r = repo(t, { ...BASE, 'Book One/Characters/Dr.Quinn.md': 'q\n', 'Book One/Characters/Ana.md': ['---',
'relationships:', ' - "[[Dr.Quinn]]"', '---', '[[Dr.Quinn]] ![[Dr.Quinn]]', ''].join('\n') });
const result = await readLibrary({ repo: r.repo, commit: r.commit, mapping: mapping() });
assert.deepEqual(file(result, 'Book One/Characters/Ana.md').links.map(l => [l.field ?? null, l.path, l.line, l.embed]), [
[null, 'Book One/Characters/Dr.Quinn.md', 5, false], [null, 'Book One/Characters/Dr.Quinn.md', 5, true],
['relationships', 'Book One/Characters/Dr.Quinn.md', 3, false]]);
assert.deepEqual(result.problems.filter(p => p.path === 'Book One/Characters/Ana.md'), []);
});
test('R5: a canon file that refers into the playground is reported, and the link still goes by path', async t => {
const r = repo(t, { ...BASE, 'Book One/Places/Dock.md': 'See [[What If]].\n' });
const result = await readLibrary({ repo: r.repo, commit: r.commit, mapping: mapping() });
+1 -19
View File
@@ -39,31 +39,13 @@ test('the survey prints folder names, counts, key sets and link-form counts, and
' 1 [appears_in, name, type]',
' 1 [name]',
' 1 [type]',
'body links plain 3, label 1, heading 1, embed 0, self 0, markdown 1; attachment 0',
'body links plain 3, label 1, heading 1, embed 0, self 0, markdown 1',
'field appears_in wikilink 2, name 0',
'field relationships wikilink 1, name 1',
''].join('\n'));
for (const s of NEVER) assert.ok(!out.stdout.includes(s), s);
});
test('folder names print DEL, C1 and bidi controls, U+061C among them, as escapes', async t => {
// One name per escaped range, with each range's first and last code point.
const names = ['A\u007fB', 'C\u0080D\u009fE', 'F\u200eG\u200fH', 'I\u202aJ\u202eK', 'L\u2066M\u2069N', 'O\u061cP'];
const r = repo(t, Object.fromEntries(names.map(n => [`${n}/a.md`, `${MARKER}\n`])));
const out = await run(['survey', r.repo]);
assert.equal(out.code, 0, out.stderr);
assert.deepEqual(out.stdout.split('\n').slice(3, 9), [
' "A\\u007fB" 1 0 0 0', ' "C\\u0080D\\u009fE" 1 0 0 0', ' "F\\u200eG\\u200fH" 1 0 0 0',
' "I\\u202aJ\\u202eK" 1 0 0 0', ' "L\\u2066M\\u2069N" 1 0 0 0', ' "O\\u061cP" 1 0 0 0']);
assert.doesNotMatch(out.stdout, /[\u007f-\u009f\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/);
});
test('an embed of an attachment counts as an embed and as an attachment; a plain link to one does not', async t => {
const r = repo(t, { 'Book One/Chapters/A.md': ['![[cover.png]] ![[missing-note]] [[map.PDF]] [[Chapter 1.5]]', ''].join('\n') });
const out = await run(['survey', r.repo]);
assert.match(out.stdout, /^body links plain 2, label 0, heading 0, embed 2, self 0, markdown 0; attachment 1$/m);
});
test('with --mapping the survey adds scope, resolver step and problem counts', async t => {
const r = repo(t);
const out = await run(['survey', r.repo, '--mapping', join(FIXTURES, 'mapping.json')]);
-319
View File
@@ -1,319 +0,0 @@
#!/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));