In the scaffold I built and run my projects from, where several coding agents share one checkout through linked worktrees, a claim on a task is a claim ref and one lock ref per declared resource, written in one git transaction. The claim ref is refs/swarm/task-claims/<task-id>. It points at a small JSON blob holding the agent's name, its branch, the base commit, the claim time and an expiry 30 minutes out. Each lock ref, refs/swarm/task-locks/<hash>, stands for one resource the task file declares. git update-ref --stdin creates all of them or none of them. A second agent that claims the same task gets git's own refusal, reference already exists, before it has edited a file. The task file is never touched by a claim.
A task file has five states, queued, blocked, done, failed and cancelled. Active is not a state in the file. It is a queued task whose claim ref exists and has not expired, computed whenever someone runs the status command.
The alternative was in my first draft. One operator, three to five coding agents in linked worktrees of one repository, and the task board kept as files in that repository. Agent A edits status: active into a task file and commits on its branch. Agent B, on its own branch, reads the same file and sees status: queued. It keeps seeing queued until A's branch merges. By then both have spent an hour building the same thing. Creating a marker file races too, since checking for it and writing it are two separate operations. Both were P1 findings in the board's design review, which is in the scaffold's first commit, before any task ran.
The scaffold is a starter for concurrent agents: gates that each ship with a negative test, decision records, and a task board where each file is a complete handoff. The evidence here is the claim script, its spec file, and the loop review that replaced amendment PRs with a proposal inbox, read at commit f4a81c1 unless a later commit is named.
I run the scaffold on my own projects, and the site this essay is on runs the same script: pnpm task claim here resolves to scripts/task-claim.ts, the file quoted below. Usually several agents are running here at the same time, each in its own worktree.
Why a Task File Cannot Hold "Active"
A claim has to be written where every worktree reads it without waiting for a merge. A commit on branch agent/T-1 changes the task file only in the worktree that has agent/T-1 checked out; every other worktree's copy is whatever its own branch holds. The design review of the board (a second model reading my draft, findings graded P1 and P2) put this problem first. Its first finding: "status: active in task files cannot coordinate worktrees. A claim committed on branch A is invisible in branch B until merged, by which point the work is already underway." Its second: "Plain file creation cannot prevent two agents claiming the same task. 'Check then write' races. Use atomic git update-ref transactions."
source: gated-swarm-starter scripts/task-claim.ts lines 32-33, 95, 205-208, 342 and scripts/task-claim.spec.ts lines 15-44 (f4a81c1), outputs measured in a throwaway repo · claimRace.data.ts
Each committed state is a folder under .agents/tasks/, and there is no active/ folder. The status command lists the refs under refs/swarm/task-claims/. A queued file with a live claim ref there is active. Deleting the ref returns the task to queued with no commit to the task file. Each line the command prints for a ref tells you who holds the task, on which branch, under which plan revision and until when, and once the expiry has passed it says EXPIRED in place of active.
source: gated-swarm-starter docs/TASK-BOARD-DESIGN.md lines 21, 35, 45-54, 79 and scripts/task-claim.ts line 32 (f4a81c1) · taskLifecycle.data.ts
Linked worktrees (git worktree add) share one common directory, the .git of the main checkout, and the shared refs, refs/swarm/ among them, live there. A worktree on branch agent/T-1 and a worktree on branch agent/T-2 read the same refs/swarm/ the moment it changes. git update-ref --stdin takes a script of create, update and delete lines on standard input and applies them together. If any create names a ref that already exists, git refuses the whole script and writes nothing. The second claimant's lock ref below was never created.
The Claim Is One update-ref Transaction and a 30-Minute Lease
pnpm task claim <id> --agent <name> reads the task from the base branch, never from the caller's working tree. It fetches the base branch from origin first and exits before admission if an origin exists and the fetch fails: a stale copy of the plan never admits a claim. A task that exists only in your checkout is refused with "is not in .agents/tasks/queued/ on main (only queued, base-branch tasks are claimable)", and the script's other refusals say what that enforces, "claim authority comes from the base branch, not your working tree". Then admission checks the task against the plan. It must be in queued/ on that branch, and every depends_on task must be in done/. review_by must not have passed. The plan must be approved, the manifest's revision must match it, and the manifest's milestone must list the task and be active (the reserved integration task also claims while its milestone is integrating). A task in the backlog is refused until the operator triages it.
Admission returns the task's declared locks and the plan revision. The claim blob is built from eight fields, task, agent, branch, baseCommit, claimedAt, expiresAt, locks and planRevision, and written with git hash-object -w. The lock ref name is the sha256 of the lock key, first 16 hex characters (the fixture's key spec-lock hashes to 474cc8e6e4f0452f, which one line of node will confirm). The claim itself is about forty lines of git plumbing: a hash, a blob and one create line per ref, piped to update-ref --stdin. The code, reduced to the three pieces that do the work:
function lockRef(key: string): string { return LOCKS + createHash('sha256').update(key) .digest('hex').slice(0, 16)}function transaction(lines: string[]): void { git(['update-ref', '--stdin'], lines.join('\n') + '\n')}// in claim(), after admission against the base branch:const blob = writeBlob(built)transaction([ `create ${CLAIMS}${taskId} ${blob}`, ...a.locks.map(l => `create ${lockRef(l)} ${blob}`),])I rebuilt the spec's fixture in a throwaway repository (one queued task, one lock key, plan revision 1, a branch called work) and ran the claim twice with different agent names. The lines are the script's output with two reductions: the fixture has no origin, so git prints a stderr complaint when the fetch falls back to the local base branch, and those lines are removed; and lines wider than the column are folded onto an indented second line.
$ tsx scripts/task-claim.ts claim T-20260814-abcdef01 --agent aclaimed T-20260814-abcdef01 for a until 2026-10-07T11:38:26.623Z (rev 1 @ main, locks: spec-lock)$ tsx scripts/task-claim.ts claim T-20260814-abcdef01 --agent bfatal: cannot lock ref 'refs/swarm/task-claims/T-20260814-abcdef01': reference already existsFAIL Command failed: git update-ref --stdinfatal: cannot lock ref 'refs/swarm/task-claims/T-20260814-abcdef01': reference already exists$ git for-each-ref refs/swarm0ca67071fc8a38b57bdedaca728467276f6d6df8 blob refs/swarm/task-claims/T-20260814-abcdef010ca67071fc8a38b57bdedaca728467276f6d6df8 blob refs/swarm/task-locks/474cc8e6e4f0452fBoth refs point at blob 0ca67071, agent a's (the figure's run gives a different blob, 54058e90, since the blob holds the claim time). git cat-file -p on it gives a claimedAt of 11:08:26.623Z and an expiresAt of 11:38:26.623Z, the 30-minute default lease. After both commands git status in the fixture is empty, and the task file was never opened. The first fatal line is git's, and the FAIL line is the script's, which repeats git's stderr in its error message.
Claims are leases. renew writes a new blob with a later expiry and moves the claim ref and its lock refs with a compare-and-swap, update <ref> <new> <old>, where <old> is the blob SHA it read. If another agent has replaced the claim in between, git refuses the update. When the work is done the agent runs release, which deletes the claim ref under the same CAS and the lock refs without one, in a single transaction. steal is the only way another agent takes a claim, and it works only once the claim has expired; a live claim is refused with "operator override required". The agent running the task keeps its claim alive by renewing on an interval it picks itself, renew --every N with N from 1 to 120 minutes. The command gives N no default. If the agent stops renewing, the claim expires after 30 minutes, and from then on check refuses it and steal can take it.
The renew path had a bug I fixed in commit c40b97c. Renew replaced the claim ref with the new blob and left the lock refs pointing at the old one. Steal's transaction compares each retained lock ref against the claim ref's blob SHA. After a renew the lock refs still held the previous blob, and update-ref refused every steal of an expired claim that had ever been renewed. The fix moves the lock refs with the claim ref in the same transaction, and every ref now carries one blob SHA. The test for it is named for the invariant, "renews without desyncing lock refs: an expired renewed claim is stealable". It renews with --minutes 0 and steals as agent b.
The spec runs against a real throwaway git repository, built with git init, one fixture commit and git switch -c work. Commit 25d374d added it with four tests. "refuses claiming from the base branch itself" and "rejects invalid heartbeat intervals" came after, and this is the run of all six, with the file path and the task-claim protocol > describe prefix trimmed from each line, the longest name folded onto a second line, and the timing footer dropped:
✓ refuses claiming from the base branch itself 498ms ✓ claims atomically and rejects a second claimant 942ms ✓ renews without desyncing lock refs: an expired renewed claim is stealable 954ms ✓ check-branch validates the held claim and release clears it 910ms ✓ rejects invalid heartbeat intervals 151ms ✓ refuses authority minted only in the working tree 326ms Test Files 1 passed (1) Tests 6 passed (6)The second test asserts only the substring already exists.
Refs Coordinate Linked Worktrees, Not Separate Clones
The design document says it in two sentences: "This protocol does not coordinate independent clones. Do not pretend otherwise." The review's P2 lists what a file board loses to GitHub Issues: multi-machine claiming, notifications, assignees, comments, permissions, saved views and API integrations. Its reading was that the design "is credible only for worktrees sharing one Git common directory". Two clones on two machines have two common directories, and a ref created in one is not in the other until something pushes it.
Locks coordinate on declared keys. A task file lists locks: such as a package, a schema, a generated artifact or a decision record, and the claim creates one lock ref per key. Two tasks that both declare schema cannot both be claimed. Two tasks that edit the same file without a shared key can both be claimed. The loop review put that in a P1 of its own: "'Atomically claimed' is not the same as 'safe to run concurrently.'"
The local tripwire became a wall on 2026-08-20 (the split between local gates as tripwires and server-side twins as walls is argued in the control-loop essay). In another project an agent authored its own task straight into done/, had task claim refuse it, and implemented anyway. I merged a provenance gate after that. Claim, renew, release and steal now push their refs to origin under refs/swarm/*. Release archives the final blob to refs/swarm/claim-log/<task>/<timestamp> before deleting the claim. A CI job fetches refs/swarm/* and fails any implementation PR that does not move exactly one base-queued task to done/ with a claim recorded against its head branch, an authority block identical to the base copy, and every changed path inside that authority. Its spec had ten cases when it merged. Five of the negatives replay the incident's moves: a task authored straight into done/, no claim, a claim on the wrong branch, a widened authority block, and a path outside the authority.
Six Human Queues Make a Perfectly Documented Traffic Jam
The design review of the loop itself (the plan, solidify, execute cycle the board sits inside) opened with "Adopt with substantial changes" and then said where it would fail. The likely failure "is not chaotic coding", it wrote, but "a perfectly documented traffic jam". "With five active agents, discoveries can arrive faster than the operator can even read them." It counted six human queues feeding one person: plan approval, solidification review, cross-model review adjudication, PR review and merge, amendment PR review, and harvested-task triage.
Its line on the mechanism was "A Git PR is a poor concurrent proposal protocol. It conflates 'raise a finding,' 'design a resolution,' and 'mutate authority.'" The scenario it gave for the amendment path starts with agents A, B and C each discovering that the approved package boundary is wrong, and all three open amendment PRs against plan revision 7. The human merges A's, which makes revision 8. B and C now describe a world that no longer exists (rebasing them proves they are textually compatible and does not prove they are semantically compatible), and tasks D and E may meanwhile claim work approved under revision 7.
Four of its changes are now in the scaffold's standing orders and code. Amendment PRs from agents are gone: a discovery becomes a proposal file with a base_plan_revision and a severity from the closed set local | ordering | scope | endgoal, and I fold the open proposals into one plan revision at fixed triage points. "Regenerate, don't repair" became a failure classification. The taxonomy I wrote into the standing orders has five kinds: implementation mistake, broken verification, spec gap, invalid premise, and plan-level wrong. The review had proposed six, among them base branch changed and time budget exhausted. Only an invalid premise regenerates the task. Each milestone reserves its integration task when it is solidified, and a milestone is not done because its leaf tasks merged. Last, every claim binds to an exact plan revision, and every task binds through the manifest's plan_revision: the blob carries planRevision, and renew and check re-read the base branch and fail if the revision moved.
The operating limit from the same review went into the standing orders as written: at most 3 implementation tasks in flight, 1 integration, 1 planning, and 1 plan amendment being folded. The metric it told me to watch first is human review queue depth and decision latency. The one milestone that has run end to end under these rules was m1: two board-ergonomics tasks and the reserved integration task, closed on 2026-08-14.
Seven Board Rules the Review Said to Steal
The board review's last P2 opens "Steal from this repo" and lists what to keep. The lifecycle is encoded twice, in the path (.agents/tasks/queued/) and in the header (status: queued), and the board gate fails when the two disagree. Classification is a closed set (feature, bug-fix, simplification, architecture, process, testing, docs). Ids are immutable and dated, T-YYYYMMDD-<8 hex>, so a regenerated task gets a new id and a regenerated_from. There is no central index, so creating a task edits no shared file. A decision record must name the alternatives it rejected, and task files are temporary (pruned after a retention window, suggested 30 days) and stay separate from the durable notes. Every repository path and anchor a task file names should be checked mechanically; the design names that check verify-task-links, and the scaffold's task gate does not run it yet.
Limits
The one recorded milestone had three tasks. The loop has not run at five agents, and the queue metric is not measured. The WIP caps and the stability exits are text in the standing orders, with no gate behind them, and the claim protocol coordinates linked worktrees only. A second model wrote the two reviews against my drafts. They are design reviews, and the measurements are the two claims run one after the other and the six-test spec run above. The scaffold repository is private, so the code in this essay is quoted from a checkout and not linked.
Appendix A: Five CI Label Fixes Found by Live Fire
On 2026-08-14 the CI label surface (Appendix B) failed four times, and once succeeded without posting anything, before a labelled run completed and posted its result. Each of the five items below is its own commit marked "found by live-fire". The action needs id-token: write to mint its GitHub credentials over OIDC, and it needs either the workflow token passed in or the Claude GitHub App installed. Prompt mode runs silently unless track_progress is on: the first run to succeed posted nothing to the issue. Tag mode performs git operations, and the label workflow's checkout sets persist-credentials: true, unlike the scaffold's other workflows. And squash merges landed with empty bodies until the repository set squash_merge_commit_title=PR_TITLE and squash_merge_commit_message=PR_BODY together. PRs 4 and 5 found that one by landing with nothing in them. In this scaffold the PR body becomes the squash commit body, and the README's one-time setup now sets both values in one gh api call.
Appendix B: Both Agent Surfaces Run Without an API Key
Neither invocation surface holds an API key. The CI labels run anthropics/claude-code-action, pinned by SHA, on an OAuth token from claude setup-token, the operator's own subscription. The local dispatcher (pnpm dispatch) is the zero-secret path. Each run lists the open issues labelled agent-triage, removes the label, runs the versioned triage prompt through a local codex in read-only sandbox mode or a local claude with file and exec tools disallowed, and comments its classification. It handles triage only. Issue bodies are untrusted input, and attempts run on the isolated CI runner.
Commit 2b13779 closed three security findings on the label surface. Both CI jobs run only when the repository owner applied the label, so a third party cannot start a run against the credentialed session. The triage job runs with Bash, Edit, Write, NotebookEdit and WebFetch denied. The dispatcher removes the agent-triage label before it runs anything, and its next poll of the same issue finds no label to act on.