Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ed41933168 |
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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".
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Generated
+1
-2
@@ -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
@@ -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.
|
||||
-2
@@ -1,2 +0,0 @@
|
||||
model:
|
||||
default: marker-model-chet-6d13
|
||||
-1
@@ -1 +0,0 @@
|
||||
marker-memory-chet-d913
|
||||
-1
@@ -1 +0,0 @@
|
||||
description: marker-profile-chet-0c55
|
||||
Vendored
-1
@@ -1 +0,0 @@
|
||||
{"text": "marker-session-chet-08bc"}
|
||||
Vendored
-1
@@ -1 +0,0 @@
|
||||
marker-skill-chet-6a70
|
||||
-1
@@ -1 +0,0 @@
|
||||
marker-state-chet-52aa
|
||||
-1
@@ -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();
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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,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';
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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,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');
|
||||
});
|
||||
|
||||
@@ -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() });
|
||||
|
||||
@@ -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')]);
|
||||
|
||||
@@ -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));
|
||||
Reference in New Issue
Block a user