Written to be read cold by a session with no context: what is established versus what is someone's claim, which of the four actors the gate actually binds, and what is in flight but unpushed. The transferable part is §7 -- one failure shape recurred five times in two days across two agents and the assistant: a property inferred from something ADJACENT to it rather than tested directly. Protection from a settings page, reachability from a DNS record, a label count from a grep that matched the function definition, Gitea's host from a DNS lookup, and a clippy version from rustfmt output parity. The last is the sharpest: rustfmt 1.8.0 and 1.9.0, nine months apart, both emit 774 hunks here, so formatting parity carries no information about which clippy ran -- and the conclusion drawn from it (that CI's green was cached or ungated) was wrong in a way that would have sent someone chasing a phantom.
12 KiB
Handoff — 2026-09-06
For a fresh session on a different machine. Written to be read cold: it assumes you know nothing about what happened, and it says what is established versus what is someone's claim.
1. What this project is
Two things, and they are easy to confuse:
- The long game — Project Sylpheed: Arc of Deception — Reborn, a clean-room native port of an Xbox 360 game in Rust + Bevy. Values and behaviour come from the original by observation and static RE only — never copied decompiled code. The oracle is the real game running in Xenia Canary, never any renderer of ours.
- The work of 2026-09-04/06, which is what this document is about — moving the project's working surface onto a self-hosted Gitea, and getting CI to produce an answer for the first time.
If you only read one other file, read PROTOCOL.md.
2. The actors — four, and only two are constrained
| what it is | identity | constrained by the gate? | |
|---|---|---|---|
| the human | directs everything; the only approver and merger | fabi |
n/a — is the gate |
| the Pi agent | supervisor, runs on the Pi beside Gitea | sylph-pi <pi@sylpheed.local>, no Gitea account |
❌ has gitea admin; can mint tokens, edit rules |
| you (this session) | runs on the x86_64 desktop, holds the push credential | commits as the human ⚠️ | ❌ same carve-out, different mechanism |
| the two loop agents | Decoder (disc→meaning) and Port (disc→playable) | sylph-decoder, sylph-port, Write not Admin |
✅ |
⚠️ The looping agents are stopped and must stay stopped until their images carry the Gitea MCP and their issues are ready. Starting them early gives them a brief telling them to read notifications and open issues with no tool that can.
📌 The two supervising agents are the ones the gate does not bind. That is
written into GITEA-SETUP.md Phase 2 deliberately. Neither has a distinct Gitea
identity; both operate through the human's credential or an unlinked git author.
That is a known, unresolved wart, not an oversight.
3. The machines
desktop fabi-Hyrican-PC |
x86_64, 12 core, 15 GB | agent containers, the repo clone, the only push credential |
the Pi raspberrypi.fritz.box |
aarch64, on the LAN | runs Gitea (published through a VPS), the CI runner, and the supervising agent |
Gitea git.mc02.dev |
1.25.5 | resolves to a hosted address — that says nothing about the origin, which is the Pi |
File transfer between them is by hand. The Pi agent has no push credential,
so its work arrives as git bundle over scp, which the human runs. This is the
weakest link in the setup: four round trips on 2026-09-04, each needing a
password twice, and once a guessed filename that was wrong. A write:repository
token on the Pi scoped to pi/* branches would remove it — a deliberate
decision, deliberately not taken yet.
⚠️ CARGO_BUILD_JOBS=4 and limited -j. A full-parallel build has
OOM-crashed the desktop. One emulator process at a time; Canary runs muted.
4. Where things stand
Merged to main
Nothing since 59649824. main is protected — enable_push=false, 1
approval required, merge and approvals both whitelisted to fabi only. Verified
behaviourally: a real push was refused with pre-receive hook declined, as the
repository owner.
Open pull requests
| head | what | why not merged | |
|---|---|---|---|
| #10 | agents/gitea-mcp @ a3d99ada |
the Gitea surface: MCP wiring, gitea-protect, the runbook, two PROTOCOL rules |
waiting on the human. Merge-on-merits: docs and tooling, no .rs |
| #14 | fix/clippy-lints @ d8807c4f |
73 clippy lints → 0 across four crates, Closes #13 |
same |
🔴 Both need the admin override to merge. fabi authored them and is the
only whitelisted approver, and Gitea bars self-approval — so they can never reach
one approval. block_admin_merge_override is false deliberately so that door
stays open. Do not tick it.
Open issues
Nine work items, all state/approved, awaiting the loop agents:
#1 F1 measure (decoder) ←blocks— #2 F1 implement (port)
#3 F2 disc gain (decoder) ←blocks— #4 F2 playback gains (port)
#5 F3 title cue (decoder)
#6 OPTIONS re-propose (port)
#8 F5/F6 findings (decoder) ←blocks— #7 F5/F6 port work (port)
#9 f6-out-of-sample residue (decoder)
Four infrastructure issues:
- #11
state/proposed— WASM. Work exists past its shape; see §6. - #12
state/proposed— rustfmt: 774 hunks, deliberately deferred. - #13
state/approved— clippy;#14closes it. - #15
state/proposed— the lint gate floats@stable.
Unmerged branches
agents/gitea-mcp 11 → PR #10
fix/clippy-lints 16 → PR #14
auto/frame-blend-draw-path 495 the Decoder's corpus — returns via #8
auto/port-p6-audio 366 the Port's work — returns via #7
The last two are the reason #7 depends on #8: port/scripts/boot.gd cites
docs/re/ pages that exist on neither its own branch nor main.
tools/port/check-citations --for-merge counts 19 such citations.
5. CI — it produced its first answer on 2026-09-05
Before that: 23 runs cancelled, 2 waiting, zero successes. The workflow
described GitHub's hosted fleet (windows-latest, macos-latest, and a
--target x86_64 cross-compile) on a one-runner aarch64 instance, so the run
never reached a terminal state — the checks were unfinished, not red.
Now, three jobs, all terminal:
| job | state | |
|---|---|---|
| Native — linux | green, three runs running | check build test clippy all pass on aarch64; 207 passed, 0 failed, 14 ignored, 30 suites |
| WASM — Web | red | #11 |
| Formatting | red | #12 — 774 hunks, identical on main |
The workspace being portable to ARM was unknown before this and is now
established. The disk exhaustion that broke the test link is retired: 46 GB
reclaimed, / at 55%.
⚠️ /var/lib/docker is still on the Pi's 117 GB SD card while a 916 GB SSD sits
at 16%. This will recur. The fix is data-root in daemon.json plus a Docker
restart, and it wants a moment when every container going down is fine.
6. 🔴 What is in flight and NOT pushed
The WASM work. The Pi agent has ba6c5da, bundle at
/tmp/sylph-wasm-compile.bundle on the Pi, base d8807c4. It makes
Check WASM compile exit 0. #11 turned out to be three stacked blockers,
each invisible until the previous was gone:
getrandomneeds--cfg getrandom_backend="wasm_js"and the feature — its own error says either alone is insufficient;sylpheed-formatsdeclaredtokioas a normal dependency it never used, draggingtokio/full→net→mio, which does not build for wasm32;bevy_eguineeds--cfg web_sys_unstable_apis.
Two things the human must decide before it lands:
- #11 is
state/proposedand this goes past its stated shape. It was written around the getrandom error alone. The tokio removal is a change to another crate, not CI config — look at that one specifically. - It will not turn the job green. It unblocks two steps that have never run:
jetli/trunk-action@v0.5.0andtrunk build --release. The action's bundleddist/index.jsnamesx86_64-unknown-linux-gnuonce andaarch64never, so it will fetch an x86_64 binary onto the aarch64 runner. Upstream does publishtrunk-aarch64-unknown-linux-gnu.tar.gz— so this is "the action cannot find it", not "trunk is unavailable on arm". Replacing the install step means picking a version and a method: a decision, not a slip-in.
7. The method lessons — the most transferable part
One failure shape recurred five times in two days, across two agents and the human's assistant. Every instance is: a property inferred from something adjacent to it, rather than tested directly.
| what was inferred | from what | how it failed |
|---|---|---|
main is protected |
the settings page | merging ignores the push whitelist; both agents could have approved each other |
| the desktop can't reach Gitea | curl being refused |
that was a permission prompt, not the network |
| the tool creates 12 labels | grep -c '^mklabel' |
one match was the function definition |
| Gitea is not on the Pi | a DNS lookup | it is, published through a VPS |
| CI's clippy == mine | identical rustfmt output | rustfmt is output-stable by design; clippy moves lints between groups |
The last one is the sharpest. rustfmt 1.8.0 and 1.9.0, nine months apart,
both produce 774 hunks on this tree — so formatting parity carries no
information about which clippy ran. From it I concluded CI's green must be cached
or ungated ("the frozen splash again"). It was neither: collapsible_else_if is
warn on 1.92.0 and allow on 1.98.1, which is what the runner has.
The check that worked, every time, was counting the same thing with the same tool against a baseline.
Three rules now in PROTOCOL.md, each earned:
- A finding reaches
mainbefore the code that cites it. - A check may only soften against a condition it can test — can this branch tell the difference between "not yet" and "no longer"?
- If you are writing the softening in the same commit as the check, the thing you want is an issue, not a flag.
And the ordering constraint that is not obvious: the cheap-looking fix for #12 is the expensive one. A whole-tree reformat before #7 and #8 return would put a conflict in every file of 861 commits and make the reviews those items exist to enable unreadable. Measured: 133 files carrying 81% of the debt cannot collide; the real blocker is 21 files. Land #7/#8 first, then sweep once.
8. Resuming — concrete first steps
git fetch origin
git switch fix/clippy-lints # PR #14's head, d8807c4f
python3 tools/gitea-protect --verify # expect: protection holds. rc=0
Then, in order of what is actually blocking:
- Merge #10 and #14 — human, with the admin override. Everything else is
downstream: the loop agents clone
main, which has none of this. - Decide #11's scope, then fetch
/tmp/sylph-wasm-compile.bundlefrom the Pi and push it. - Decide the trunk-action replacement (§6).
- Then Phase 7 of
GITEA-SETUP.md: start the decoder alone, watch one full iteration — notifications polled, a PR rather than a bare push, no merge button — before starting the port.
Credentials, all on the desktop, all chmod 600
~/.sylph-git-credentials write:repository — the push credential
~/.sylph-gitea-token-decoder the decoder agent's, four scopes
~/.sylph-gitea-token-port the port agent's, four scopes
~/.sylph-claude-token long-lived Claude auth for the containers
~/.sylph-gitea-api-token (write:issue) lives on the Pi, because that is
where tools/gitea-setup runs. Never paste any of them into chat.
⚠️ This desktop was updated to rustc 1.98.1 on 2026-09-06 to match the
runner. Anything verified here before that ran on 1.92.0 — the fmt counts (774)
and test counts (207/0/14) matched CI exactly and therefore carry; a clippy
result from before does not.
9. Standing constraints
- Never commit game content, under any directory name. 545 MB reached a
branch on 2026-09-04 under a name the ignore list did not happen to mention.
.gitignorenow describes the shape (/export*/, media by extension). - Agents never merge, never push to
main, never rewrite history. One force-push was authorised, once, to fix commit authorship before merge — a recorded exception, not a precedent. - The Explorer shows static data only — the ISO, the embedded PE, savegames. Never anything generated by a Sylpheed run.
- Never self-screenshot; ask the human to capture. Ask before a
watch-and-verify
--uilaunch. - Never judge emulator crash or stability from a Bash-launched run.