b2f089e7d3c109bba8971c7439b3b7fa21e5caa5
23 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
| b924d1e9cb |
feat: propose-work — push, open the PR, and move the issue, in one command
GITEA-SETUP.md has listed this under "Still to build" since 2026-09-04: `propose-work`, superseding `push-work` -- push the branch AND open the PR with `Closes #N` AND set the label, in one step. Today `push-work` does the first third; the other two thirds being manual is how they get skipped. PROTOCOL.md 145-149 requires all three: * branch `auto/<agent>/<issue#>-<topic>`, one item per branch * open the PR with `Closes #<issue>` in the body * label the issue `state/needs-human` and say, in one line, what to look at Until now those were three things to remember, and PR #20 had to add a warning to `port-loop.md` about the two that get forgotten. A rule enforced by memory decays; this makes the sequence structural. WHAT IT DOES NOT DO is reimplement push-work's refusals -- it CALLS push-work, so `main`, shared branches and force-push stay refused in exactly one place. Duplicating them would let the copies drift, and the copy that drifts is the one that matters. Three design choices worth stating: * THE ISSUE NUMBER IS DERIVED FROM THE BRANCH NAME, which PROTOCOL already specifies as `auto/<agent>/<issue#>-<topic>`. So a PR cannot cite a different issue than the branch was cut for -- a mismatch no reviewer would catch. `-i` overrides. * `-m` IS MANDATORY. PROTOCOL says an issue in `state/needs-human` must say what to look at and what pass and fail look like, "so a person can judge it in under a minute". Refusing without that line is cheaper than letting the label carry an empty promise and costing a human a round trip. * IT MOVES THE LABEL RATHER THAN ADDING IT -- other `state/*` labels are removed. Leaving `state/in-progress` attached makes the board lie about what is waiting on a person. The token is read from a file and handed to curl through a `--config` document on stdin: never an argument, never exported. Arguments are world-readable in /proc and this token can push. Verified that the mechanism actually delivers the header rather than silently dropping it -- with a bogus token the API answers "invalid username, password or token", while the same URL with no header returns the list anonymously, so the header is demonstrably being read. Also verified: issue derived from the branch (#42 from `auto/decoder/42-widget-census`), refusal without `-m`, refusal when the branch carries no number, `--dry-run` sends nothing, and repo/API derivation from the remote. `--dry-run` deliberately does NOT require a credential -- it exists so an agent can check the command it is about to run, and demanding a token it never sends would make the check unavailable exactly where it is cheapest. Identical in both agents' bin/ on purpose: the agent name comes from the branch, so there is nothing per-agent to diverge. UNVERIFIED, and stated as such: no end-to-end run. That needs a real token and a real issue, which this desktop does not have -- it is the second machine, and the agent credentials live on the agent box. Everything above the network call is exercised; the POSTs are not. Refs #11 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|||
| eccb789c0b |
docker: give each agent its own Gitea hands, and close the cross-approval hole
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 |
|||
|
|
1d1ffc5750 |
docker: stop the wrapper typing into live sessions, and support per-agent logins
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.
|
||
|
|
b305aa4a5a |
docker: support a long-lived Claude token, and stop the seeding fighting 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. |
||
|
|
108308057a |
docker: the expect wrapper swallowed both the signal and the exit status
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.
|
||
|
|
18b5b3d5f1 |
port: give the port container a GPU path -- it never had one
Reported as "the port has low FPS". Godot 4 renders through Vulkan and this launcher passed nothing through, so it fell back to lavapipe: software Vulkan, correct and slow. The decoder's launcher has had this block for a long time; the container that actually runs a renderer was the one without it. Same three cases as the decoder, including the part worth repeating: passing /dev/dri alone does NOT work for NVIDIA -- Mesa cannot drive the card and the proprietary userspace lives outside the image. It needs the container toolkit. The NOTE now prints the full repo-add sequence, because the package is not in Ubuntu's default repos and `apt install nvidia-container-toolkit` on its own fails with 'no installation candidate' -- which reads like the package is wrong rather than the source being missing. |
||
|
|
4ac23b94dd |
docker: auto-restart, and resume the session the agent was actually in
The decoder died mid-task and it took four separate findings to explain, each of which read as something else: 1. OOM-KILLED, REPORTED AS A CLEAN EXIT. `OOMKilled: true` with **ExitCode 0**. So `--restart on-failure` would treat a memory kill as a successful finish and leave the agent down -- the policy has to be `unless-stopped`. 2. THE JOB CAP WAS SET AND THEN REMOVED THREE LINES LATER. build-reborn has always exported CARGO_BUILD_JOBS, but a raw `cargo test --release -p sylpheed-formats` never reaches the wrapper. Adding `-e CARGO_BUILD_JOBS` to the launcher did not help either: the entrypoint recomputes and exports over it unconditionally. An explicit value now wins, and says so in the log. 3. THE MEMORY CONSTANT WAS WRONG. `mem_gib * 2 / 3` assumes ~1.5 GB per job; release rustc on this workspace needs ~2 GB, and 4 jobs in 6 GB is what died. Divisor is now 2. 4. `--continue` CANNOT RESUME AN ABRUPT DEATH, which is the only kind we get. It resolves through ~/.claude.json's per-project `history`/`lastSessionId`, and MEASURED mid-session both are None -- they are written at a graceful shutdown. A killed container never writes them, so `--continue` answered "No conversation found to continue" with 33 MB of transcripts in the volume beside it. Persisting .claude.json did not help, because the fields were never populated in the first place; that attempt is removed rather than left in looking useful. The TRANSCRIPTS are durable and named by session id, so the entrypoint reads the id off the newest one for its cwd and passes `--resume <id>`. Verified on both agents: each reattached to its exact prior session and appended to the same file rather than opening a new one. The /loop prompt is still passed alongside `--resume`, so the loop is RE-ARMED rather than merely restored -- a resumed conversation with no wake-up scheduled answers once and stops, which looks like resuming and is not. Restarting into the same death is guarded at the other end: a start less than 120 s after the previous one begins FRESH instead of continuing back into whatever killed it. That fired correctly during this work. On resume the agent is told it was restarted, that its in-progress work is uncommitted in the tree, that any build or capture it had running did not finish and its absence is not a result, and which wrapper to prefer over a raw release build. |
||
|
|
0cceaaf4a9 |
containers: an expired token could never be replaced
Credentials were seeded only when the container's copy was MISSING. So when a session expired, the file still existed, the copy was skipped, and restarting changed nothing -- the one recovery path a human has, re-logging in on the host, could not reach the containers at all. Now re-seeds whenever the host's copy is newer. Newer-wins rather than always-copy, because a container refreshes its own token mid-run and that copy may legitimately be the fresher of the two. Found when both sessions expired: host credentials at 16:30, containers holding 14:20 and 14:24. |
||
|
|
d2d2592c92 |
decoder: mount the disassembly DB and the flat VA image
The decoder had neither, and reported the gap precisely: four scripts in this repo READ /work/xenia-rs/sylpheed.db and nothing produces it, so the whole static PPC route was consumers with the producer missing. Both exist on the host and are now mounted read-only: the 586 MB database (25 481 functions, 851 classes with RTTI, EH tables, imports, 1.8M indirect-dispatch candidates) and the decompressed image. The image is the more useful of the two. It is a FLAT VA DUMP -- file offset = VA - 0x82000000 -- so reading a known address needs no XEX decrypt, no LZX, and no booted emulator. The decoder had independently recovered the same bytes by dumping /dev/shm/xenia_memory_* and validating against the GamePart table, which is good work and a sound method, but it noted itself that needing a running emulator is a bad dependency for something the entire static corpus rests on. It does not need one. Also recorded that an earlier claim the .pe was STALE was tested and refuted, so nobody re-litigates it, and that instructions.raw is an INT rather than hex. Written down as reference material, explicitly NOT a deliverable: they are read-only, they come from outside the repository, and a fresh checkout elsewhere has neither. Reimplementing the producer belongs in sylpheed-formats, and until it exists every static finding rests on an artefact this project cannot rebuild. |
||
|
|
7e643c242c |
audio: actually install the capture path I kept deferring
The audio work was three parts and I shipped two. The transcode-fidelity method and the pinned 5.1 downmix landed; the null sink -- the only one that answers "what does the GAME play" -- I deferred to "the next natural rebuild window" and then rebuilt both images four times without doing it. pulseaudio-utils is now in both, with tools/audio-capture wrapping it: a null sink is a real device as far as an application is concerned, so Canary and Godot open it normally and parec records what they emit. This unblocks the decoder's Q8. The cue-to-event bindings are currently a name match against the authors' own identifiers -- a plausible guess, not a measurement -- and capturing what the game plays on a menu move converts them. `audio-capture run` reports the peak level and warns when the capture is silent, because silence is the failure that looks like success: a WAV of exactly the right duration, full of zeroes, because the application opened a different sink. A duration check alone passes it, which is how a confident wrong number gets made. |
||
|
|
5a9b75e152 |
agents: they never spoke, the decoder lost the disc, and both shared one state dir
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. |
||
|
|
824b150be4 |
containers: fix volume ownership and make the clone guard survive interruption
Two bugs, both mine, both found by starting the thing.
**Volume mount points must exist AND be owned by the agent before USER agent.**
Docker seeds a named volume from whatever the image has at that path, ownership
included, and creates a ROOT-OWNED directory when the path is absent. Either way
the agent cannot write, and the failure surfaced far from its cause: "clone
FAILED", with no permission error anywhere in sight. The port's own Dockerfile
already carried a comment explaining this trap, which I then walked into for
/work and /exchange.
**The clone guard checked for a .git directory, not a usable HEAD.** A clone
interrupted partway -- the container was removed while one ran -- leaves a .git
with no commits, and a presence check then skips the retry forever and hands the
agent an empty repository that looks like a checkout. It now verifies HEAD, and
clones via a temp directory so a partial result never lands in /work at all.
Also: the port launcher's path defaults still assumed the old repo root, so it
mounted no disc; and the stale /reborn notice is gone now that there is one
repository.
Verified running: both agents cloned
|
||
|
|
c58196b795 |
containers: each agent clones the monorepo into its own volume
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. |
||
|
|
9fbb352ef0 |
monorepo: one repository for the decoders, the port and the corpus
Merges the Godot port into the reverse-engineering repository, preserving both
histories -- 1019 commits of corpus plus the port's 31, brought in by subtree
merge and then moved into place so git can follow each file across the rename.
The reason is not tidiness. The two-repo split forced the exporter to depend on
the decoders by pinned revision, and that created a whole class of failure that
now disappears: a sha reachable only from a topic branch, orphaned by a
squash-merge, breaking a fresh checkout silently at build time. It also forced a
live read-only mount of one agent's working tree into another's container, which
is why a contract file could move mid-iteration. With a path dependency, a
decoder change and the exporter change it requires land in the same commit or
not at all.
Canary stays separate: it is a fork tracking upstream.
New structure for the long term:
docs/game/ how the game is NAVIGATED -- menus, modals, prompts, alerts,
and in-game flight. Written so nobody rediscovers it. Mostly
open questions on purpose; the in-game tutorials are the
resource for the flight half.
docs/port/MODDING.md
modding as a constraint on the exporter TODAY, not a later
feature: one logical asset in one file (the disc splits nearly
everything, and resolving that is the exporter's job), names a
person recognises, PNG/OGG/OGV/JSON only, base-and-overrides so
re-exporting is always safe, provenance in every file.
data/base + data/mods
generated tree and drop-in overrides, both gitignored
exchange/ transient inter-agent files, deliberately outside history
docs/agents/ the team protocol
Both the README and the navigation doc lead with the correction that cost the
most: the oracle is the real game under Xenia Canary. Reborn's renderer is a
hypothesis under test, it has been wrong, and treating it as ground truth
propagated into three documents and both agents before a human caught it.
Scripted modding stays possible without being built: no screen name is hardcoded
in GDScript and there is no native code in port/, which is what Godot Mod Loader
needs to be able to substitute behaviour later.
|
||
|
|
590fc5bbff |
agent: tag the decoder states the port pins, and stop leaking credential config
Two fixes to push-work and the policy that goes with them.
**Tags.** The port's exporter depends on sylpheed-formats BY REVISION, so a
commit of ours is part of its build -- and the commit it pinned lived on one
auto/* branch and nowhere else. Deleting that branch orphans it; squash-merging
it is worse, because squash creates NEW commits, so main appears to contain the
work while the pinned sha becomes unreachable and the port stops building for a
fresh checkout. Silently, at their build, long after the breakage.
push-work now pushes with --follow-tags, which publishes annotated tags
reachable from the pushed commits, and MISSION.md says to tag whatever the port
needs. formats-pin-2026-08-29 at
|
||
|
|
bada97e989 |
agent: the loop must never stop itself
A run ended with a clean exit 0 while the display title read "Loop interval optimization", leaving four files uncommitted in the tree. Nothing crashed -- the loop was ended, and ending the loop ends the run: the container exits and there is no next iteration. The prompt said the agent did not NEED to arm a wakeup. It never said not to stop one, and an agent that reads about a pacing control will reasonably try to use it. Now explicit: do not call ScheduleWakeup at all, and if the cadence is wrong, say so and leave it to a human -- the interval is set outside the prompt. Recorded with the date and the symptom, because "the container exited cleanly" looks like a finished job rather than a self-inflicted stop. |
||
|
|
ddd220fa94 |
agent: stop the launcher claiming it cannot push when it can
The `loose` footer announced "cannot push -- no git credentials are mounted" unconditionally. It was written before the credential mount existed and then went stale, so with credentials correctly mounted it told the operator their work would be lost -- the exact failure the mount was added to prevent. It now reports what is actually true, and keeps the real warning for the case where the file genuinely is missing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
880e6ebe83 |
port: the container agent answers the questions, it does not build the port
Corrects the split I got wrong in
|
||
|
|
965c3f70fd |
port: make the Godot menu shell the agent's primary objective
Sets a new mission: boot the real disc through developer splash -> intro video -> title -> main menu -> submenus in Godot 4, interactively, with no gameplay, no 3D and no emulator. docs/port/MISSION.md defines it -- eight gated milestones, each finished by an ARTIFACT rather than by compiling, plus the Ready Room as an explicitly gated stretch goal with a one-iteration probe that decides go/no-go. GP_READY_ROOM is 1106 entries with 6 recoverable names and is ISL-scripted, so it is either a week or a quarter, and the agent must not start it on its own authority. Architecture, per the user's decision: the Godot project is INDEPENDENT of the Rust viewer and never reads a disc format. An offline Rust exporter converts the disc into open formats; Godot reads only those. No GDExtension, no Rust in the Godot project, and sylpheed-viewer is off limits -- it stays the human's verification tool with its static-data rule intact. docs/port/FORMAT.md specifies the open format, versioned, because modding is the port's second goal and that makes the layout a deliverable rather than a temp directory: JSON over XML (Godot parses JSON natively; its XMLParser is SAX), names never hashes, provenance in every generated file, and unknowns listed rather than guessed. The discipline the whole thing rests on is the derived/authored split. `export/` is regenerated wholesale and never hand-edited; `authored/` is hand-written and survives a re-export. Three things this milestone needs are NOT on the disc in any decoded form -- which button does what, paint order, and menu sound cues -- so they live in `authored/` with a stated `why`. Deleting an authored entry because the exporter can now emit it IS the measure of progress. `export/` is gitignored: it is generated from the user's own disc and this stays a clean-room repo. Container: adds a pinned Godot 4 (windowed under Xvfb for screenshots, plus a headless wrapper). ffmpeg already carries libtheora, which is the video target -- Godot 4 plays only Ogg Theora natively and the disc's ADV.wmv is WMV3/WMA Pro. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
797765a0b2 |
agent: move the loop prompt's memory into the corpus, and let it push
The autonomous agent's loop prompt had grown to ~9 000 words of accumulated
findings, refutations and traps. That is a symptom, not a style: it was doing
the job the corpus should do, in the one place that dies with the container.
Three concrete failures followed from it, and each gets a structural fix rather
than a louder instruction.
1. IT REPEATED WORK IT HAD ALREADY DONE.
The "do not revive" list and the method traps existed ONLY in the prompt, so
nothing pointed a fresh iteration at them. Extracted verbatim into two
tracked files:
docs/re/REFUTED.md 105 claims tested and dead, grouped by subject so a
grep for your noun finds the neighbourhood
docs/re/METHOD.md the traps already paid for -- controls, inference,
searching, reading data, runtime
Both are linked from INDEX.md, and the loop prompt now opens by requiring
them to be read. This is the fix for "re-derived something already known":
the knowledge is now where the next iteration looks, not in a context window.
2. IT FORGOT TO ARM THE NEXT WAKEUP.
The prompt tried to solve this by shouting at itself in the first line. The
real fix is to stop asking: `loose` now defaults to a FIXED interval (45m),
so the harness owns the cadence and a forgotten ScheduleWakeup cannot end the
run. SYLPH_LOOP_INTERVAL= (empty) restores self-pacing.
3. IT COULD NOT PUBLISH, SO THE WORK ONLY EXISTED IN THE CONTAINER.
New `push-work`, plus a read-only credentials mount
(SYLPH_GIT_CREDENTIALS, default ~/.sylph-git-credentials). It pushes the
CURRENT branch only, refuses anything that is not auto/*, and never
force-pushes -- so the consolidated line stays a human's decision and a
confused iteration cannot rewrite history. The loop prompt now requires a
push on every iteration that commits, rather than at the end of some longer
arc, which is exactly when a container dies.
The prompt itself drops from ~9 000 words to 85 lines and, more importantly,
stops accumulating: findings go in the corpus, and the prompt points at it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
7215cfd70a |
docker: two ways to talk to the loose agent
Remote Control, so a detached run is not a one-way trip: ./sylph-agent remote -> https://claude.ai/code/session_... ./sylph-agent attach -> the container's own terminal `loose` now starts Claude Code with `--remote-control <name>`, registering the session with your account so you can chat with it from claude.ai or a phone. The name is passed EXPLICITLY because the flag's value is optional — a bare `--remote-control` swallows the /loop prompt that follows as the session name. Off with SYLPH_REMOTE=0, renamed with SYLPH_REMOTE_NAME. The pty is forced to 200x50. A detached `docker run -t` gives 80x24, and Claude Code hard-wraps to the terminal width — which truncated the Remote Control URL to ".../session_01..." in the one place you actually need to read it, and made `docker logs` nearly unusable besides. `remote` waits up to six minutes and reports progress: registration lands a minute or two after launch, so answering "not found" immediately would be answering a different question than the one being asked. Verified: a loose run registers and `./sylph-agent remote` prints the full URL. `attach` is NOT verified end to end from here — `docker attach` refuses a piped stdin ("cannot attach stdin to a TTY-enabled container"), which is a property of this harness rather than of the container, so it needs a real terminal to try. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
cb6205ccfb |
docker: turn the agent loose — detached /loop, with the first-run gates handled
`./sylph-agent loose [task]` starts Claude Code detached with
--dangerously-skip-permissions, running /loop on loop-task.md: work the RE
backlog one item at a time, commit to auto/* branches, never push, record
withdrawn results rather than deleting them. `logs`/`attach`/`stop` to watch and
end it. Runs -d WITHOUT --rm so the transcript survives the container exiting —
for an unattended run that is the only record of what happened.
Two things had to be fixed for an agent to survive being left alone.
MEMORY CONTINUITY. The project is now bind-mounted twice: at /work, and at its
own host path. Claude Code derives its per-project state key from the working
directory, so running at /work handed the agent an empty project instead of the
accumulated one. Verified: a loose run now reports MEMORY=yes and reads back the
same branch and backlog as the host.
FOUR INTERACTIVE GATES, each a silent permanent hang with nobody at the keyboard
-- no error, no log line, just a container that looks healthy and does nothing:
theme picker hasCompletedOnboarding + lastOnboardingVersion. Re-fires
whenever the container's Claude Code is a different version
to the host's, which is the normal case.
folder trust projects.<path>.hasTrustDialogAccepted
bypass disclaimer answered in a pty by bin/claude-autonomous. It has no config
key by design -- it wants a person to accept once, and the
person did so by launching this.
fullscreen upsell fullscreenUpsellSeenCount. This one fires MID-SESSION, after
the pty wrapper has already handed over, so it cannot be
answered the same way.
Config key names were read out of the shipped binary's own strings, not guessed.
The pty wrapper matches SINGLE WORDS. Claude Code draws its UI with
absolute-column escapes between words, so the prompt arrives as
`Yes,\x1b[13GI\x1b[15Gaccept` and a multi-word pattern never matches -- failing
in a way indistinguishable from the wrapper not running at all. It stops
matching once the session is live so nothing later is answered by accident.
~/.claude.json is now mounted read-only at a staging path and copied in, so the
container cannot rewrite the host config. Credentials stay shared read-write in
~/.claude, which is what token refresh and memory continuity need.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
64f9d15610 |
docker: a container the autonomous RE agent can be turned loose in
Builds and runs both halves of the project -- Canary as the oracle, Reborn as
the port -- plus the dynamic-RE toolkit. Claude Code runs with
--dangerously-skip-permissions as an unprivileged `agent` user, because that
flag is refused under root.
Capped at half the machine, computed at launch: --cpus nproc/2, --memory half
of MemTotal with --memory-swap equal to it (no swap escape hatch -- a swapping
build thrashes the host, which is the failure the cap exists to prevent), and
build parallelism derived INSIDE the container from available memory rather
than core count, since a full-parallel build of this tree has OOM-killed the
host outright.
Three things the old box got wrong are fixed rather than reproduced: a real
toolchain (so rebuild_canary.sh's hand-relinking is obsolete), numpy and Pillow
(whose absence silently disabled every image oracle and looked like a logic
bug), and a display owned by PID 1 (so Xvfb no longer "dies on its own every
few minutes" -- it was being reaped because nothing owned it).
Verified end to end, not by inspection: the image builds, sylph-doctor is green,
`build-canary` links xenia_canary inside the container, and that binary then
runs -- guest memory and the JIT code cache appear in /dev/shm within 4 s,
1 205 log lines, gmem.py reads guest RAM, pad.py drives the file pad, and
screenshot captures the display.
Five environment defects found and fixed on the way, each of which fails in a
way that points somewhere else entirely:
* /dev/shm is `noexec` under Docker. Xenia maps its JIT code cache out of an
shm file, so it died with "Unable to allocate code cache generated code
storage / Cannot initalize processor" -- which reads as an address-space
clash, not a mount flag. Now `--tmpfs /dev/shm:rw,exec`.
* An unknown xenia flag HANGS rather than errors: ParseLaunchArguments calls
ShowSimpleMessageBox before logging is initialised, and that SDL dialog
blocks on XIfEvent forever. `--audio` (which the RE notes recommend) is not
a cvar in this tree; the symptom was a 10x10 window and an empty log.
* Named volumes come up root-owned unless their mount point exists in the
image, so the first cmake configure failed on pkgRedirects.
* Ubuntu 24.04 ships its own uid-1000 account, colliding with the host user.
* Ubuntu's spirv-opt has no --canonicalize-ids, so the shader step dies ~500
objects in; the launcher mounts the host's LunarG SDK instead of baking one
in, which also keeps shader output byte-identical to a host build.
Known limits, stated rather than papered over: on an NVIDIA host without the
NVIDIA Container Toolkit there is no hardware Vulkan (/dev/dri alone does
nothing for NVIDIA), and under lavapipe the emulator runs correctly but was not
observed to reach a rendered frame within a couple of minutes. gdb needs `sudo`
inside the container because the host's yama ptrace_scope outranks SYS_PTRACE.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|