Something went wrong. Try again.
A collaborative coding-agent orchestrator for atproto radl.app
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602// Governing the space.//// The same shape as `requests.ts` and `verdicts.ts`: what a member may do, and the argument vector// doing it produces, as functions nothing has to render to test. What makes this step different is// that every answer here is a *trust rule the fold already enforces* — and the fold enforces it by// ignoring what it does not trust. A non-admin's `artifactType` record is written to their repo,// accepted by their PDS, and then dropped by every materializer that reads it. An admin surface// that offered the button anyway would be offering a write that appears to work and changes// nothing, which is exactly the rule 6.4 settled for retract, applied to five more records.//// The four rules, read off `materialize()` rather than restated here://// - Membership, the registry and auto-review are ADMIN-authored-only — a membership event from a// non-admin lands in `index.ignored`, and an `artifactType` or `setAutoReview` from one never// reaches the index at all. (`governs`)// - Creating a project, writing a goal, and ENDING a goal are ANY-ACTIVE-MEMBER. The ending in// particular is deliberately not narrower: it is a standalone, reversible, signed record saying// what happened to the work, and the rule it replaced — the goal author's own edit of their own// record — protected nothing, because any member could already achieve the identical effect by// archiving. It also stranded a goal whose author had gone. (`participates`)// - Editing a PROJECT — its name, remote, default branch — and archiving one belong to the// project's author, and to nobody else, admin included. These are standalone records like a// goal's ending, so the PDS accepts anybody's and the fold is what drops them; the rule is narrow// because what a project is decides which repository every future turn clones, and archiving one// shelves every goal beneath it. (`ownsProject`)//// Creating a space is the one act with no rule, because there is nothing yet to have a rule about:// the author of a space record is that space's root admin by construction, and their grant is// implicit for as long as the space exists.
import type { ArtifactTypeRecord, GoalView, IndexedRecord, MaterializedIndex, ProjectView, StrongRef,} from '@radial/core'import { artifactTypes } from '@radial/core'import type { Actor, Directory } from './directory.js'import { locator } from './requests.js'
// ── who may ─────────────────────────────────────────────────────────────────────────────────────
/** * An active admin. Membership, the registry and auto-review are all folds that read `authorRole`, * so this is the exact condition under which those three writes count for anything. */export function governs(directory: Directory, did: string): boolean { if (!did) return false const actor = directory.get(did) return actor.active && actor.role === 'admin'}
/** An active member of any role: what a project, a goal, or a goal's ending asks for. */export function participates(directory: Directory, did: string): boolean { return Boolean(did) && directory.get(did).active}
/** * The project's own author, and nobody else — including an admin. * * A project's name, remote, branch and archived state are STANDALONE records (`editProject`, * `archiveProject`), so anybody's PDS will accept one and the fold is what drops it. So this is the * same kind of answer the rest of this file gives — a trust rule read off `materialize()` — and the * reason for the rule is that what a project IS decides which repository every future turn clones. * That is not an annotation about where attention should go (which is why ending a *goal* is any * member's), it is the thing itself. */export const ownsProject = (project: ProjectView, did: string): boolean => Boolean(did) && project.target.did === did
// ── members ─────────────────────────────────────────────────────────────────────────────────────
/** * One row of the member list. * * A removal names the exact grant it revokes, so a member who has no grant to name cannot be * removed: that is the root, whose membership is implicit and who the fold refuses to remove at all. * Everyone else carries the `membership` strongref their `addMember` record minted. */export interface MemberRow { actor: Actor /** The space record's author. Admin by construction, and not revocable. */ root: boolean active: boolean /** * The grant this row's membership rests on — what a removal names. Absent only for the root, whose * grant is implicit. A removed member still carries the (revoked) one, because the fold keeps it: * that is how a second removal is recognised as targeting a grant that is already gone. */ membership?: StrongRef /** * This DID has opened the space at least once and its client wrote a `join` — or it is the root, * whose space record makes the question moot. False is the interesting case: somebody was granted * membership and has never turned up, which is a fact about people that membership alone cannot say. * * An agent is expected to be false forever: a daemon is handed a space URI by its operator, so it * never needs a bookmark to find one, and nothing writes it a join. * * In a PRIVATE space it is false for everybody and says nothing: the directory poller lists the * device collections and nothing else (`ingest/private.ts`), so no `join` is ever read there. * `bootstrapped` is the question worth asking instead. */ arrived: boolean /** * This DID has published a signing key, as this replica has read the device directory. * * The private-space answer to "have they turned up?", and it is the mechanical one rather than a * politer proxy: a peer serves an endpoint its directory names (`core/private/peers.ts`), so a * member with a grant and no device is somebody nobody can reach yet — they have not accepted a * ticket, or their `device` record has not been read back. In a public space it is false for * everybody and is not shown, because devices only exist in private mode. */ bootstrapped: boolean}
/** * Every DID the space has ever granted, active first. Removed members stay on the list because the * fold keeps them — their records are ignored from the moment the grant was revoked, and saying so * is more useful than a row quietly disappearing along with everything it wrote. */export function memberRows(index: MaterializedIndex, directory: Directory): MemberRow[] { const rootDid = index.space.did const joined = new Set(index.joins.map((join) => join.did)) const published = new Set(index.devices.map((device) => device.did)) return index.members .map((member) => ({ actor: directory.get(member.did), root: member.did === rootDid, active: member.active, ...(member.membership ? { membership: member.membership } : {}), arrived: member.did === rootDid || joined.has(member.did), bootstrapped: published.has(member.did), })) .sort( (left, right) => Number(right.active) - Number(left.active) || Number(right.root) - Number(left.root) || // People before agents: a space's humans are its members in the ordinary sense, and its // agents are the machinery they point at work. Number(left.actor.kind === 'agent') - Number(right.actor.kind === 'agent') || left.actor.name.localeCompare(right.actor.name), )}
/** * A member this signer could actually remove: an active grant, not the root's, and not the signer's * own. Removing yourself is representable and is a foot-gun the protocol has no undo for — a sole * admin who revokes their own grant leaves a space nobody can govern — so it is not offered. */export function removable(row: MemberRow, directory: Directory, did: string): boolean { return governs(directory, did) && row.active && !row.root && row.actor.did !== did}
/** * What `addMember` writes. `kind` and `role` are independent fields and are offered as two choices, * because the protocol keeps them independent — but the pairing matters in one direction that is * easy to get wrong: `MaterializedIndex.agents` binds an agent's self-description by its grant * saying `kind: 'agent'`, so a daemon's DID added as a human publishes a record the fold ignores, * and it never appears in an assignee picker. */export interface MemberDraft { /** A handle or a DID; `runCli` resolves a handle to the DID that is stored. */ actor: string kind: 'human' | 'agent' role: 'admin' | 'member' | 'agent'}
/** The role that goes with a kind unless somebody chooses otherwise. */export const defaultRole = (kind: 'human' | 'agent'): MemberDraft['role'] => kind === 'agent' ? 'agent' : 'member'
export const addMemberArgs = (spaceUri: string, draft: MemberDraft): string[] => [ 'member', 'add', '--space', spaceUri, '--actor', draft.actor.trim(), '--kind', draft.kind, '--role', draft.role,]
/** * `radial member remove`. The grant is not named here: `runCli` asks its `resolveMembership` * dependency for it, which in this tab is a lookup in the index rather than a trip to the network * (`write.ts`). A removal that named a stale grant would be one the fold drops. */export const removeMemberArgs = (spaceUri: string, did: string): string[] => [ 'member', 'remove', '--space', spaceUri, '--actor', did,]
// ── the registry ────────────────────────────────────────────────────────────────────────────────
/** * One row of the artifact-type registry — the screen this whole step exists for. An admin adds * `security-review` here and it appears in every ⊕ menu of the right scope, with no deploy and no * code change, which is the claim design §4 makes and 6.4 built the menu against. */export interface RegistryRow { record: IndexedRecord<ArtifactTypeRecord> name: string scope: 'goal' | 'project' durability: 'living' | 'append-only' /** * Another record already claims this name. Duplicate names resolve earliest-wins in `core` and * latest-wins in the daemon's dispatch registry, which is why `type create` refuses one — but a * duplicate written before that guard existed, or by a second admin at the same moment, is a * record the space is carrying and nothing has ever shown. It is inert: no menu offers it, no * turn is briefed from it. */ shadowed: boolean}
/** * The registry as records rather than as the resolved map, so a shadowed entry is visible. * * `durability` comes off the record itself here, not off `durabilityOf()` — for the row that wins * the name they are the same value by construction (asserted in `admin.test.ts`), and for a * shadowed one only the record's own field says anything true about it. */export function registryRows(index: MaterializedIndex): RegistryRow[] { // Earliest-wins, by the same walk `artifactTypes()` makes over the same ordered list — so the row // this marks as the winner is the one every menu, brief and turn bundle actually reads. const winner = new Map<string, string>() for (const record of index.artifactTypes) { if (!winner.has(record.value.name)) winner.set(record.value.name, record.uri) } return index.artifactTypes .map((record) => ({ record, name: record.value.name, scope: record.value.scope, durability: record.value.durability ?? ('living' as const), shadowed: winner.get(record.value.name) !== record.uri, })) .sort( (left, right) => left.name.localeCompare(right.name) || left.record.value.createdAt.localeCompare(right.record.value.createdAt), )}
/** A name the registry already carries. `type create` refuses one, so the form does too. */export const nameTaken = (index: MaterializedIndex, name: string): boolean => artifactTypes(index).has(name.trim())
/** * A new registry entry. Every field is one the ⊕ menu and the compose card read back: `brief` is * what a turn is instructed with, `outputSpec.description` is the sentence under the menu item, and * `scope` decides which view offers it at all. */export interface TypeDraft { name: string brief: string description: string scope: 'goal' | 'project' durability: 'living' | 'append-only'}
export const typeArgs = (spaceUri: string, draft: TypeDraft): string[] => [ 'type', 'create', '--space', spaceUri, '--name', draft.name.trim(), '--brief', draft.brief.trim(), '--output-description', draft.description.trim(), '--scope', draft.scope, // Always sent, though `living` is also what omitting it means. The registry is the one place the // distinction is authored, and a record that says which it is stays legible to a reader who does // not know the default. '--durability', draft.durability,]
/** What the toast says once the registry has moved. The consequence is the point, not the record. */export const wroteType = (draft: TypeDraft): string => `${draft.name} is in the registry — it is in the ⊕ menu on every ${draft.scope} from now on`
// ── projects and auto-review ────────────────────────────────────────────────────────────────────
/** A `name=command` check, as two fields rather than as the string the CLI takes. */export interface CheckDraft { name: string command: string}
export interface ProjectDraft { name: string gitUrl: string defaultBranch: string checks: CheckDraft[]}
/** The https remote, or why what was typed cannot become one. */export type GitUrlResult = { url: string } | { error: string }
/** * The remote a project is stored with, which is always an https one. * * A turn clones over https and nothing else — `run.gitSchemes` defaults to `["https"]` — and on * tangled the ssh push remote is *derived* from this URL rather than typed, so an ssh remote here is * not another spelling of the same project. It is a project whose first turn dies at checkout with * `git url scheme "ssh" is not allowed`, hours after the form said it was fine. * * Both forges offer an ssh remote for copying, so the rule converts rather than scolds: * `git@host:owner/repo` and `ssh://git@host/owner/repo` name the same repository as the https URL * beside them, and the `git@` in front is transport rather than a credential worth carrying over. * Ports go with it — an ssh port means nothing to https. * * What it will not do is guess. `http://` is refused rather than quietly upgraded, since which * scheme a host answers on is a fact about the host and not ours to assert. Userinfo on an https URL * is refused rather than stripped: that is a token somebody meant to send, `project` records are * public, and the daemon rejects a URL carrying one at checkout anyway. */export function httpsGitUrl(value: string): GitUrlResult { const raw = value.trim() if (raw === '') return { error: 'A project needs a git remote.' } // `://` as git tests for it, rather than trusting URL(): `github.com:owner/repo` is an scp remote, // but `github.com` is also a syntactically valid scheme, so URL() would accept it as one. if (/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(raw)) { let url: URL try { url = new URL(raw) } catch { return { error: 'That is not a URL Radial can parse.' } } const scheme = url.protocol.replace(/:$/, '').toLowerCase() if (scheme === 'https') { if (url.username !== '' || url.password !== '') { return { error: 'Drop the credentials from the URL — this record is public, and Radial brings its own.' } } return { url: url.href } } if (scheme !== 'ssh' && scheme !== 'git') { return { error: `Radial clones over https, and a ${scheme}:// remote is not something a turn can check out.` } } return { url: httpsRemote(url.hostname, url.pathname) } } const scp = /^(?:[^@/:]+@)?([^@/:]+):(.+)$/.exec(raw) if (!scp) return { error: 'That is not a git remote. Paste the repository’s https URL.' } return { url: httpsRemote(scp[1] as string, scp[2] as string) }}
const httpsRemote = (host: string, path: string): string => `https://${host.toLowerCase()}/${path.replace(/^\/+/, '')}`
export const blankCheck = (): CheckDraft => ({ name: '', command: '' })
/** * `radial project create`. A check with no command is a row somebody added and did not fill in, and * writing it would put a named check with nothing to run into a permanent record — the same rule * the findings editor follows for an empty finding. * * Throws on a remote that cannot be an https one. The form gates on the same rule and never gets * here with a bad value; this is so that a caller which forgets to fails loudly, rather than by * creating a project that looks fine until its first turn cannot clone it. */export function projectArgs(spaceUri: string, draft: ProjectDraft): string[] { const git = httpsGitUrl(draft.gitUrl) if ('error' in git) throw new Error(git.error) const args = [ 'project', 'create', '--space', spaceUri, '--name', draft.name.trim(), '--git-url', git.url, ] const branch = draft.defaultBranch.trim() if (branch) args.push('--default-branch', branch) for (const check of draft.checks) { if (check.name.trim() && check.command.trim()) { args.push('--check', `${check.name.trim()}=${check.command.trim()}`) } } return args}
/** What a project's three mutable facts currently are — the edit form's starting state. */export const projectEditDraft = (project: ProjectView): ProjectEdit => ({ name: project.name, gitUrl: project.gitUrl, defaultBranch: project.defaultBranch,})
export interface ProjectEdit { name: string gitUrl: string defaultBranch: string}
/** * The fields a submitted form actually changed, trimmed. Empty when nothing did. * * Sending only what changed is not tidiness: the fold overlays `editProject` records PER FIELD, * latest-wins, so a record restating a field it was not changing would win that field against a * concurrent edit that was. A blank field is a field the form did not fill in rather than an * instruction to clear one — the lexicon has no way to say "unset", and a project with no remote is * not a project — so it is dropped. * * The remote is compared in its https form, which has one visible consequence: open this form on a * project stored with an ssh remote from before the rule and it has a change to save before anything * is typed. That is the right answer rather than a glitch — that project cannot be cloned as stored, * and saving is what repairs it. An unconvertible remote is left exactly as typed and reported as a * change; the form refuses to submit it, and this runs inside a `$derived` where throwing would take * the page down instead. */export function projectChanges(project: ProjectView, draft: ProjectEdit): Partial<ProjectEdit> { const changes: Partial<ProjectEdit> = {} for (const field of ['name', 'gitUrl', 'defaultBranch'] as const) { const trimmed = draft[field].trim() if (!trimmed) continue const git = field === 'gitUrl' ? httpsGitUrl(trimmed) : undefined const next = git && 'url' in git ? git.url : trimmed if (next !== project[field]) changes[field] = next } return changes}
/** * `radial project edit`. * * The project is pinned by uri#cid for the same reason `set-auto-review` is: the fold matches an * overlay to its project by exact strongref, so pinning the version this tab folded is what makes the * form it drew and the record it writes the same project — and a project record is immutable, so the * pin can never be the reason a write fails. */export function projectEditArgs(project: ProjectView, changes: Partial<ProjectEdit>): string[] { const args = ['project', 'edit', '--project', locator(project.target)] if (changes.name !== undefined) args.push('--name', changes.name) if (changes.gitUrl !== undefined) args.push('--git-url', changes.gitUrl) if (changes.defaultBranch !== undefined) args.push('--default-branch', changes.defaultBranch) return args}
/** * `radial project archive|unarchive` — a standalone record, reversible, and the project author's. * * Consequential in a way archiving a goal is not: it shelves every goal under it too, so agents stop * dispatching, reviewing, checking and merge-polling anything beneath it in one act. */export const projectArchiveArgs = (project: ProjectView, archive: boolean): string[] => [ 'project', archive ? 'archive' : 'unarchive', '--project', locator(project.target),]
/** A name another project in this space already answers to. A rename onto one makes two rows the rail * cannot tell apart and one URL that resolves to whichever was created first, so the form refuses it. */export const projectNameTaken = ( index: MaterializedIndex, project: ProjectView, name: string,): boolean => index.projects.some( (other) => other.target.uri !== project.target.uri && other.name === name.trim(), )
/** * One switch on a project's settings: an artifact type, and whether landing one of that type asks * for a review without anybody pressing anything. * * Every registry type gets a row, both scopes, because the daemon's trigger scans goals and * projects alike — a project-scoped document lands and is owed a review the same way. */export interface AutoReviewRow { type: string scope: 'goal' | 'project' enabled: boolean}
export function autoReviewRows(index: MaterializedIndex, project: ProjectView): AutoReviewRow[] { return [...artifactTypes(index).entries()] .map(([type, value]) => ({ type, scope: value.scope, // `ProjectView.autoReview` is already the project record's own defaults with the admin // `setAutoReview` overlays applied, latest-wins per type. Absent means off. enabled: project.autoReview[type] === true, })) .sort((left, right) => left.type.localeCompare(right.type))}
/** * `radial project set-auto-review`. * * The project is pinned by uri#cid rather than named by URI alone. The fold matches an overlay to a * project by exact strongref, so pinning the version this tab folded is what makes the switch it * draws and the record it writes the same project — and a project record is immutable, so the pin * can never be the reason a write fails. */export const autoReviewArgs = (project: ProjectView, type: string, enabled: boolean): string[] => [ 'project', 'set-auto-review', '--project', locator(project.target), '--type', type, '--enabled', enabled ? 'true' : 'false',]
export const feedbackSourceArgs = (project: ProjectView, source: string, enabled = true): string[] => [ 'project', 'feedback-source', '--project', project.target.uri, '--source', source.trim(), ...(enabled ? [] : ['--off']),]
/** * `radial space guest-comments`. * * The space is pinned by uri#cid for the same reason the project is above: the fold matches the * overlay by exact strongref, and a space record is never rewritten, so the pin can never be why a * write fails. Admin-authored-only when it is FOLDED — the button asks `governs` for the same reason * every other governance control does. */export const guestCommentsArgs = (index: MaterializedIndex, enabled: boolean): string[] => [ 'space', 'guest-comments', '--space', locator(index.space), '--enabled', enabled ? 'true' : 'false',]
/** Auto-review writes a request; a request nobody can answer sits open. Worth saying before it does. */export const autoReviewNeedsAgent = (directory: Directory): boolean => !directory.agents().some((agent) => agent.active && agent.artifactTypes.includes('review'))
// ── a goal's ending ─────────────────────────────────────────────────────────────────────────────
/** * `radial goal close|reopen`, and the disposition when it is a close. * * The goal is named by URI and deliberately not pinned by uri#cid, which is the one thing to get * right here: the fold matches an ending to its goal by URI alone, because a legacy `closed` edit * minted a second CID for goals that already carry one. Pinning would make an ending resolve onto * nothing on exactly those goals. */export const closeArgs = (goal: GoalView, close: boolean, disposition?: string): string[] => [ 'goal', close ? 'close' : 'reopen', '--goal', goal.target.uri, ...(close && disposition ? ['--disposition', disposition] : []),]
// ── creating a space ────────────────────────────────────────────────────────────────────────────
export interface SpaceDraft { name: string description: string /** * Whether its records travel in signed envelopes and reach no PDS (design §18). * * Set at creation and never afterwards: a space does not change mode, so there is no `editSpace` * that could turn this on for records already published — which is the whole reason the flag is * here rather than on a settings screen. */ private?: boolean}
/** * `radial space create`. Seeding the built-ins — `plan` and `implementation`, plus the project-scoped * `architecture`, `architecture-inventory`, `glossary`, `conventions`, `adr` and `fdr` — is what * `--no-builtins` turns off and this never does: a * space with an empty registry has a ⊕ menu with nothing in it and a System section that can never * fill, and "add your own types" reads very differently on a screen that already has some than on one * that has none. */export function spaceArgs(draft: SpaceDraft): string[] { const args = ['space', 'create', '--name', draft.name.trim()] const description = draft.description.trim() if (description) args.push('--description', description) // The flag and the writer have to agree, and `runCli` refuses either mismatch rather than picking // one: `--private` with a PDS writer would publish the very records the flag says must never reach // one, and an envelope writer without it would seal a public space nobody could read. if (draft.private) args.push('--private') return args}
/** What a type's durability means, said once so the registry form and its rows cannot disagree. */export const durabilityNote: Record<'living' | 'append-only', string> = { living: 'kept current as the project changes', 'append-only': 'kept as a dated history',}