A collaborative coding-agent orchestrator for atproto radl.app

Add tangled as a second forge, behind a per-project forge registry (#13) master

* Add tangled as a second forge, behind a per-project forge registry The forge stops being one global switch. `ForgeRegistry` selects an adapter per project by the host of its `gitUrl`, so a space can hold a GitHub project and a tangled project at once — and a missing GitHub token no longer disables implementation turns for every project, which was a live bug. GitHub is unchanged: still observation-only, its turns still push and open their own pull request with `gh`, `--pr` still required on submit. Every pre-existing dispatch and turn-socket test passes unmodified, which is the proof. Tangled is an atproto forge, so it moves two things: - Observation is a fold, not an API call. `TangledForge.getPullRequestState` resolves the `sh.tangled.repo.pull` record and the newest `sh.tangled.repo.pull.status` naming it, read straight from PDSes (tangled hosts no public Bobbin). The merge poller needed no changes at all. - The daemon opens the pull request, because tangled ships no CLI a container could run. `openPullRequest` is an optional adapter capability: where it exists a turn submits `--branch --commit` and the daemon generates the patch, uploads the gzipped blob, creates-or-appends the record as the acting agent DID, and stamps `links.pr` itself. A v2 appends a round to the same record, pinned with `swapRecord`. Turns push over ssh with a per-agent ed25519 key `radiald init` generates and publishes as `sh.tangled.publicKey`, mounted read-only per turn with a pinned `known_hosts` — never `StrictHostKeyChecking=no` — and no `GH_TOKEN`. No lexicon changed. One core change was needed: `format: uri` validation now accepts an `at://` URI, which is an absolute URI by RFC 3986 that WHATWG `new URL` refuses because a DID's colons parse as a port. `links.pr` carries the pull record's at-uri, since the appview's number is index-assigned with no public reverse lookup; the UI renders it as a chip rather than reading the record key as a pull number. `docs/adr-tangled-forge.md` records the decisions and, honestly, the seven things that could not be verified without a live knot. Co-Authored-By: claudebot.disnetdev.com (did:plc:n6ku5xddiuguwze3f356evla) <claudebot.disnetdev.com@noreply.radial> * Read tangled pull state from the public Bobbin API Review finding: tangled DOES run a public Bobbin instance at https://api.tangled.org — its read-only, unauthenticated XRPC appview. The first cut read `sh.tangled.repo.pull.status` records straight off PDSes, reasoning from the docs' note that tangled hosts no public Hydrant or Slingshot; that is true of Bobbin's upstreams and false of Bobbin itself. `TangledForge` now folds pull state through the appview, with the direct PDS fold kept as a fallback. Two things the live spike found, neither guessable from the docs: - `sh.tangled.repo.getPull` returns the record and NO state. Only the index endpoints carry it, so `getPullRequestState` reads `listPullsBy` keyed by the pull's author — which is exactly what its at-uri names. Reading state off `getPull` would have reported every pull open and never observed a merge. - On tangled an identity's `#atproto_pds` is frequently a knot, which answers 404 to every `com.atproto.repo.*` call (two of four DIDs sampled, including tangled.org's own, owner of `core`). For those repos the old direct fold could not work at all — so this is not an optimisation, it is the difference between observing a merge and never observing one. The fallback runs on an appview outage, on an index that has not caught up with a pull yet (a miss is "cannot say", never "open"), and on `api: false`. Reads of the agent's OWN pull records stay on its PDS: a Bobbin index miss there would not degrade, it would open a second pull request for a chain that already has one. Verified end to end against api.tangled.org: three real `tangled.org/core` pulls read back merged (with mergedAt), open and closed. Co-Authored-By: claudebot.disnetdev.com (did:plc:n6ku5xddiuguwze3f356evla) <claudebot.disnetdev.com@noreply.radial> * Identify a tangled repo by its own DID, not its owner's A repository on tangled has its own DID, distinct from the DID of the person who owns it: a pull's `target.repo` names the repository, while the `sh.tangled.repo` record — and its `repoDid` field, the only mapping between the two — lives in the owner's atproto repo. The adapter compared one against the other, so every identity question in it was answered `false` silently: `pullBelongsToProject` disowned a project's own pull (a fresh branch and a second pull record on every v2), and `openPullRequest` wrote a `target.repo` naming a repository no knot or appview has heard of. `projectIdentity` now resolves the owner handle, reads that owner's repo record (matching `value.name` or the rkey — both conventions are live) and returns its `repoDid`; a record without one is read as owner-identified. Pull records are written with the repo DID under both spellings live records carry. The direct PDS fold was scanning the repo DID, which resolves to a knot that 404s every `com.atproto.repo.*` call — and because a failed candidate was fatal, that took the owner's answer down with it, so `api: false` observed nothing at all. It now scans the pull's author and the repository's owner, skipping a repo it cannot read and throwing only when it could read none. The owner is not in a pull request URL, so `getPullRequestState` takes an optional project context that the merge poller and the dispatcher's reuse guard pass; GitHub ignores it. Verified live against api.tangled.org and the PDSes behind it, which also showed that Bobbin's `open` is not a ruling — its index reports `open` for pulls whose merge status records are plainly readable — so an `open` is now corroborated against the fold and a status record beats it. A `merged` or `closed` stands and costs no PDS read. Co-Authored-By: claudebot.disnetdev.com (did:plc:n6ku5xddiuguwze3f356evla) <claudebot.disnetdev.com@noreply.radial> --------- Co-authored-by: claudebot.disnetdev.com (did:plc:n6ku5xddiuguwze3f356evla) <claudebot.disnetdev.com@noreply.radial>