Plan: The engine in Rust, and a site that only takes its output (from intent.md 2026-10-21)#
Status: approved.
The approved spec is spec.md (PR #27). This plan ports the Node CLI and engine to one Rust program engine-first, keeps the Node CLI shippable until a parity gate passes, then changes the art, the app and the release, and removes Node from everything but repo tooling. The site's own change (spec D13) is not part of this plan. It starts after this change's release.
Before step 2 (blocked on the owner): this Mac has no Rust toolchain (cargo isn't on the PATH). The owner installs rustup (curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh, into ~/.rustup and ~/.cargo), or says yes to the main session running it. rust-toolchain.toml then fetches the pinned version on first use. Nothing touches system settings.
Files that change#
PR 1, the foundation (feat/rust-engine-foundation)
Cargo.toml,Cargo.lock,rust-toolchain.toml(all new): the workspace, the three approved crates (serde,serde_jsonwithpreserve_order,miniz_oxide), and the toolchain pinned to the current stable at step 2 (exact version, for example1.90.0)..gitignore:target/.crates/engine/andcrates/pixellab/(new): every module file in spec D1, with its public types and signatures, and a doc comment naming the Node file it ports.crates/engine/src/lib.rsandcrates/pixellab/src/registry.rslist them all, so later units fill one file each without touching a shared one.crates/engine/src/jsnum.rs,deflate.rs,png.rs,sha256.rs,serialize.rs,format.rs(filled in, with tests).crates/engine/tests/corpus.rs(new): the goldens harness (step 6).scripts/goldens.mjs(new, removed at cutover): Node writes the format-2 bootstrap copy of the corpus, and the outputs the Rust units compare against.fixtures/sample.pixellab/(new): the test fixture in format 2, written by the bootstrap (see "Departures from the spec")..github/workflows/test.yml(new): R28, on every PR.docs/intents/2026-10-rust-engine/port-queue.md(new): the queue PR 2 runs from (Workflow).
PR 2, the port and the parity gate (feat/rust-engine-port)
crates/engine/src/*.rs:iso,depth,motion,engine,edit,svg,compile,preview,starter,migrate(spec D1), withcrates/engine/src/starter-palette.json(new).crates/engine/tests/*.rs: the engine suites ported fromcli/test/with their names (spec D12).crates/pixellab/src/*.rsandsrc/commands/*.rs: one Rust file percli/lib/*.mjsandcli/lib/commands/*.mjs(the queue table lists every pair), withdocument migrate(R8) and R9's problem.crates/pixellab/tests/*.rsandtests/common/mod.rs: the program's suites, andmakeDocument()andmakeSite()as temp-dir helpers.fixtures/format1/sample.pixellab/(new): a copy ofcli/test/fixtures/sample.pixellab, the input for the migrate tests and R9's.scripts/parity.mjs(new, removed at cutover),scripts/test/parity.test.mjs(new),package.json(testbuilds the program, then also runsscripts/test/).
PR 3, self-contained art (feat/rust-engine-art), in parallel with PR 4
crates/engine/src/compile.rs,svg.rs,motion.rs,engine.rs,ambient.rs: R4, R11–R15, D5–D7.crates/pixellab/src/commands/scene_edit.rs,object_edit.rs,scene.rs,object.rs,art.rs,check.rs,state.rs: D8's commands, R4, R20'scheckwarning, D11.crates/engine/tests/motion_site.rs(new),crates/engine/tests/output.rs(new), and the suites the new fields touch.scripts/parity.mjs: setsPIXELLAB_LEGACY_SVG=1(see "Departures").scripts/visual-parity.mjs(new): R16, D9.docs/site-contract.md(new),docs/document-format.md(new),docs/cli.md(generated, still by the Node script until cutover, from the program'shelp --markdown).
PR 4, the app on the program (feat/rust-engine-app), in parallel with PR 3
app/Sources/PixelLab/Server/Locator.swift,ServerConnection.swift,Protocol.swift.app/Sources/PixelLab/Model/AppModel.swift,Settings.swift,AgentModel.swift,NewDocDraft.swift,HitTesting.swift.app/Sources/PixelLab/Views/SetupView.swift,MainWindow.swift,ChatBubbles.swift.app/Sources/PixelLab/Chat/ClaudeLocator.swift,ClaudeTurn.swift,ChatModel.swift.app/Sources/PixelLab/Support/ScriptRunner.swift:migrateand its expects;cliruns the program.app/Tests/PixelLabTests/*.swift: the tests that name.mjsor Node.scripts/build-app.sh(bundlestarget/release/pixellabasContents/MacOS/pixellab, and no longer bundlescli/),scripts/install.sh,scripts/snapshot.sh.scripts/acceptance.sh(migrates its temp site copy with the program first; runs the program, notnode …/pixellab.mjs),scripts/acceptance/document.txt,scene-object.txt,migrate.txt(new),mcp-agent.mjs,stale-agent.mjs(PIXELLAB_CLI_PATHbecomes the program).
PR 5, play and the examples (feat/rust-engine-play)
app/Sources/PixelLab/Model/TimelineModel.swift,SceneEditor.swift,app/Sources/PixelLab/Views/SelectionInspector.swift,LayerInspector.swift,TimelineView.swift: ambient loops on the timeline, ACTIONS and Play (R23).app/Sources/PixelLab/Support/ScriptRunner.swift:play,expect ambient|actions.app/Tests/PixelLabTests/StageTests.swift.scripts/acceptance/actions.txt(new),scripts/acceptance.sh.scripts/make-examples.mjs(drives the program),examples/harbor.pixellab/,examples/town.pixellab/(format 2, harbor with an ambient loop and a hop action),scripts/test/examples.test.mjs(new;cli/test/examples.test.mjsdeleted),scripts/try-example.sh.cli/test/helpers.mjs,chat.test.mjs,depth.test.mjs,depth-edit.test.mjs: read the format-1 examples from the parity gate's commit (git archive), until cutover.scripts/parity.mjs: the same, for its format-1 examples.
PR 6, cutover, release and docs (feat/rust-engine-cutover)
cli/(deleted),scripts/goldens.mjs,scripts/parity.mjs,scripts/test/parity.test.mjs(deleted), thePIXELLAB_LEGACY_SVGbranch incompile.rs(deleted).package.json: nobin,exportsorfiles.testrunsscripts/test/. A new description.scripts/test/docs-site.test.mjs,scripts/test/package.test.mjs(moved fromcli/test/; package now checks that nothing exports an engine).scripts/docs-cli.mjs(runspixellab help --markdown),scripts/make-icon.mjs(renders with the program),.github/workflows/docs.yml(installs the toolchain, builds the program).scripts/package-program.sh(new): builds one target'spixellab-<target>.tar.gz. The release runs it, and it can be run locally..github/workflows/release.yml: R17, R27.- Docs (R30):
CLAUDE.md,README.md,app/README.md,scripts/README.md,crates/pixellab/README.md(new, takes over fromcli/README.md),docs/architecture.md,docs/testing.md,docs/contributing.md,docs/README.md,docs/cli.md(generated), and one pointer each in../2026-10-mac-app/plan.mdand../2026-10-pixellab-document/spec.md.
Order of work#
main ── feat/rust-engine ─ plan ───────────────────────────────────────────────── the main PR (draft from step 1, owner merges) ──▶ main
├─ PR 1 foundation: workspace, skeleton, jsnum, format, goldens, CI ◀─ feat/rust-engine-foundation steps 2–6
├─ PR 2 the port, migrate, the parity gate (R3) ◀─ feat/rust-engine-port steps 7–11
├─ PR 3 self-contained art, the visual gate (R16) ◀─ feat/rust-engine-art ┐ steps 12–18
├─ PR 4 the app on the program, no Node, Migrate… ◀─ feat/rust-engine-app ┘ steps 19–23
│ in parallel, from PR 2's merge
├─ PR 5 play on the stage and in the Inspector, examples in format 2 ◀─ feat/rust-engine-play steps 24–28
└─ PR 6 cutover: cli/ removed, scripts on the program, release, docs ◀─ feat/rust-engine-cutover steps 29–34
Each slice branch is cut from feat/rust-engine in its own worktree (../jlawcordova-lab-rust-<slice>). Inside PR 2, PR 3 and PR 6, units of work run on unit branches (feat/rust-engine-<slice>-<unit>) in their own worktrees and merge into the slice branch without a PR of their own (Workflow, "The queue").
Why six slices: PR 1 settles the module layout and signatures that parallel units build on. PR 2 ends at the parity gate, which must pass before anything changes the outputs. PR 3 and PR 4 own disjoint files and run at the same time. PR 5 needs both. PR 6 removes the safety net (the Node CLI and parity), so it goes last, alone.
- The plan lands on
mainthrough its own PR, as the intent and spec did; merging it approves it. The owner then starts the build with/goal(Workflow, "The goal"). The main session's first act is to cutfeat/rust-enginefrommain, push it with an empty commit ("Start the Rust engine"), and open the main PR (feat/rust-engine→main) as a draft. That PR is the goal: everything after this lands in it through the slice PRs.
PR 1: steps 2–6
- The workspace and the skeleton:
Cargo.toml,rust-toolchain.toml, both crates with every D1 module, its public types and signatures, and the command shape (name,summary,paramsschema,args,mutates,writes,session,document,run,format, as inregistry.mjs). Documents and sources areserde_json::Valuewith insertion order (see "Options considered").port-queue.mdwith every unit in PR 2's table.cargo testpasses on the empty suites. jsnum.rs:round,to_fixedandto_string, tested against casesscripts/goldens.mjscollects from Node: every number the corpus prints, plus −0, halves of both signs,1e21,1e-7and ties fortoFixed.deflate.rs(gzip aroundminiz_oxide, CRC-32),png.rs(encode and decode RGBA) andsha256.rs, each against published vectors (the NIST SHA-256 examples, CRC-32's123456789check value, a PNG round trip, a gzip streamgunzipreads). A Sonnet unit.serialize.rs(the key-order tables andprop()fromserialize.mjs) andformat.rs(D4's canonical JSON, and reading a format-2 folder).goldens.mjs --bootstrapimports each.mjssource with Node and writes a format-2 copy of the fixture, both examples and, when a site checkout is present, a temp copy of the owner's document.fixtures/sample.pixellabis committed from it. Test:serialize(parse(text)) == textfor every bootstrapped file.- The goldens harness and CI.
goldens.mjs --out <dir>writes the Node CLI's outputs for the corpus: every scene's SVG, every preview PNG's RGBA, and the JSON of every read command (mutates: false, nosession, nowrites).crates/engine/tests/corpus.rscompares against them whenPIXELLAB_GOLDENSis set, and prints that it was skipped otherwise..github/workflows/test.yml:ubuntu-latest, the pinned toolchain,cargo fmt --check,cargo test,npm test. PR 1's checks, then merge intofeat/rust-engine.
PR 2: steps 7–11
The queue: every unit below, run as Workflow says, one or more commits per unit, each unit merged into the slice branch with a merge commit when its checks pass. A unit's checks are
cargo fmt --check,cargo testfor its crate, its ported suite with every test name kept, and, for the units that produce outputs (compile,preview, the commands),PIXELLAB_GOLDENS=<dir> cargo test --test corpuswith no differences.Unit Node → Rust Tests ported Needs Tier U1 iso.mjs→iso.rsiso cases of engine.test.mjsPR 1 S U2 svg.mjs,starter.mjs,starter-palette.mjs→svg.rs,starter.rs,starter-palette.jsonsvg and starter cases PR 1 S U3 engine.mjs→engine.rs(validate, resolve,sceneAt)validation cases PR 1 O U4 depth.mjs→depth.rsdepth.test.mjsU1, U3 O U5 motion.mjs→motion.rsmotion cases of engine.test.mjsanddepth-motion.test.mjsU3 O U6 edit.mjs→edit.rsedit cases of engine.test.mjsU3 S U7 compile.mjs→compile.rs(budgets, lossless,gitsubprocess)compile.test.mjs, corpus SVGsU2–U5 O U8 preview.mjs→preview.rspreview cases of render.test.mjs, corpus PNGsU1, U3 S U9 the rest of engine.test.mjs→ tests/engine.rs, in two units bydescribeblockU3–U6 S U10 errors.mjs,argv.mjs,params.mjsparams.test.mjsPR 1 S U11 jsonrpc.mjsits cases in servers.test.mjsPR 1 S U12 state.mjs,activity-summary.mjsactivity-and-proposals.test.mjsU10 S U13 document.mjs→document.rs(resolution, manifest, R9's problem)document.test.mjsU10 S U14 session.mjs,open-docs.mjs,context.mjssync.test.mjsU3, U6, U12, U13 O U15 merge.mjs,diff.mjsmerge.test.mjsU3 O U16 watch.mjs→watch.rs(polling, D2)its cases in sync.test.mjsU13 S U17 scene-ops.mjsscene-ops.test.mjs,groups.test.mjsU3, U4 S U18 layer-ops.mjslayers.test.mjsU17 S U19 motion-ops.mjsmotion-commands.test.mjsU5 S U20 render.mjsrender.test.mjsU7, U8 S U21 registry.mjs,cli.mjs,help.mjs,bin/pixellab.mjs→registry.rs,cli.rs,help.rs,main.rshelp.test.mjs,commands.test.mjsU10 S U22 commands/scene.mjs,scene-edit.mjsscene parts of edit-commands.test.mjsU14, U17, U21 S U23 commands/scene-layers.mjs,scene-motion.mjsU18, U19, U21 S U24 commands/object.mjs,object-edit.mjsmap-set.test.mjs, object parts ofedit-commands.test.mjsU14, U21 S U25 commands/doc.mjs,document.mjs,proposal.mjs,check.mjs,art.mjs,common.mjsart-commands.test.mjsU7, U14, U15, U21 S U26 commands/chat.mjs,chat-context.mjschat.test.mjsU14, U21 S U27 depth-edit.test.mjs, the program parts ofdepth-motion.test.mjs→ program tests U22–U24 S U28 serve.mjs,server-stdio.mjs,server-mcp.mjsservers.test.mjs, transcripts replayedU11, U14, U16, U21 O U29 migrate.rs,document migrate(D3, R8)new tests: every refusal, notes, colorNotes, the legacysource/folder, the SVG check refusing,--to, in place; its output equals the bootstrap's for the corpus apart fromnotesU7, U13, U25 O docs/cli.mdand the help text come out of U21–U26 unchanged;npm run docs:clistill runs the Node registry here, and the parity check compareshelp --markdown.scripts/parity.mjs(R3): both programs over the corpus. The Node CLI reads format 1 as it is; the Rust program reads a temp copy made by its owndocument migrate --to. It compares the four things R3 names (raw sizes exactly, gzip within 1%), plus:serve --mcp'sinitializeandtools/listresults, byte for byte (the instructions and every schema);- an edit replay: each mutating command once on the fixture through both CLIs, then every scene's SVG and
scene show/object showcompared; - the human output and exit code of every read command, and of a set of failing calls (an unknown command, a bad param, a missing scene), so R1's errors and messages match too. It writes a report and exits non-zero on any difference.
scripts/test/parity.test.mjsruns it over the fixture and both examples innpm test, andpackage.json'stestbuilds the program first (cargo build --release --quiet).The parity gate:
scripts/parity.mjsover the fixture, both examples and a temp copy of the owner's document (node cli/bin/pixellab.mjs document copy --to <scratch>/owner.pixellab --site ~/Projects/jlawcordova.github.io, which only reads the checkout). Zero differences. The report goes in PR 2.The adversarial review of PR 2 (Workflow); its blocking findings fixed. PR 2's checks, then merge into
feat/rust-engine.
PR 3: steps 12–18
R4: over budget warns.
compile.rswrites the scene and returnswarnings;art build,art sizesandcheckprint it as a warning line and exit 0; validation problems still stop the build. Tests for each door.parity.mjstreatsok/warningson an over-budget scene as R4's expected change.R11, R14, R15: the root's
data-pixellabandshape-rendering, the scoped names, and the frame loops'<style>(D7), with tests that a scene without animation gets no<style>. The old output stays behindPIXELLAB_LEGACY_SVG=1, read in one place incompile.rs, whichparity.mjssets.D6:
ambientandactionson scene items and object layers. Validation (every rule in D6, with the item named),sceneAtplacing ambient loops, renders takingplay, andambient.rswriting their CSS (D7: classes, keyframes,offsetas a negative delay, actions overriding ambient by specificity, the reduced-motion rule). Tests:pcloud's three drifts andcbob's bob expressed exactly, an action'sanimationName.D8's commands:
scene ambient,scene action,object ambient,object action,--play <action>@<ms>and ambient loops at--timeon both renders. Tools over MCP, proposals on save, undo and redo. Tests over all three doors.docs/cli.mdregenerated.Three units at once: D5's images (
imageon a scene,publish.images,kindinoutputs[], validation); D11 (retiring proposals without"format": 2, the activity line, numbers carrying on); R20 (motion_site.rswith the site's motion tests rewritten against the fixture,output.rswith the deterministic-output and no-script-link-text-or-external-reference tests over the fixture and both examples, andcheck's warning for a source not in canonical form).docs/site-contract.md(R19) anddocs/document-format.md(R6, R7, D4–D6).scripts/visual-parity.mjs(D9) and the visual gate. It makes the two pages for each scene of a temp copy of the owner's document:- old: the Node CLI's format-1 build, inlined with the site's
src/styles/pixel-art.css(copied from the checkout, read-only); - new: the program's build of a migrated copy with the site swap rehearsed by the program's own commands (
pcloud/pc0–pc2→ ambient loops,cbob→ an ambient loop and ahopaction), inlined bare.--write-swapped <dir>keeps that copy, for the site's change to start from.
It finds Chrome or Chromium (
CHROME, else the usual macOS and Linux paths), drives it over the DevTools protocol with Node's built-inWebSocket, pauses every animation attwithdocument.getAnimations(), and compares 1× screenshots at D9's times. Sample times sit at least 5 ms from every step boundary on either page, so R16's 4 ms tolerance can't flip a frame. The gate passes with every frame matching; the report goes in PR 3. PR 3's checks, then merge.- old: the Node CLI's format-1 build, inlined with the site's
PR 4: steps 19–23
- The program in the bundle:
Locator.findProgram()(bundle, elsePIXELLAB_PROGRAM, elsetarget/release/pixellabin the checkout);findNode,runCli(node:cli:)andfindCLIgo;ServerLaunch, the hook'scli,ClaudeTurn.mcpConfigand the Agent window'sclaude mcp addline take the program's URL;build-app.shrunscargo build --releaseand copies the program intoContents/MacOS/;install.shprints the new command. - The setup screen and Settings (D10): no Node row, Choose… or "Find Node Automatically", no
SettingsKey.node(a stored value is ignored), the new copy, no "Finding Node.js…" phase;documentProblemcheckspalette.jsonand gives R9's problem with Migrate… and its alert, which runsdocument migrateand reopens. The hook gainsmigrateandanswer migrate|cancel. - The chat's PATH from the login shell (D10), resolved once with a 5 s limit, after
claude's folder. .jsoneverywhere (R26):Protocol.swift'sfile,MainWindow's problem trimming,AppModel's watch handling, the chat's notes,NewDocDraft's message; the unit tests that pin them.- The acceptance pass on the program:
acceptance.shmigrates its temp site copy withpixellab document migratebefore the first script, and runs the bundled program wherever it rannode …/pixellab.mjs;document.txtandscene-object.txtfor.json;migrate.txt(a second temp copy left in format 1: the problem, Migrate…, Cancel leaves it, Migrate converts it, the expects). Snapshots of the setup screen (no document, R9's problem with Migrate…, the alert) and Settings in dark and light. PR 4's checks, then merge.
PR 5: steps 24–28
- Ambient loops on the stage (D10): the timeline's length is the longest of scene motion and the ambient loops, capped at 60 s; playing and scrubbing render through the server's
time. - ACTIONS in the Inspector, for an item and for an object layer: the Silkscreen label, one row per action with its name, its duration and Play (
play.fill, "Play hop", visible focus, a comfortable hit target). Play renders the action's frames with--playand shows them once; under Reduce Motion it shows the rest frame and announces "hop: shown at rest". Tokens fromTheme.swift. - The examples in format 2 (R10, R29):
make-examples.mjsdrives the program instead of importing the engine, and gives the harbor an ambient loop and ahopaction;npm run examplesrewrites both;scripts/test/examples.test.mjschecks they match. The Node tests andparity.mjstake their format-1 examples from PR 2's merge commit withgit archive. - The hook's
play <action> [<item>]andexpect ambient|actions <v>;actions.txton a temp harbor copy (select the boat,expect actions hop,play hop, a snapshot mid-play, the rest frame after, Reduce Motion's rest frame);acceptance.shruns it. - Snapshots of the Inspector's ACTIONS (item and layer), mid-play, and the timeline with an ambient loop, in dark and light. The whole acceptance pass. PR 5's checks, then merge.
PR 6: steps 29–34
scripts/package-program.sh <target> <out>:cargo build --release --target <target>withPIXELLAB_VERSION, the tarball, its line inSHA256SUMS. Run locally foraarch64-apple-darwin;pixellab --versionprints the version passed.release.yml(R17, R27): a Linux job builds and testsx86_64-unknown-linux-gnuand uploads its tarball; the macOS job runscargo test,npm test,scripts/test-app.sh, builds the app with the bundled program, checksContents/MacOS/pixellab --versionagainst the tag, smoke-testsserve --mcpwith it, and publishes the.dmg, the.zip, both tarballs andSHA256SUMS. The site clone goes.- The cutover (R2, R29):
cli/,goldens.mjs,parity.mjs, its test and thePIXELLAB_LEGACY_SVGbranch are deleted;package.jsonlosesbin,exportsandfiles;docs-site.test.mjsandpackage.test.mjsmove toscripts/test/(package now checks there is nobinorexports, and a test checks nothing readsPIXELLAB_LEGACY_SVG). - The scripts on the program:
docs-cli.mjswritesdocs/cli.mdfrompixellab help --markdown;make-icon.mjsrenders with the program (if the icon needs a render the program can't give, stop and report rather than adding a command);docs.ymlinstalls the toolchain and builds first.npm run docs:clileavesdocs/cli.mdunchanged. - Docs (R30), in two units at once:
README.md,app/README.md,scripts/README.md,crates/pixellab/README.md,docs/README.md; anddocs/architecture.md,docs/testing.md,docs/contributing.md, the two pointers. The main session writesCLAUDE.md(commands, architecture, rules: "the engine lives here in Rust; the site takes only output"; the acceptance pin; the Rust toolchain). - PR 6's checks (all of Proof), then merge.
The main PR: steps 35–37
git fetch origin, merge the latestorigin/mainintofeat/rust-engine, rerun every check.verify-changein a fresh session on the main PR, still a draft; its blocking findings fixed onfeat/rust-engine.- The main PR's body written in full (the gate reports, every check's output, the snapshots and what to try), the PR marked ready for review, and the goal reported done; hand over.
Workflow and reviews#
The goal#
The build runs as one Claude Code /goal, so the main session keeps going across turns, slices and subagents until the goal holds, rather than stopping between steps. The owner starts it in a fresh session on this checkout, once this plan is merged and rustup is installed:
/goal The main PR for docs/intents/2026-10-rust-engine (feat/rust-engine → main) is ready for review: every step of plan.md is done, every slice PR is merged into feat/rust-engine, the parity gate (R3) and the visual gate (R16) passed with their reports in the PR, verify-change's blocking findings are fixed, and every check in plan.md's Proof passes with its output pasted in the PR. Follow plan.md's workflow; never merge into main.
- The one PR the goal is about is the main PR. It's opened as a draft at step 1 and fills up as slices merge into
feat/rust-engine, so the owner can watch the whole change grow in one place. - Slices branch out from it and merge back into it. Each slice cuts its branch from
feat/rust-engineas soon as what it needs is merged (PR 3 and PR 4 at the same time), opens its slice PR intofeat/rust-engine, and is merged there by the main session when its checks pass. Units inside a slice branch from the slice branch and merge back the same way, without PRs. - The goal isn't done until the main PR is marked ready. Merging it is the owner's, never the goal's.
- Stopping early is still allowed only when blocked (a gate that can't pass, a file outside a slice's ownership, a write that would touch the real site checkout, or anything outside the plan that's hard to reverse). The main session says what blocked it in the main PR and in the session, and waits.
The run at a glance#
Who runs what, slice by slice. Every slice (PR 1–6) branches from feat/rust-engine and its PR merges back into it, so each one lands in the main PR. The main session is Opus throughout. Purple boxes are Opus work (the main session, or an Opus subagent), green boxes are Sonnet subagents, and yellow diamonds are the gates. Dashed arrows only happen when needed.
flowchart TB
start(["Plan merged · rustup installed ·<br/>the owner starts /goal"]) --> open["Main session · Opus<br/>cuts feat/rust-engine,<br/>opens the main PR as a draft"]
open --> f1
subgraph PR1["PR 1 · foundation"]
direction LR
f1["Main session · Opus<br/>workspace, skeleton and signatures,<br/>jsnum, format, goldens, CI"]
f2["Sonnet subagent<br/>gzip, CRC-32, PNG, SHA-256"]
f1 --- f2
end
f2 --> queue
subgraph PR2["PR 2 · the port"]
queue[["port-queue.md<br/>a unit is ready once its needs are merged"]]
queue -->|"ready S units, up to 4 at once"| s2["Sonnet subagents × 4<br/>iso, svg, edit, preview, argv, params,<br/>state, document, watch, scene and layer ops,<br/>render, registry, commands, test ports"]
queue -->|"ready O units"| o2["Main session · Opus<br/>engine, depth, motion, compile,<br/>session, merge, servers, migrate"]
queue -.->|"a second O unit while main is busy"| o2b["Opus subagent"]
s2 -.->|"fails its checks twice"| o2
s2 --> merge2["Main session reviews the diff,<br/>reruns the checks, merges the unit"]
o2 --> merge2
o2b --> merge2
merge2 -->|"next wave"| queue
merge2 -->|"queue empty"| parity{"Parity gate · R3<br/>Node vs Rust, owner's document included"}
parity --> adv["Opus subagent<br/>adversarial review"]
end
adv --> o3
adv --> s4
subgraph PR3["PR 3 · self-contained art"]
o3["Main session · Opus<br/>scoped SVG style, frame loops,<br/>ambient loops, actions"]
s3["Sonnet subagents × 3–4<br/>budgets warn, D8 commands, images,<br/>retired proposals, engine tests, docs"]
visual{"Visual gate · R16<br/>Chromium, old vs new"}
o3 --> visual
s3 --> visual
o3 --- s3
end
subgraph PR4["PR 4 · the app"]
s4["One Sonnet subagent, in the background<br/>bundled program, no Node, Migrate…,<br/>.json names, chat PATH, acceptance"]
r4["Main session<br/>reviews the diff and snapshots"]
s4 --> r4
end
visual --> s5a
r4 --> s5a
visual --> s5b
r4 --> s5b
subgraph PR5["PR 5 · play, examples"]
s5a["Sonnet subagent<br/>ambient on the timeline,<br/>ACTIONS and Play"]
s5b["Sonnet subagent<br/>examples in format 2"]
s5c["Sonnet subagent<br/>hook actions, actions.txt"]
o5["Main session<br/>snapshots, the acceptance pass"]
s5a --> s5c
s5b --> s5c
s5c --> o5
end
o5 --> o6
o5 --> s6a
subgraph PR6["PR 6 · cutover"]
o6["Main session · Opus<br/>package-program.sh, release.yml, CLAUDE.md"]
s6a["Sonnet subagent<br/>cli/ removed, scripts on the program"]
s6b["Sonnet subagents × 2<br/>the docs"]
s6a --> s6b
end
o6 --> verify
s6b --> verify
subgraph MAIN["The main PR · the goal"]
verify["Fresh session · verify-change"]
owner(["The owner reviews and merges"])
verify --> owner
end
classDef opus fill:#e6dcf5,stroke:#6b4fa0,color:#2b1d45
classDef sonnet fill:#dcefdc,stroke:#3f7d3f,color:#1d3a1d
classDef gate fill:#fbefc4,stroke:#a07c1a,color:#45360b
classDef queueNode fill:#eeeeee,stroke:#666666,color:#222222
class open,f1,o2,o2b,adv,o3,r4,o5,o6,merge2,verify opus
class f2,s2,s3,s4,s5a,s5b,s5c,s6a,s6b sonnet
class parity,visual gate
class queue queueNode
Steps, runners and branches#
| Steps | Who | Where | Then |
|---|---|---|---|
| 1 | main session | claude/rust-engine-plan → a PR into main | the owner merges it and starts /goal; the main session cuts feat/rust-engine and opens the main PR as a draft |
| 2–3, 5–6 | main session | feat/rust-engine-foundation → PR 1 | |
| 4 | Sonnet subagent | its own worktree, unit branch | main reviews, merges into the slice branch |
| 7 | the queue: Sonnet subagents for S units, the main session for O units | unit branches → feat/rust-engine-port | per unit, as below |
| 8–10 | main session | feat/rust-engine-port | |
| 11 | Opus subagent (adversarial review), then main session | PR 2 | fix blocking findings, merge PR 2 |
| 12, 15, 16, 17 | Sonnet subagents (16 is three at once) | unit branches → feat/rust-engine-art | main reviews, merges |
| 13, 14, 18 | main session | feat/rust-engine-art → PR 3 | merge PR 3 |
| 19–23 | one Sonnet subagent, in the background, while the main session runs PR 3 | feat/rust-engine-app → PR 4 | main reviews the diff and snapshots, merges PR 4 |
| 24–27 | Sonnet subagents: 24 and 25 together (one subagent, they share the stage), 26 at the same time; 27 after both | unit branches → feat/rust-engine-play | main reviews, merges |
| 28 | main session | → PR 5 | look at the snapshots, merge PR 5 |
| 29–30 | main session | feat/rust-engine-cutover | |
| 31–33 | Sonnet subagents (31 and 32 in one, then 33's two units at once); CLAUDE.md by the main session | unit branches → feat/rust-engine-cutover → PR 6 | main reviews, merges PR 6 |
| 35–37 | main session; a fresh session for verify-change | feat/rust-engine → the main PR | mark it ready, hand over |
The queue (the dynamic part). PR 2, and the fan-out steps of PR 3, PR 5 and PR 6, run from a queue rather than a fixed order. The main session decides each wave from what's ready:
- The queue file.
port-queue.mdholds the PR 2 table with a status column (ready, running, merged, escalated). A unit is ready when everything in its "Needs" is merged into the slice branch. The main session updates the file in each unit's merge commit, so the branch always shows where the port stands. - Dispatching. Whenever a unit merges, the main session dispatches every ready S unit, up to four Sonnet subagents at once (
model: "sonnet",isolation: "worktree", in the background), and takes the next ready O unit itself. If two O units are ready while the main session is busy, the second goes to an Opus subagent. Expected waves, for orientation only: U1, U2, U10, U11 with U3 in the main session; then U4/U5, U6, U8, U12, U13, U21; then the ops units and the program's plumbing; then the commands; then U27–U29. - The brief. Each unit's brief names: the Node files it ports and the Rust files it fills (the only files it may edit, plus its own test files); the signatures from PR 1 it must keep; the test names to keep; the byte-parity rules in spec D2 ("Byte parity") and
jsnum.rs; the checks to run and paste; and that it never editslib.rs,registry.rs,Cargo.toml,Cargo.lock, another unit's files or anything outside the worktree, never adds a crate, and never touches the site checkout. - Merging a unit. The subagent commits on its unit branch and reports its checks. The main session reads the diff, reruns the checks in the slice worktree, and merges the unit branch with
git merge --no-ff. A conflict between units is the main session's to resolve. - Escalation. An S unit whose checks fail gets one more round, with the failure and a hint. If it fails again, or reports it needs a signature change or another unit's file, the main session takes it over (its tier becomes O in the queue). The main session may also re-tier a unit down to S once its dependencies make it mechanical. A change to a PR 1 signature is made by the main session on the slice branch, and the units that use it are re-briefed.
- Width. Four at once by default. It drops to two after two merge conflicts in one wave, and may go to six for units that only port tests.
Merge rules. The main session merges each slice PR into feat/rust-engine with a merge commit (gh pr merge --merge --delete-branch) once its checks, pasted in the PR, pass, after the review the plan names for it. When main moves, it's merged into feat/rust-engine before the next slice is cut, and a slice merges feat/rust-engine into itself before its PR. Only the owner merges the main PR.
File ownership while PR 3 and PR 4 run in parallel. PR 3 owns crates/, fixtures/, scripts/parity.mjs, scripts/visual-parity.mjs, scripts/goldens.mjs, scripts/test/, docs/. PR 4 owns app/, scripts/build-app.sh, install.sh, snapshot.sh, test-app.sh, acceptance.sh and scripts/acceptance/. A slice that needs a file the other owns stops and reports. PR 4 runs against the program as merged at PR 2; PR 5 runs everything against both.
Owner reviews. One, on the main PR: the snapshots of the setup screen, Settings, the Inspector's ACTIONS and the timeline; both gate reports; and an installed build to try (scripts/install.sh --dest <temp dir>) on a temp copy of an example or of their document. The PR says plainly that the installed app opens only format 2, so installing it into ~/Applications before the site's change migrates their document means migrating it first (Migrate… writes into the site checkout, which is the site's change to make). Their feedback is fixed on feat/rust-engine before they merge.
Independent review. Two:
- PR 2, step 11: an adversarial Opus subagent, before PR 2 merges. It looks at what parity can't:
document migrate's in-place writes and every refusal, the session's undo and redo, proposals andbaseHash, the stdio server's notifications and watching, and the MCP surface (left-out commands,--directoff). It reports findings; it doesn't commit. - The main PR, step 36:
verify-changein a fresh session, given only "Use theverify-changeskill on PR #<n>.", because this changes what's written to the site, the proposals path and the MCP server, and it's large.
Options considered#
- Typed Rust structs for the document instead of
serde_json::Value. Rejected for the port: the Node engine is dynamic (unknown keys pass through, problems quote the input's shape, key order is the output's order), and parity is easier to reach with the same model. Hot paths (resolved items, pixels) use typed structs inside the engine. - One slice. Rejected: about 21,000 lines to port, and the parallel units need a settled skeleton first. A PR per unit was rejected too: about 30 PRs. Units merge into their slice branch instead.
- Moving the examples to format 2 at cutover. Rejected: D12's
actions.txtneeds the harbor with an action, whichmake-examples.mjsgives it. The Node tests read format-1 examples from git instead, until cutover. - Comparing new SVGs to old with a normalizer after R11–R15. Rejected as fragile;
PIXELLAB_LEGACY_SVGkeeps the old output exact until cutover.
Departures from the spec#
The fixture lives at
fixtures/sample.pixellab, notcli/test/fixtures/, sincecli/is removed at cutover (R10). Its format-1 copy, for the migrate tests, isfixtures/format1/sample.pixellab.PIXELLAB_LEGACY_SVG=1is a test-only switch that makes the program write today's SVGs, so R3 stays runnable innpm testafter R11–R15. It's read in one place, used only byparity.mjs, documented nowhere else, and deleted at cutover with a test that nothing reads it.The parity check also compares
serve --mcp'sinitializeandtools/listand an edit replay, beyond R3's four comparisons.As built, PR 1: the read commands' goldens are compared by the program's own corpus test (
crates/pixellab/tests/corpus.rs), since the engine crate can't run commands;crates/engine/tests/corpus.rscompares the SVGs and preview PNGs. The number casesjsnum.rsis tested against are committed (crates/engine/tests/data/jsnum.json, written bygoldens.mjs --jsnum), socargo testneeds no Node. Arustfmt.tomlsets 140 columns. The canonical text writes an unknown key after the known ones instead of dropping it, asserialize.mjsdid.gzip sizes within 1.5%, not R3's 1% (the owner's decision, 2026-10-21, at the parity gate). miniz_oxide's gzip can't match zlib's on every scene: at its closest setting (level 9) the owner's document has 3 scene totals 1.01–1.10% from zlib's and a 20-byte color row 3 bytes off. Raw sizes and every SVG, PNG and JSON result are identical. The parity check and the corpus test allow 1.5%, or 4 bytes on tiny values, and report the largest difference.
As built, PR 2 (from step 11's review):
serve --stdioopens a format-1 document too, so R8's migrate works through the app's door: every command that needs the document gives R9's problem, anddocument.migrateconverts it and reopens it in the same server (a site's legacysource/folder reopens as its new bundle).serve --mcpstill refuses a format-1 document. A panic in a command is an internal error for that call, and the servers keep their open docs.As built, PR 4: the program is bundled at
Contents/Helpers/pixellab, notContents/MacOS/pixellab(spec D10, steps 19 and 30): on a case-insensitive volumepixellabis the app's own executablePixelLab, so the copy would overwrite it.Locator.findProgram(),install.sh'sclaude mcp addline, the Agent window and step 30's smoke test use that path. Migrate… runs the program as a CLI (document migrate) and then restarts the server. Under the script hook only, the chat's PATH is the launching shell's rather than the login shell's, so the fakeclaude(a#!/bin/shthat runsnode) findsnodewithout asking the owner's login shell.As built, PR 3: an action's name is one move in a scene (validation refuses the same name with different moves, since its keyframes are
<scene>--<action>, the documentedanimationName); action rules sit inside@media (prefers-reduced-motion:no-preference), so under reduced motion nothing plays and noanimationendfires; elements that play the same moves share one class.docs/cli.mdis generated from the program'shelp --markdownfrom PR 3 on (scripts/docs-cli.mjs); the Node reference parity compares against isscripts/docs-cli-node.mjs, removed at cutover. The visual gate compares every SVG the owner's document publishes; preview-only scenes (thethumb-*scenes, which become D5's PNGs) are covered by the parity gate's pixel-identical previews. The hop's step-end jumps become hold keys one ms before each jump.As built, PR 6:
scripts/visual-parity.mjsand its test are deleted at cutover with the Node CLI, which they need to build the format-1 pages; the gate's report is in jlawcordova/jlawcordova-lab#34. Its--write-swappedcopy isn't kept: the site's change (D13) migrates its own document and repeats the swap with the commands that report lists (scene set --class,scene ambientfor the clouds,object ambientandobject action … hopfor the character's layer). After verify-change, migrate's SVG check (R8) has a test that drives a refusal through a test-only fault hook (PIXELLAB_TEST_MIGRATE_FAULT, compiled out of release builds), and the harbor example's manifest gives its SVGs a 128 KB budget, so the example checks without a budget warning.
If the owner wants any of these the spec's way, spec.md wins.
Risks#
- Byte parity is the riskiest part (U3, U5, U7):
Math.round,toFixed, number printing, key order and sort stability. Handled byjsnum.rsfirst (step 3) with cases drawn from the corpus, goldens from PR 1 on so each output unit is checked as it lands, the owner's document in the corpus, and the gate at step 10. If the gate shows a difference that can't be fixed in the port, stop and report before PR 3. - Sonnet units drifting from the Node semantics. Every unit keeps its suite's test names, the main session reads every diff, and escalation moves a unit to Opus after one failed round. The goldens catch what the tests miss.
- The main session as a bottleneck (nine O units). An Opus subagent takes a second O unit when two are ready.
- Parallel units colliding. The skeleton puts every module and signature in place first, and shared files belong to the main session.
document migratewriting in place. It checks byte-identical SVGs before writing, refuses on unsaved edits, and is tested on temp copies. Nothing in this change runs it on the real site checkout; the site's change does, on its own branch. Step 11's review covers it.- Proposals retired (D11). Tests cover old proposals moving, numbers carrying on and the chat card reading "Settled elsewhere". Only temp state dirs are used.
- Linux CI and the Node suites.
npm testhasn't run on Linux. If a Node suite fails only there, its portability is fixed (a path,zsh, mtime granularity), never skipped. - Watching by polling. The
sync.test.mjscases, the acceptance's stale scripts and a timing check (a poll of the owner's document under 1 ms) cover it. - The visual check's timing. Samples stay clear of step boundaries; the report keeps each mismatched frame for a look. If Chrome isn't found, the check fails, it doesn't skip.
- The release can't run without a tag.
package-program.shruns locally; the Linux build is compiled and tested bytest.ymlon every PR; the workflow's smoke test is the same command the acceptance pass runs. - The owner's install. After the main PR merges, the app opens only format 2 (Owner reviews). The PR says so, and the site's change is the place to migrate.
- The real site checkout is only read: by
document copy, bygit archive-style reads and by copyingpixel-art.css. Every test and script works on temp copies. - No Rust on this Mac blocks step 2 until rustup is installed (top of the plan).
Proof#
cargo fmt --checkandcargo testpass, with the ported suites'R…:names readable in the output.npm testshows# fail 0: until cutover the Node suites and the parity check over the fixture and both examples; after it, the repo tooling's suites.- The parity gate's report (step 10) over the fixture, both examples and a temp copy of the owner's document: zero differences, gzip within 1%.
- The visual gate's report (step 18) over every scene of a temp copy of the owner's document: every frame matching at t = 0, 24 times per loop, every 40 ms through the hop, and under reduced motion.
swift build --package-path appwith no errors;scripts/test-app.shpasses.scripts/build-app.sh, thenscripts/acceptance.shexits 0, withmigrate.txtandactions.txt, and the hero island's SVG byte-identical across the layers script as today.npm run docs:clileavesgit statusclean apart from the change's edits;npm run docs:sitebuilds with no broken links.scripts/package-program.sh aarch64-apple-darwin <dir>, thenpixellab --versionprints the version passed, and itsSHA256SUMSline checks withshasum -a 256 -c.cargo treelists onlyserde,serde_json,indexmap,miniz_oxideand what they bring in (R5).- No
findNode,runCli(node:orSettingsKey.nodeis left inapp/, and the bundled app starts on a temp document withPATH=/usr/bin:/bin(R21). - Snapshots in dark and light of the setup screen (no document, R9's problem with Migrate…, the alert), Settings, the Inspector's ACTIONS for an item and a layer, mid-play, and the timeline with an ambient loop, looked at, in PR 4, PR 5 and the main PR.