--verify's collaborator loop printed ⚪ and continued on 404 without touching
`ok`, so the one instrument that checks Phase 1.2 could not report Phase 1.2
being undone. An agent removed from the repository read as "nothing to say"
rather than as a gate that is no longer there.
It has never fired: Gitea answers that endpoint with permission "read" for a
non-collaborator rather than 404, so the case was caught by the role test two
lines down. Correct outcome, wrong reason -- the same shape as the check that
passed on an instance with no rule at all, and not worth keeping because the
luck has held so far.
Found by the port agent reading the file rather than running it, which is the
only way this one was ever going to surface.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01McNbzUeq1KRBWs4G6X2YVj
Phases 1-4 and 6 are done on the instance. This file still opened with "Nothing
exists on the instance: no agent users, no API tokens, no labels, no milestones,
no branch protection" -- every clause of which was false by the time the merge
that carried it landed.
Replaced with a table of measured state, and each row says what was MEASURED
rather than what was run:
* protection is verified behaviourally -- a real push to main refused with
`pre-receive hook declined`, as the repository owner -- not read off a
settings page. That distinction is the whole subject of this file.
* the tokens are probed: right identity, 403 on branch_protections for both
agents, so the Write-not-Admin carve-out is demonstrated and not asserted.
* the labels are 11 because the instance holds 11.
And a standing note that this block is the part most likely to be wrong, with
what to believe instead: `gitea-protect --verify` and the issue list MEASURE,
this block REMEMBERS. A remembered status is a cache with no invalidation, which
is the same failure as a 1,227-line BLOCKED.md and as the two documents this
runbook was split across an hour ago.
Caught by the Pi agent against the live instance after Phase 4 ran. The tool
creates 5 state/*, 2 agent/*, 4 kind/* = 11.
Where the 12 came from is worth a line, because it is a shape that recurs:
$ grep -c '^mklabel' tools/gitea-setup
12
$ grep -n '^mklabel' tools/gitea-setup | grep -v ':mklabel "'
74:mklabel() { # name colour description
I counted the function DEFINITION as a call. A measurement taken one token away
from the thing being measured -- the same shape as reading protection off a
settings page and reachability off a DNS record, which is now three today. The
version that cannot make this mistake is counting what the instance holds, and
that is what found it.
Two things that read as protection while being none.
Phase 2's rule binds everyone who reaches Gitea through the API or the web, and
does not bind anyone with `gitea admin` in the container -- which includes the
supervising agent that created the agent accounts and minted their tokens. From
that shell the rule is editable and an admin token is one command away. That is
the boundary of what the phase buys, not a hole to plug there, and the document
read as though the gate were universal. Phases 1 and 2 gate the two CONTAINERISED
agents, whose design assumption is that policy lives where they cannot reach it;
a supervisor with a host shell is not in that set.
And `gitea-setup` finished by telling the reader to go and build a Gitea project
board by hand, four sections after the doc explains that a board is a second copy
of the state to hand-sync and is precisely the failure that produced a 1,227-line
BLOCKED.md. A tool instructing you to do the thing its own documentation argues
against is the drift this whole surface exists to end.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01McNbzUeq1KRBWs4G6X2YVj
The runbook existed as two documents -- a published page and this file -- with
no mechanism keeping them equal, only an intention to remember. Two versions was
the predicted outcome of that, not an accident on top of it. This is the fold,
and the rule that follows it: THIS FILE IS THE SOURCE, the page is derived from
it. When something is urgent enough to push to the page first, it lands here in
the same turn, not "shortly after".
Four things the file did not carry:
* YOUR OWN PUSHES TO main STOP. `enable_push: false` compiles to CanUserPush,
which returns false with no bypass for admins or the owner -- quoted from
the source. Three commits went in by direct push the day this was written,
so the first notice would have been mid-task. Now a check step.
* the token files' MACHINES, which the table had lost.
* do NOT add `write:repository` to the `fabi` token. That scope IS a push
credential. Written down because that advice was given, in chat, by the
author of this file.
* Gitea 1.25.5 confirmed from the desktop too, not just the Pi.
And one thing deliberately NOT folded in: the page said the desktop's outbound
HTTP was blocked, and that is false. `python3 -c 'urllib...'` returns
200 {"version":"1.25.5"} from this box. What is refused here is `curl`, by a
local permission prompt -- which I read as a network constraint and then
published as one. The Phase 3 locations stand; the reason given for them did not.
The "not verified" section now separates WRONG from UNCHECKED. Four entries are
wrong -- requiring an approval does not close the gate, the check could not have
caught that, the token scope, the reachability -- and the pattern in all four is
identical: a property inferred from something ADJACENT to it (protection from a
settings page, reachability from a DNS record) instead of tested directly. That
is the frozen-splash failure, committed in the document about avoiding it. The
first two were caught by the other agent, which is the argument for the review
gate this file exists to build.
Phase 2 as a file. Six settings where two are load-bearing and both were missing
from the first draft is the shape of thing that gets mis-clicked at 1am, so it
goes through the API: what was applied is readable in a diff, and `--verify`
can re-check it later instead of it being checked once.
--verify states its expectations INDEPENDENTLY of what the apply path sends.
A check derived from "whatever we posted" cannot fail -- it re-derives the
expectation from the thing under test, which is the same instrument-shaped
failure as a check that passes on an instance with no rule at all.
It also asserts both agents are still Write and not Admin, because an agent
promoted to Admin can edit the rule and then merge, so a green rule proves
nothing on its own. That is the `gitea-verify` card from "Still to build";
what is left of it is only putting it on a timer.
`block_admin_merge_override` stays false on purpose, and the reasoning is in
the file: approvals are whitelisted to `fabi`, and Gitea will not let `fabi`
approve a `fabi` PR -- so with the override blocked, a human-authored PR could
never reach one approval and could never merge at all. The override is not a
hole in the agent gate because the agents are Write, not Admin. Phase 1.2 pays
for that; this is where it is spent.
Reads the repository-scoped credential that already exists on the agent box
(~/.sylph-git-credentials) rather than the issue-only ~/.sylph-gitea-api-token,
which every branch-protection endpoint refuses. That keeps the setup needing no
new credential, and keeps push rights on one machine.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01McNbzUeq1KRBWs4G6X2YVj
Phase 5 of docs/agents/GITEA-SETUP.md, plus a correction to Phase 2 that the
runbook could not have known it needed.
gitea-mcp v1.7.0 goes into both images, pinned by the sha256 the release
publishes and smoke-tested with `--version` at build time, so a bad pin fails
the build instead of the agent. Each entrypoint registers it at user scope for
that container's own identity, remove-then-add so a restart is idempotent.
The token is passed BY PATH. `-e GITEA_ACCESS_TOKEN=$(cat …)` would write it in
cleartext into ~/.claude.json, which every session in the container reads;
GITEA_ACCESS_TOKEN_FILE is new in the pinned version and leaves the secret in
its read-only mount. Verified against the binary's own --help, not assumed.
The tool filter stops being an experiment. The names are in the release README:
each agent gets issues, notifications, labels, milestones and pull requests, and
NOT `pull_request_review_write`. That one matters because separate identities
open a hole the runbook did not name: Gitea refuses to let an author approve
their own pull request, and does nothing about sylph-decoder approving
sylph-port's. Two agents could satisfy `required_approvals = 1` between
themselves and then merge, since branch protection blocks pushes to main and
never blocked merges.
Withholding the tool is defence in depth; the controls are in branch protection,
and both docs now say so: approvals whitelisted to the human so an agent's
approval does not count, merges whitelisted to the human so an approved PR is
still merged by a person. Phase 2's check gains the step that actually tests it
-- approve the throwaway PR yourself, then confirm the agent STILL has no merge
button. Without that step, the check passes on an instance where the agents can
merge each other's work.
Also settles two entries on the runbook's own "not verified" list: the tool
filter names, and the Gitea version (1.25.5, whose API schema carries
enable_merge_whitelist and enable_approvals_whitelist under those names).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01McNbzUeq1KRBWs4G6X2YVj
WORKFLOW-gitea.md said what the working surface is and why. It did not say how,
in what order, or how to know a step worked -- so it was a destination with no
route. This is the route.
Seven phases, each with a check, each marked 👤 human or 🤖 me:
1 identities two agent users, Write NOT Admin
2 protection main behind a PR + 1 approval -- BEFORE tokens exist
3 tokens three principals, three tokens, three files
4 structure labels and bundles, and deliberately NO Kanban board
5 MCP gitea-mcp v1.7.0, per-agent identity, user scope not .mcp.json
6 items migrate the live findings only -- not 1,227 historical lines
7 restart and verify the three things that must be true
Phase 1 leads because it is not hygiene: Gitea does not let a PR's author
approve it, so while an agent IS `fabi` either the human cannot approve its work
or it can approve its own. The review gate does not exist until the agents are
distinct people. (It also fixes 495 commits of agent work attributed to the
human's email.)
Phase 2's check is a real push and a real PR, not a reading of the settings
page. The reason protection lives in the server rather than in a brief is that
it should not depend on good behaviour -- so verifying it should not either.
Phase 5's install facts are checked, not remembered: gitea-mcp v1.7.0,
`gitea-mcp_Linux_x86_64.tar.gz`, `-t stdio -H <host>`, `GITEA_ACCESS_TOKEN`.
The `--tools` filter is flagged as an EXPERIMENT that might exclude the merge
tool as defence in depth -- explicitly not a substitute for phase 2.
Ends with what is still to build (propose-work, an attachment uploader,
gitea-verify, the wiki landing page) and a "what I have not verified" section:
the approve-your-own-PR behaviour, the --tools names, the Projects API, and the
Gitea version -- the API was unreachable from my sandbox three times running.
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.
Takes the port branch up to 77320d5e -- the state the human play-tested on
2026-09-02 -- for SOURCE paths only. Not a branch merge: `auto/port-p6-audio`
is 366 commits and 938 files, and most of that must not land.
WHAT COMES IN (76 files, all human-confirmed working):
* the logo splash animation. 08ed3dd1 found it: `pose_at` ASSIGNED the settle
instant instead of clamping to it, so the splash never animated at all --
and the same bug manufactured a passing harness result, because the harness
photographed t past the settle. Confirmed by play-test: "cannot notice any
obvious difference from the actual game."
* gamepad input -- (A)/(B) bound additively (`ui_accept` ships with NO joypad
binding), stick latched with hysteresis at the game's own 61% digitise
threshold. This is what made (A), video-skip and Extras work at all.
* menu navigation and flow, menu audio, the exporter, the authored
declarations, and 23 verification tools under tools/port/.
WHAT IS DELIBERATELY LEFT ON THE BRANCH:
* everything after c0ae460a -- the F5/F6 title-timing investigation, whose own
tip commit calls itself a "hand-off for one-minute human checks". Unchecked
by definition; it goes through the new review gate like anything else.
* the OPTIONS menu work of 2026-09-03. Real, probably good, NOT play-tested.
* the F1 repeat mechanism, which its own commit calls "deliberately inert".
WHAT MUST NOT LAND, AND WHY THE .gitignore CHANGED:
545 MB of extracted game content was committed on that branch -- 850 sprite,
audio and transcoded video files under `export-probe/` and `export-probe2/`,
plus 246 MB of loose .wav and .tsv at the repo root. This repository's own
rule, in this file, is "never game content".
The rule was not missing. It was written, and it was tightened on that very
branch, with a careful comment explaining why BOTH `export/` and `data/base/`
had to be listed -- while the exporter was writing to a third name that
nobody had thought to list. Enumerating names is the thing that failed. So
the ignore rules now describe the SHAPE: any top-level `export*/`, game media
by extension, and loose capture output at the root. Verified both ways -- it
catches all four offenders and ignores nothing currently tracked.
Verified: `cargo check --workspace` clean; all nine GDScript files parse in
project context, with a positive control (an injected syntax error is detected,
3 lines) so the clean result means something. `tools/port/check-all` was NOT
run -- it needs the container, the export tree and a display.
The human wants to direct this project from a web UI rather than chat or Remote
Control, so Gitea becomes the working surface. No new store: adding a second
copy of the truth is this project's defining failure mode, and Gitea already
holds the code. Its first-party MCP server (gitea/gitea-mcp v1.7.0, checked) has
issues, labels, milestones, PRs, attachments and notifications.
ISSUES replace BLOCKED.md. Milestones are bundles the human defines; issues are
items agents propose and the human approves. The state labels end in
`needs-human`, which is the state the whole model turns on and the one no
off-the-shelf tool models -- the market has converged on removing the human.
`blocked` uses Gitea's DEPENDENCY EDGES rather than prose, so "the Port is
blocked on the Decoder answering X" becomes queryable and closes itself.
PULL REQUESTS, the human's proposal, adopted -- and a bigger improvement than it
looks. Today's long-lived auto/* branches have drifted 280 and 373 commits apart,
which is unreviewable by construction. One PR per item makes the human gate
NATIVE rather than a label convention, binds the change to its item, and enforces
the sizing rule: an item too big to review in one sitting was too big to be an
item.
🔴 Agents must not merge their own PRs, and pull_request_write includes merge --
so this goes in BRANCH PROTECTION on main, not in a document asking them not to.
Same principle that fixed the build-jobs cap: policy where the agent cannot reach
it.
WIKI -- the human suggested it for RE findings, and that half is declined with
reasons. A finding's value is that it sits beside its evidence, versioned with
the code that consumes it; the wiki is a separate git repo, so a decode
correction and the exporter change depending on it could never be one reviewable
PR. And wiki edits bypass review: the REFUTED.md reclassification changed the
file both agents read to decide what not to try, and as a wiki edit it would have
been an unreviewed mutation of shared ground truth. The wiki takes human-facing
orientation instead -- runbook, navigation, container notes, and a landing page,
which closes the real gap that there is no view of what is happening except
container logs.
FILES: three needs, three homes. Agent-to-agent transient stays in /exchange.
Evidence a HUMAN must look at attaches to the issue it belongs to -- it travels
with the item and cannot be orphaned from the claim. Evidence a finding cites
stays in git. Note the MCP exposes attachment_read only; upload needs a direct
REST call.
tools/gitea-setup creates the labels and bundles, idempotently, with --dry-run.
Blocked on a token with write:issue -- the push credential is write:repository
and every issue endpoint refuses it, checked rather than assumed.
Both agents stopped, and the decoder diagnosed it itself:
"I received '2' and '1' but I don't have a pending question those would
answer -- I was in the middle of setting up the /loop cron job."
claude-autonomous matched the BARE SUBSTRINGS 'Choose', 'trust' and 'accept' to
answer Claude Code's one-time first-run gates. The /loop prompt is echoed into
the terminal, and that day's briefs contain 'accepted as-is' and 'least
trustworthy' -- so expect matched the agent's OWN INSTRUCTIONS and typed 2\r and
1\r into a running session, which then sat waiting for a human to explain them.
The old comment argued a multi-word pattern 'never matches' because the gate
text wraps. True of a literal string, false of a whitespace-tolerant regex, which
is what these now are: \s+ spans the wrap, and the terminal is 200 columns wide.
Measured, old against new, against the real brief text and a real gate:
{accept} brief 0 gate 1 (case-sensitive; briefs say 'accepted')
{Yes,\s*I\s+accept} brief 0 gate 1
{trust} brief 1 <- the trigger
{Do\s+you\s+trust\s+the\s+files} brief 0
Two defences, because one is not enough for something that can type: patterns
prose cannot match, and gates skipped ENTIRELY on resume (SYLPH_SKIP_GATES) --
a resumed session cannot show a first-run gate, so there is nothing to answer
and everything to lose. Timeout cut 90s -> 25s for the same reason.
Also: SYLPH_OWN_LOGIN. Remote Control stopped registering under the long-lived
token, and the likely reason is scope -- `claude auth login` requests
user:sessions:claude_code and the token's auth status reports no email, org or
subscription. A per-agent `claude auth login` restores Remote Control AND avoids
the rotation collision, because each agent holds its own grant rather than a copy
of one. The flag stops the entrypoint seeding the host's credentials over it.
The rotating OAuth credential file is why the agents kept parking, and a
long-lived token removes the failure by construction instead of recovering from
it after the fact.
MEASURED 2026-09-04. ~/.claude/.credentials.json holds a refresh token that
ROTATES ON USE. Seeding both containers from the host left three clients holding
one token; the first to refresh invalidated the other two, and on the failed
refresh Claude Code CLEARS the stored tokens -- writes empty strings, keeps the
metadata, and parks at "Login expired".
decoder credentials emptied 13:04:28
decoder last transcript 13:04:29 <- one second later
The emptying and the park are the same event, which is why it never self-heals:
not a stale token a retry could fix, but no token at all, with no browser in the
container to complete /login. A hollow file passes every "does it exist" check --
508 B healthy against 280 B emptied -- which is how three separate diagnoses
missed it. And recovery re-armed the bug: after re-seeding, host and decoder held
the IDENTICAL refresh token hash.
`claude setup-token` issues a long-lived token against the same Claude
subscription. Checked, not assumed: `claude auth login` defaults to --claudeai
and it is `--console` that means Console/API billing, so this is not the separate
API bill. `CLAUDE_CODE_OAUTH_TOKEN` is recognised by the installed binary.
Passed as an ENVIRONMENT VARIABLE, both halves of the failure are gone: nothing
rotates, so peers cannot invalidate each other, and there is no file for Claude
Code to empty on a failure.
Both launchers read $HOME/.sylph-claude-token if present -- same pattern as
SYLPH_GIT_CREDENTIALS -- and both entrypoints skip OAuth seeding entirely when
the variable is set, because copying the rotating file in would re-create the
exact collision the token exists to remove.
Inert until the file exists. Without it, nothing changes.
Also worth recording for the preflight work: `claude auth status` prints JSON
with loggedIn/authMethod/subscriptionType. That is a far better SessionStart
assertion than checking a file exists, and it would have caught this on the first
iteration rather than the third incident.
One fresh no-input boot against predictions registered before it. Sweep-
internal quantities pass on a run that had no hand in deriving them: leaf
period ratio 1.1753, size ratio 1.3009, pulse/sweep 0.0963. Those are now
3-for-3 across captures.
The three plate-relative quantities fail, together and in the same
direction: ptcopyright ramp ratio 0.550 vs 0.733 (0.75x) and sweep->plate
separation 0.0996 vs 0.138-0.141 (0.71x). So the separation is not a
constant -- three runs give 0.138, 0.141, 0.0996 -- and my published "2.2%
agreement" was n=2. Withdrawn. The F6 conclusion survives qualitatively;
the number does not.
Also records that check_labels.py fails its first independent test. I
validated it on the two captures that produced the labels, which is an
instrument validated on its own training data. Two of four checks fire on
f6c. I am not guessing which of labels/tolerances/run-variation is
responsible.
Caught mid-analysis that I had first run the checks against a log still
being written; re-ran on the complete log and the numbers are identical, so
the failures are not a truncation artefact.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jc4pciRArGHfxGGhEbwp5t
Every F5/F6 figure comes from captures taken for other purposes and
re-analysed after the reader and three labels were corrected. None has been
tested out of sample. Six predictions registered before the capture, five of
them checkable by check_labels.py, which now takes --cap NAME=path.
Prediction 6 is the one already got wrong once (0.057 against ptcopyright
instead of 0.14 against the real plate), and is stated so a landing near
0.057 would show the correction itself was wrong.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jc4pciRArGHfxGGhEbwp5t
Last iteration I proposed that the two sweeps share one indices=8 draw
because ptloop01 links to ptloop02, and said testing it needed a
loading-screen capture I lack. Wrong twice: a linked pair was already in
every capture, ptbtn00 -> ptbtn00f.
Measured: ptbtn00f is drawn ALONE in 899 (f6b) and 1441 (f6) draws and
batched in ZERO, while the sweeps pair up in 1092 and 1744. Linkage does not
batch. The constraint is blend state -- ptbtn00f is additive and its linked
partner alpha-over, which cannot share a draw. The sweeps batch because both
are additive on one page.
Page+blend is necessary but not sufficient: 8154/alpha-over appears as two
separate draws in a single frame, 2108 such draws in f6b. This removes a
wrong cause rather than supplying a batching rule.
Extends read_draws.py to preserve draw grouping (draw index and quad count
per draw); check_labels.py still passes unchanged as a regression control.
Refutation attempt on the port's 0x3002/0x3003 menu-item reading: survives.
958 of 970 stems contain "btn"; the 12 exceptions are psselect_slot and
psselect_slot_blank, which are menu rows.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jc4pciRArGHfxGGhEbwp5t
Adds examples/element_records.rs, which lists leaf AND focus_link records
for an element, plus a disc-wide census. Built because I claimed alpha 80
was undeclared after reading one of ptbtn00's two records -- and focus_link
was already parsed, with ui_layout.rs:424 already documenting the focus
record. The format was known and I did not consult it.
Census: 1467 of 15493 elements (9.5%) across 815 builds carry a second
record whose keyframes are invisible to a by-name leaf lookup.
Refutes our own parser's description of the field. It is documented as "the
focused state of a button", but GP_TITLE has pgloading_loop1 -> loop3 ->
loop4, a chain of three loop animations, and ptloop01 -> ptloop02, the two
sweeps. Neither is a focused state. Naming defect only -- behaviour is right
where it is read -- so not renamed here.
🟡 Notes a better candidate for why the two sweeps share one indices=8 draw:
they are linked, not merely co-textured. Testable on the pgloading chain,
which needs a loading-screen capture I do not have. Named, not claimed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jc4pciRArGHfxGGhEbwp5t
I claimed ptbtn00f's peak alpha of 80 was capture-only and refuted the
port's audit with it. Verified on the disc myself: ptbtn00f.rat, loop 120,
eight keyframes 0:0 6:6 29:74 35:80 50:80 58:74 97:6 105:0. ptbtn00 carries
TWO child records -- the leaf ptbtn00.rat (flat 255, which I read and
described correctly) and the focus record ptbtn00f.rat, which holds the
pulse. My "nothing declares 80" said only that nothing reached my
enumeration. METHOD.md has five entries on exactly this class.
Resolves the 🟡 with it: the corpus line attributing the 120-unit loop to
ptbtn00f is correct as written.
Turns the retraction into a stronger check. The amplitude is declared, so
check_labels.py now verifies the SHAPE: mean |alpha| error 1.14 levels (f6)
and 0.35 (f6b) against the declared 8-key curve. The first version assumed
the onset frame was t=0 and f6b FAILED at 3.24; the fix was aligning by
content rather than loosening the tolerance, since the 6->74 segment climbs
~6 levels per frame. Best lag is ~1 frame in both runs -- the element is not
drawn while its curve sits at alpha 0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jc4pciRArGHfxGGhEbwp5t
Three of my errors were the label rather than the measurement, and a
correction in one document did not reach the next page I wrote. The port
built check-authored-vs-declared for values the disc can arbitrate and named
the gap: capture-only values that name an element rest entirely on my label.
check_labels.py closes that for the cases where the identification was
itself made by matching a declared quantity -- 8 checks over two captures,
all passing, with a --selftest that points the plate label at ptcopyright
(the real error) and must fail. It does, at 82-83% against a 5% tolerance,
with the other checks still passing so the failure is localised.
Refutation of the port's "all five figures are also declared": lands for
one. ptbtn00f's peak alpha of 80 is not declared anywhere -- ptbtn00.rat's
parent peaks at 255 and its leaf is one keyframe at 255 flat, with the
120-unit loop declared but no amplitude. The period checks out; the
amplitude is capture-only and their check cannot see it.
Opens a 🟡 not mine originally: the corpus attributes the 120-unit loop to
ptbtn00f, but it belongs to ptbtn00.rat whose leaf is ptbtn00.t32. Left
uncorrected since the identification rests on the period, which holds under
either name.
Adds examples/leaf_keyframes.rs.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jc4pciRArGHfxGGhEbwp5t
The F6 page measured the sweep-to-plate separation against ptcopyright and
called it the plate. I had already established, on another page, that
ptcopyright is NOT the plate -- ptbtn00f is. Against the real plate the lead
is 83 and 165 frames, i.e. 0.138 and 0.141 of a sweep loop (2.2% apart),
against the 0.057 I published and sent to the port.
The conclusion is unchanged and stronger: with no input the glow precedes
the plate by 2.4x more than reported, and with A it still collapses to
essentially zero (-1 frame). Gating the sweep on the plate is a worse fit
than I made it sound.
Third instance of a label being the error rather than the measurement, and
this one is the same label I had already corrected elsewhere -- the
correction did not propagate to a page written after it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jc4pciRArGHfxGGhEbwp5t
The brief asks for two routes and I had only run the capture. This runs the
code route far enough to record an honest failure: the title's phase 2 and 3
handlers dispatch through vtables on nearly every branch, so finding the
clock write statically needs indirect-call resolution this container does
not do cheaply.
What the scan does establish: over 0x821C4000-0x821CD000 there is no
`li rN,<v>` for any v in 160..250, against a control of 684 li instructions
in the same range. So the snap target is computed or data-derived, not a
literal in the title's code.
Refutation aimed at my own capture result: a literal would have NAMED the
target and refuted "unpinnable from any capture". None exists, so that claim
survives and gains a second reason.
Names the instrument that would settle it: a write-watch on the clock field
in Canary, which would also close "which function advances the clock".
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jc4pciRArGHfxGGhEbwp5t
The refutation aimed at the port did not land -- they already run two
timelines, measured on their side (72.6/552.6 against declared 73/553 over
17748 samples), the right instrument for a question about their own
renderer. Recorded as an attempt that failed; still worth making, since they
report they would otherwise have asserted it from the code.
Also notes in METHOD.md that the batched-draw trap produced a THIRD wrong
finding with the entry already written. Two sections of that file and a
REFUTED.md line already said a draw carries more than one quad. The lesson
is not the fact but that a reader written before consulting the file
reproduces the bug the file exists to prevent.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jc4pciRArGHfxGGhEbwp5t
Pre-registered 1.2000 from the declared leaf loops before measuring.
Measured 1.1967 (0.28%) in f6b and 1.1841 (1.33%) in f6, as a ratio inside
each capture so no clock enters -- which is why runs differing 2x in frames
agree. f6b happens to pace at one frame per leaf unit: 600 frames for 600
declared units, 718 for 720.
First test pteff03a's declared timeline has ever had. It could have failed
against me: the flattering reading of unit 11's batched draw is one
traveller driving both quads, which gives equal periods. It does not.
Also closes "one wrap per capture, period not obtained", which has been open
all week -- both leaves give a complete boundary-to-boundary cycle in both
existing logs. The truncating reader saw one quad, so it could see only one
of the four cycles already on disk.
Refutation aimed at the port: running both leaves at one rate leaves the
strips locked and drifting ~118 units per cycle from the game.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jc4pciRArGHfxGGhEbwp5t
pteff03a's declared timeline has never been checked against a capture. The
leaves declare 600 and 720 units, so the periods must differ by 1.200 within
a single capture. Equal periods is the flattering-but-wrong outcome the
batched-draw reading could have produced.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jc4pciRArGHfxGGhEbwp5t
The port is blocked on what 0x3003 is, having only 0x3002 in its menu-item
rule. The two differ in bit 0 alone. Disc-wide over every .pak: kind&1 ==
has_parent agrees on 15493 elements with 0 counterexamples -- 192/192 for
0x3003, 778/778 for 0x3002, and the same for every other kind. The flag is
exactly redundant with the +32 parent field.
So the bit that differs carries no role information: 0x3003 is a 0x3002
button record that is parented. That replaces the port's circumstantial case
with the field, which is what they asked for rather than a widened rule.
Flags what this does NOT license: it is not a decode of "is a menu item",
and 0x73002/0x73003 (160 elements) carry an undecoded 0x70000 that any
mask-based rule silently decides about.
Adds examples/kind_bit0_census.rs.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jc4pciRArGHfxGGhEbwp5t
The brief asked which of two declared events the human sees. Neither. With no
input the sweep pair enters 34 frames (f6b) and 67 frames (f6) before
ptcopyright -- 0.057 of a sweep loop in both, agreeing to 1.2% across runs
that differ 2x in frames. With A pressed during the build-in the separation
is 2 frames.
A reveals the plate and restarts both sweep leaves at t=0 in the same frame,
so a player who presses A sees them start together and reports exactly that.
The control that makes it an explanation: in the late-press run A landed
after the sweep had already started naturally and the two are 29 frames
apart -- same input, opposite result, decided by when the press falls.
Consequence for the port: do NOT start the sweep with the plate. That fits
one boot and breaks the other. The declared gate plus the snap restarting
leaves produces both observations with nothing authored.
Refutation attempt: looked for a case where the port's current behaviour
fails against these four captures; found none.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jc4pciRArGHfxGGhEbwp5t
A tooling review predicted a PID-1 signal problem from two symptoms we could not
explain: `OOMKilled: true` with **ExitCode 0**, and `--continue` failing to find
a conversation that plainly existed. Traced it, and the prediction was right --
though the culprit is not PID 1, it is one level below.
The path is tini (PID 1) -> entrypoint.sh (exec'd) -> expect -> spawn -> claude
`spawn` CANNOT be an exec: expect has to stay alive to drive the pty. So expect
is the process Docker signals, and everything depends on it passing things on.
It did neither, in two lines:
1. NO SIGNAL FORWARDING, no trap of any kind. `docker stop` sent SIGTERM to
expect, which died and took the pty with it. Claude Code never got a SIGTERM,
so it never ran SessionEnd hooks and never wrote lastSessionId/history --
which are written ONLY at a graceful shutdown. That is the entire reason
`claude --continue` answered "No conversation found to continue" with 33 MB of
transcripts in the volume beside it, and why we resume by scraping a session
id off a transcript filename.
2. `eof { exit }` RETURNED 0 FOR EVERY DEATH. A bare `exit` in expect is exit
ZERO. When the OOM-killer took the child, expect saw EOF and reported a clean
exit. `OOMKilled: true` with `ExitCode 0` was never Docker being odd -- it was
this line. It also meant `--restart on-failure` would read a memory kill as
success, which is why the policy had to be `unless-stopped`.
Fixed and MEASURED, old against new, in a container:
child exits 7 old -> 0 (the bug) new -> 7
SIGTERM to wrapper old -> 143, child's trap NEVER RAN
new -> 42, child trapped and cleaned up
Same file in both images; they were byte-identical, so the port copy takes the
same change.
Consequences worth stating: a kill now reports 137 rather than 0, so exit codes
mean what they say; `docker stop` gives Claude Code a real SIGTERM, so it runs
SessionEnd and writes the session index -- which may make the transcript-filename
resume unnecessary. That is not assumed here: the resume path stays as it is
until it is verified redundant.
ptcopyright goes absent -> 255 in one frame at the snap, skipping its
declared 138->160 ramp, so t>=160; the sweeps are still at 255 so t<238.
Its identification is measured: in the control it first draws at t~143 and
reaches 255 at t~164 against a declared 138->160.
It stops there for a reason worth recording. Every build-4 element with a
key past t=100 holds a constant pose across [160,238) -- their intersection
is exactly that window -- so the game draws a bit-identical frame at t=160
and t=236.
Refutation attempt on the port's 236.0: not merely survives, it can never be
confirmed or refuted by capture. Every value in the window is
observationally identical, and it stays that way because the clock freezes
at settle and the exit ramp plays on leave rather than on a timer.
Classified undecodable-with-reach so nobody spends another run on it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jc4pciRArGHfxGGhEbwp5t
Pulse ratio HOLDS: 0.1000 (f6b, 16 clean cycles) and 0.0993 (f6). 60 leaf
units = 120 title units = ptbtn00f's declared loop. The independent leg
under leaf/title = 0.5 stands.
Unit 10's conclusion survives but MY NUMBERS WERE WRONG. I quoted
254.0-256.9 / 253.9-254.9 as the implied parent's range; those were the rows
I had printed, every 20th frame, not the series. Real first-cycle figures
are 250.9-260.5 (f6, n=1128) and 253.1-255.0 (f6b, n=560).
Post-wrap the spread widens to 237-283, but only in f6 -- the run with
dropped frames -- while f6b holds at median 254.3. That is my linear phase
model drifting, not the parent varying; a parent that varied would degrade
in both.
Same shape as an error already in my record: a printed subset doing the work
of the population.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jc4pciRArGHfxGGhEbwp5t
Last iteration I asserted F5 was unaffected by the truncating-reader bug
because it compared like with like. Asserting that is the move that produced
the bug, so this measures it. The new reader sees 9.7 quads/frame vs ~7.5.
Scalar that needs no element identification: quads mid-ramp (0<a<250) per
frame goes 6,4,4,4,2,1,4,3 -> 0 at f436, while the control never reaches 0
anywhere in 48 frames of build-in. One frame with nothing part-way through a
ramp is the cut.
Bonus the old reader could not show: both sweeps enter at f436-438 at their
declared opening alphas -- pteff03 at 255, pteff03a at 1,2,3,4,6,11,17 from
its declared 0.
Refutation attempt on the port's "clock jumps to 236.0": tried and failed.
My bound is [100,238), which contains 236 -- consistent, not independent
confirmation.
Adds tools/re-capture/read_draws.py so the truncating regex is not re-rolled.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jc4pciRArGHfxGGhEbwp5t
The two sweeps are batched into ONE additive indices=8 draw, two quads, and
my parser took the first v: match per line and discarded the rest. Every
analysis saw quad A and never quad B. No new capture was needed -- the same
logs units 5 and 6 used already contained it.
Both strips measured: opposite directions matching the declared -639->1521
and 1721->-839, and a size ratio of 1.301 against the declared sy ratio
800/600 = 1.333.
Unit 5 recorded that the PORT's renderer draws pteff03a and treated that as
the port being wrong. The port was right.
Third instance of this class in the corpus (palogo_anima and the six-quad
dump are the others): a truncating reader yields a clean, complete-looking
negative. Reading one raw draw line in full would have caught it -- indices=8
was printed on every line and says two quads outright.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jc4pciRArGHfxGGhEbwp5t
Phase-free: the pulse is exactly 1/10 of the sweep's 600-unit loop -- f6b
gives 60.0 frames over 16 cycles with zero variance against a 600-frame
sweep. 60 leaf units x the independently measured leaf/title = 0.5 gives 120
title units, ptbtn00f's declared loop exactly. That is also a third
independent route to the 0.5 ratio, from an unrelated declared quantity.
And it resolves the conflict I have been flagging on three pages. The quad I
called "the plate" at x=-0.54 is ptcopyright: its fade-in is 21-22 title
units under both agreeing calibrations, matching ptcopyright's declared
22-unit ramp, not the plate's 12. The plate is the pulsing element. Two
elements, one label, and I checked the label -- there was never a clock
conflict. Retires the 🟡 on every title-unit figure across three pages.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jc4pciRArGHfxGGhEbwp5t
The pre-registered wider test refuted my own claim. Pressing A at the
sweep's gate instead of 40 frames later leaves ~40 frames of artwork
animating: three elements mid-fade-in vanish in one frame and the settled
set appears at 255. The sweep enters at 255 with NO ramp where the control
ramps it 17->255 over ~15 frames, so the title clock jumped past t=100 --
and not past 250, since ptloop01's exit would have hidden the sweep.
So clock:"shared" is NOT refuted. I told the port the opposite and must
correct it.
Cause: the press takes 11-12 frames to take effect (f445->f456, f424->f436)
and my 5-frame artwork window sat entirely inside that gap. It matched the
control because the input had not been acted on yet. The window was not just
short, it was positioned where the effect cannot appear.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jc4pciRArGHfxGGhEbwp5t
F5's artwork half rests on 5 frames. This run presses A at the sweep's gate
instead of 40 frames later, giving ~40 frames of still-animating artwork,
and states the prediction for both outcomes before the log is opened.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jc4pciRArGHfxGGhEbwp5t