Two gaps the first post-migration decoder iteration exposed, both in the same
place — the loop ends before it can observe its own result.
1. Step 1 read notifications but never looked at the agent's own open PRs. It
opened #23, labelled the issue `state/needs-human`, and stopped; nothing in
the brief would ever bring it back to a red check. Next iteration it planned
to start a new question on top.
2. It called the merge resolved on `cargo build -p sylpheed-cli` — the crate the
conflict was in. The break was in `sylpheed-viewer`, which neither side of
the merge edited: one branch added a `ComposeOptions` field, the other had a
literal construction of it. The crate you just fixed is the one place the
breakage cannot be.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`decoder-loop.md` has had `## Each iteration` since 2026-09-04, ending in
7. Commit, `push-work`, open the PR, label the issue `state/needs-human`,
and stop.
`port-loop.md` has never had that section at all. It states the rules -- "you
cannot close your own work", "you move an item to state/needs-human", work
reaches main "through pull requests" -- but nowhere gives the STEPS that carry
them out. The Port agent was told the rule and never the procedure.
Two steps were missing entirely, and both are Phase 7 check items:
* READ YOUR NOTIFICATIONS first. They are polled and nothing pushes them, so
an agent that skips this never receives anything addressed to it -- and the
symptom is silence, not an error. That is GITEA-SETUP.md Phase 7 check 1.
* OPEN THE PR AND SET THE LABEL. That is Phase 7 check 2.
The second carries a trap worth naming in the brief rather than in a runbook:
`docker/port/bin/push-work` ONLY PUSHES. Verified by reading it -- no /pulls
call, no `Closes #`, no label; its refusals (no main, no force-push) are
intact. `propose-work`, which would do all three, is still unbuilt --
GITEA-SETUP.md's own words are "the other two thirds being manual is how they
get skipped". The capability is there (`pull_request_write` and `issue_write`
are both in the pinned MCP tool set), so this is an instruction gap, not a
permissions one.
Written to mirror the Decoder's shape while using the Port's own vocabulary --
a gate is an artifact, verify against captures rather than our renderer, state
the expected number first, `authored/` entries carry a `why`, never an invented
number where a measured one is pending.
Found by the Pi agent while answering a Phase 7 readiness question. My own
check had been decoder-only, which is why the asymmetry survived it.
Refs #11
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The two loop files ARE the prompts -- `sylph-port` and `sylph-decoder` read them
off the host at launch -- so the workflow change had to land here or it would
not reach the agents at all.
PROTOCOL.md gains four sections:
* Work items -- issues, milestones as bundles, the state labels, and that
`state/blocked` uses DEPENDENCY EDGES, never prose. A prose blocker is what
let a 1,227-line BLOCKED.md go stale.
* Messages -- an ask is a `kind/ask` issue, not a SendMessage. With the part
that matters: 🔴 NOTHING PUSHES. Notifications are polled, at the top of
every iteration, and therefore an agent must NEVER wait on an ask -- set the
edge, take the next item. The channel this replaces dropped 21 consecutive
messages to a stale session id and reported success each time.
* Pull requests -- one item per branch, `Closes #N`, and you may not merge
your own. Branch protection enforces it; the rule is written down so the
agent knows it, not so it depends on the agent.
* Each iteration, in order -- notifications, sync, one unit, hand over, stop.
Also: evidence a human must look at now attaches to its issue, and a blunt
"never commit game content, under any directory name" with the 545 MB that
prompted it.
The two briefs shrink 697 -> 298 lines. They had accreted five dated focus
blocks between them -- sole-focus orders, F1-F6 queues, one-off "merge this
branch on your first iteration" instructions -- which is a queue, and a queue
belongs in the tracker. What is KEPT is what outlives its bug:
* ask of any check, what would this still report if the feature were absent?
Three instruments passed a splash that never animated.
* the instrument must sit at or above the thing that can break -- the
InputEventAction / input-map miss.
* R1, and grep REFUTED.md before proposing.
* the .pe is primary and the database is somebody's analysis of it.
* the oracle is the real game in Canary, not any renderer of ours.
⚠️ NOT YET TRUE when this lands: the agents have no Gitea users, no API tokens
and no MCP server, so the issue tooling these briefs assume does not exist yet.
The agents are stopped. Setting that up is the prerequisite for restarting them.
Two new findings from the human, both about WHEN a title animation starts, and
both handed over rather than guessed:
F5 Does (A) SNAP the title to finished, or ACCELERATE it? The human says they
cannot tell and is right that they cannot -- a three-frame acceleration and a
one-frame cut look identical to an eye. Two routes that should agree: a
per-frame capture (acceleration shows intermediate alphas, a cut shows none)
and the code (assigning a target time and raising a rate multiplier are
different instructions). Their "looks more like a snap on multiple attempts"
is recorded as a PRIOR, not a result.
F6 The title's sweeping white glow -- ptloop01/ptloop02, the blue PCB-like lines
-- starts only when the plate appears in the real game, and starts earlier in
the port. A lead from the exported declaration, mine and unverified: those
elements are keyed at t = 0, 70, 100, 238, 250 while the plate reaches full
alpha at 236, with pteff02 keyed at exactly 236 and ptlogo_back2eff and
ptcopyright at 238. 236-238 is a synchronisation point in the declared data
and a human just reported a behaviour change there. Flagged AGAINST itself
too: 238...250 looks equally like an exit ramp -- ptcopyright uses that shape
and starts nothing -- and the sweep lives in a nested .rat leaf with its own
timeline.
F6 bears on clock: "shared" and on F4: if a title element does not move until
the plate arrives, either the declared data says so and our keyframe reading is
wrong, or something at the plate's arrival STARTS it, which is a mechanism
nobody has proposed.
And the process change, which is the human's and outlives this item:
"attacking the 'whole' mission was too big for them to handle. Split the given
missions and tasks into even smaller tasks which they can tackle and give to
a human for feedback."
PROTOCOL.md gains "Work in units a human can check in a minute". A milestone is
not a unit of work, it is a bag of them. A unit is right-sized when it ends in
something a person can judge in under a minute WITHOUT READING ANYTHING, and
each one states its question, what the human looks at, and what it does NOT
cover. Do one, hand it over, stop -- an unverified fix under a second change
makes a regression two-variable.
The evidence for the rule is this week: the splash sat through a whole milestone
and took one day once scoped to "does it animate?". The bar is a HUMAN check,
not a green tool -- three instruments passed a frozen screen.
I wrote "whether the game snaps both clocks forward" into yesterday's F4 and the
human asked which clocks. There are none: authored/flow.json sets
`clock: "shared"`, so the title's two composited builds -- build 4 the artwork
(finishes t~=118) and build 2/3 the plate (full alpha t=236) -- run on ONE clock
started together. Left standing, that phrasing sends an agent hunting for a
second clock this corpus says does not exist.
Corrected in both briefs and in the playtest page, marked as a correction rather
than silently edited.
And the question is better than I first framed it. `clock: "shared"` is
AUTHORED, and the port's own plate-arrival-halves.md calls it "not falsified...
not confirmed to better than ~20 % either", with an unresolved anchor
disagreement inside one binary: the reconciliation picked t=118 while
settle_time() returns 160 and the boot prints "settles at t=160".
So F4 is a TEST OF THAT PREMISE, and the discriminator is observable -- press (A)
early, while the wordmark is still building in, and watch the ARTWORK rather
than the plate:
advances the shared clock -> the artwork SNAPS to finished
only forces the plate -> the artwork KEEPS ANIMATING its build-in
Both briefs now say to answer F4 before building on `shared`, and tell the port
not to choose what "jump" means.
"Menu walk and navigation is fine. Video skips too. Extras open. New Game
shows new game intro video." -- 2026-09-02
P5 is done. Its gate was "a human clicks through it", the retro said it had been
waiting on that and not on code for the whole milestone, and it has happened.
PORT-MISSION.md updated. The NEW GAME gap is accepted as-is.
Four findings, three of them the Decoder's:
F1 THE MENU REPEATS ON A HELD DIRECTION AND OURS DOES NOT. One step per
deflection was authored as the conservative choice because nobody knew; a
human has now watched the real game and it repeats, "at a medium pace... slow
enough to see which item is selected". That settles the existence half of H1
against us. The RATE is still unmeasured and must not be guessed -- the
description bounds it and supplies no number. Decoder measures initial delay
and repeat interval as frame counts; the port implements the mechanism and
waits for the numbers.
F2 THE SFX ARE TOO LOUD BECAUSE THERE IS NO MIX AT ALL. Measured: confirm
-17.7 dB mean / -0.0 dB peak, 3 dB hotter in mean than the music and 6.4 dB
above move. No volume or gain value exists anywhere in export/ or authored/,
so every clip plays at unity on one bus. Decoder: is per-cue or per-bus gain
on the disc -- the cue table is the obvious place and cue 1103 is already
decoded. Port: gains at PLAYBACK as data, and explicitly NOT normalisation in
the exporter, which destroys the relationship between clips and cannot be
undone by a modder.
F3 SOMETHING IS MISSING ON THE TITLE SCREEN. The export carries one music file
and the port plays nothing on the title. Which cue does the title play, and
is there a sting on the plate or on accept? A negative needs a positive
control: find the menu's cue by the same method first.
F4 (A) SKIPS FORWARD THROUGH THE BOOT AND WE IMPLEMENT TWO OF THREE PRESSES.
In the game: skip video, reveal plate immediately, accept plate. The middle
one is missing here. Whether the game snaps both clocks forward or only
reveals the plate is a question, not a detail -- and it is a cheap second
route to the plate-arrival question, since a press that skips to the plate
says where the game thinks the plate belongs.
H3, the plate delay, is ACCEPTED -- "feels the same... sufficient". Left
unattributed rather than closed green.
"Looks good! Cannot notice any obvious difference from the actual game.
Mark logos as done." -- 2026-09-02
Not "the check passes": a person compared the port against the real game and
could not tell them apart. That is the oracle, and it is the strongest result
this port has produced. The sole-focus order is lifted; both agents return to
their milestones.
The fix was one word -- pose_at ASSIGNED the settle instant instead of clamping
to it, so every query returned the settled pose whatever the clock said. The
same line manufactured the false green: the capture harness shoots after two
frames, so it was photographing t~=2 units, which looked settled only because
everything looked settled. The 0.01 % agreement that closed H2 was measured
through the accident. One bug produced the defect AND the evidence of its
absence.
Verified here before it went to the human, by film rather than by claim:
motion 16.4 % -> 27.7 %, distinct luma states 26 -> 43, the publisher ramp 6
steps -> 13 in one continuous run, and the developer splash's interrupting
0.50 s freeze gone. The publisher trajectory rises to a peak and settles back --
the crossfade signature.
The port then closed a gap motion-census names in its own header ("a wrong ramp
that moves every frame passes here") with a shape check pre-registered from the
disc, measured off a film, on a non-overlapped strip, in ratios so the texture
divides out: rise:last declared 1.20, measured 1.20 exact.
Kept as the standing lesson, because it is the fourth instance: an instrument
that sits below the thing under test cannot see it fail. Ask of any new check
what it would still report if the feature were entirely absent.
Explicitly NOT claimed: P5's gate is "a human clicks through it" and nobody has
said the milestone is met. The briefs say so, and say not to record it on the
human's behalf.
The decoder's end-to-end pipeline work returns to normal priority rather than
being dropped -- it is what decides whether the port's 60 units/s matches the
game. The ramp is now right in SHAPE and unverified in DURATION.
A human on a GPU at ~140 fps: "the logos just switch, there is no animation at
all." Measured from a real boot with --film at 0.05 s, then per-frame change:
splash moves 1.30 s of 7.95 s = 16.4 %
publisher splash 0.30 s of motion, then 3.20 s FROZEN
developer splash 0.35 s + 0.25 s, then 2.40 s FROZEN
distinct luma states in 7.95 s 26
A 45-unit build-in cannot be drawn in 26 states, and a fade does not hold one
picture for 3.20 s. The frame counter says 24.8 fps achieved; both are true --
the port is DRAWING 25 times a second and CHANGING almost never.
🔴 Why every check passed, which matters more than the bug:
frozen sweep drives the clock BY HAND -- proves the renderer can draw
pose N, never that the poses are drawn in sequence
settled compare 0.01 % against the capture -- a screen frozen 84 % of the
time matches a settled reference PERFECTLY, that is what
frozen means
achieved fps counts frames DRAWN -- the same pixels 25x/s scores
identically to animating
Every one measured throughput or a pose. None measured CHANGE. Same shape as
InputEventAction bypassing the input map: the instrument sat below the thing
that was broken, so the break could not appear in it.
tools/motion-census closes the class. It measures change and nothing else, and
its --selftest asserts it separates a fade (97.4 % moving) from a switch (2.6 %)
from a frozen film (0.0 %) -- a detector that cannot tell those apart would
report the same green line on all three.
Both briefs: this is the SOLE focus. The port reproduces before changing
anything and gates every fix on a film rather than a still. The decoder maps the
whole pipeline end to end -- disc bytes, the game's per-frame update (does it
interpolate between keyframes or hold?), what is submitted per frame, and what
Canary does to it before a capture records it -- delivered as a SERIES, not a
settled value.
The port should also record the refutation against itself: H2 reads ANSWERED on
the strength of the frozen sweep. The mechanism half stands, the blur is a baked
companion texture. The behaviour half does not.
Both are pushed. The decoder's carries the R1 register reclassification and
tools/stale-instrument; the port's carries the two input fixes, verify-input
and BLOCKED H1-H3. Each branches from that agent's own tip, so it is a
fast-forward on the line they are already on -- and the port must merge before
touching input or it will re-derive a fix that is already asserted.
A human played the port on real hardware for the first time (2026-09-01) and
found four things. Two were port defects, fixed. Two are open and are now both
agents' focus: the PRESS (A) plate arrives late, and the splash fade/blur is
weaker than the game's.
Their verdict on method is the reason this is a brief change and not a ticket:
"the agents were essentially guessing and trying to copy what one would see,
but while they did get close it still is not quite right"
Close-but-not-right is the signature of reproducing APPEARANCE instead of
deriving MECHANISM. So the Decoder's focus block asks, in order: is there a
post-process pass at all, what is it, where do its parameters come from -- and
only then what curve. Both routes, dynamic (GPU state, shader constants, render
targets; add logging to Canary, it is theirs read-write) and static (.pe, the
DB, the paks), with each fact labelled by which produced it.
TEMPORAL-VERIFICATION.md is the other half, and it generalises past the
splashes. We have been photographing the game at time t, and t is never the
same twice: emulator speed varies with host load, Canary presents at ~28.1 fps,
the capture path costs a variable 0.1-10.8 s, and a long-lived x11grab stream
degrades and then freezes. The register already carries FOUR refutations of
exactly this shape. The replacement rule: record a film, not a photograph;
align by CONTENT, not by clock, and report the lag as a measurement rather than
minimising it away; prefer ordering, counts, durations and shape over any value
at a wall-clock instant; anchor on an event; report achieved fps against
requested fps.
Also into both briefs: the input set. The port had no joypad binding for (A) or
(B) and nobody noticed for a whole milestone, because --script sends
InputEventAction, which BYPASSES the input map -- so every check asserted the
code below the map and nothing about the map. The Decoder is asked to DECODE
the full set the game reads rather than discover it by pressing buttons; the
Port is told input is verified at the device level or not at all.
And both briefs now point at the R1 register reclassification, because two of
the ten re-opened entries land on this focus: "the declared keyframe timeline
reproduces the captured splash" is 🟡 our-reader, and the rest() pair is open
in BOTH directions -- while the two splashes are the only screens that reach
that fallback.
Both agents read the protocol, their mission and the shared tooling from their
OWN checkout, and both work on topic branches -- so without an explicit sync
they follow whichever version of the rules existed when the branch started.
Found concretely: tools/audio-capture and two protocol revisions were on main
while the decoder worked for hours from a branch that had neither. The port had
merged on its own initiative and did have them, which is exactly the kind of
divergence nobody notices until the two disagree about what the rules say.
Three defects, all mine, found by checking instead of assuming.
**They never exchanged a word.** SendMessage=0, ListAgents=0 across both new
sessions. PROTOCOL.md specified in detail what a message MAY and MAY NOT do and
never said how to send one or that the other agent was addressable -- they knew
that last time only because the human told them directly, and rebuilding with
fresh volumes wiped it. Policy without mechanism is prose. Now documented with
the two addresses, a worked example, and an instruction to introduce themselves
on the first iteration rather than waiting to have a question.
**The decoder lost the disc and the ISO.** They used to arrive inside the project
mount and silently stopped when /work became a clone. Silently is the word: the
disc-gated tests SELF-SKIP without SYLPHEED_DISC and report green, so a whole
test suite would have passed while measuring nothing. Both are now mounted
explicitly, the ISO at a stable path so run-canary does not depend on host
directory names.
**Both agents shared one Claude state directory.** They share the host's
~/.claude, and once both working directories became /work they resolved to the
same projects/-work/ -- two supposedly independent agents writing to one place,
which undoes the point of separate checkouts. Each now has its own volume, seeded
once from the host with credentials only, so a token refresh writes locally and
neither can corrupt the host's auth.
Also widened the pacing rule. It banned ScheduleWakeup by name; the decoder then
scheduled itself an hourly cron job -- not harmful, but the same instinct that
ended a run yesterday, through a door I had left open. Now: no self-scheduling by
any route.
Mount audit after the changes: shared and intentional are the exchange volume and
the read-only credential seed. Everything else -- repo, Claude state, cargo,
target, canary, disc, ISO -- is per agent or one-sided.
The last structural fix for the collision class that has bitten three times. Both
containers now clone the repository into their OWN named volume instead of
bind-mounting a human's working tree, so an agent's local git config cannot
capture a human's commits, a credential helper cannot leak a container-only path
onto the host, and a `git add -A` cannot sweep another party's in-flight files.
Cloned once at startup and never auto-pulled: pulling under a running agent
moves files out from under whatever it is mid-edit, which is the same bug again.
Accepted knowingly: Claude Code keys per-project memory off the working
directory, so moving off the host path starts that memory empty. The corpus in
docs/ is the memory that matters and it travels with the clone.
Other changes:
* docker/agent -> docker/decoder; the launcher is sylph-decoder. Roles, not
"the agent", now that there is more than one.
* /reborn is gone -- one repository now, so the port reads HANDOFF from its own
checkout rather than through a live read-only mount of someone else's tree.
* Canary mounts separately at /canary; it stays a fork tracking upstream.
* A shared `sylpheed-exchange` volume at /exchange, with tools/ on PATH so
`share` is available in both.
* The decoder's credential file gets the .host-copy treatment the port already
had -- `credential.helper=store` rewrites by rename-over-target, which is
EBUSY on a bind mount and reports a fatal that is not one.
* Budget split deliberately: decoder 5 cpu / 6 GB, port 3 / 4, leaving room for
the planned Referee. "Half the host" was right when there was one agent.
Prompts move to docs/agents/ and are rewritten around the protocol: the oracle
is the running game, dynamic RE stays with the decoder, each iteration must
attempt to refute one claim of the other, and neither may verify its way out of
its own role.