Working several todos at once
One session works one todo, and everything about that is Working a todo — unchanged here. This page is the part around it: how several sessions get different work, where each of them writes, and how what they wrote comes back. Nothing on it replaces the reading, the research or the question a todo is owed.
It is worth doing where the queue holds work that does not overlap, which is most of what accumulates here: entries that name a decision each and are read against different parts of the checkout. It is not worth doing for two todos about one file, and it is not worth doing for one.
Main and the branch#
``main`` carries the state, the branch carries the work.
That is the whole arrangement, and every rule below follows from it. Who has
what in hand is on main, where all of them read it. The half-finished diff
is on a branch, where nobody else has to look at it. A question that stopped a
session comes back to main on its own — the branch it was asked on stays
where it is.
The reason is that todo/ is the one thing every session writes. One todo is
one file, and that was already true before anybody worked two at once; it is
what makes this possible at all. Each session touches its own file and no other.
Taking them on#
bin/cli todo:claim 3bin/cli todo:claim 3
That command is the whole setup. Three todos come out of the queue into
todo/progress/, each with the branch it will be worked on and today's date;
from then on bin/cli todo:next offers them to nobody, and the fourth session
is handed the item behind them. Then the commit that carries the claims, a
worktree apiece with its own composer install, and the message the three
sessions are started with. What is left to do is start them.
It carries that out rather than printing it because of the order, not the
typing. A claim has to be on main before the worktree is cut from it, or the
worktree carries no file saying what it was made for — and that failure surfaces
in the session, hours later, as a refusal nobody can place. Three steps that
have to happen in one order are one step.
Where a todo has been worked before, the branch it derives to and the worktree
named after it are both still there. Neither is refused and neither is reused:
the claim is given the first free name — todo/<name>-2,
.worktrees/<name>-2 — and records it, because a worktree that quietly
attaches to an old branch is the one failure here that looks like success.
**Branch:** on the claim is what says which is which, which is what it was
always for.
What it prints besides the branches is an overlap, in the three ways two claims can have one. Nothing here knows which lines a step will touch, so all three are a warning to read before the worktrees exist rather than a refusal.
Two claims answering for one entry are two sessions editing one file, and
taking one of them is cheaper than merging both. Two naming one class are
the same a step less certainly — a todo says where it is about to work, as a
path, as Class::method(), or as the bare name, and the claim resolves all
three to the file. Two standing on one requirement or decision without
serving it are working from a single judgement, which is where a pair of steps
that have to agree comes from.
The last two are here because Serves: alone missed the collision that cost
the most. On 2026-08-02 two todos with different Serves: lines each added a
handler for one token to one function; the rebase put them in sequence, each
ending in continue, and the second was never reached. Both had named
R-ANS-012 and both had named the class — one as Extension::describe(),
the other as src/Installation/Extension.php. Neither is a declaration and
neither had to be: it is a session saying where it is going, in the file the
claim reads anyway.
bin/cli todo:release <name> is the way back out, for a claim nobody is
working — a branch that came home with a question left over, one that was
abandoned, a session that never started. Where it goes is read off the claim,
and the branch is left alone either way.
The worktree#
One per claim, made by todo:claim: a worktree on the claim's branch, its own
composer install, and .checkouts/ symlinked in. What it does by hand is
here because it is what the command is doing, and because a worktree made some
other way has to do the same.
Run ``composer install`` in the worktree. Never symlink ``vendor/``.
That one costs an afternoon to find. Paths::root() is the directory above
src/, and Composer's autoload map resolves src/ from wherever the
autoloader physically sits — so a symlinked vendor/ points every path in
this repository back at the main checkout. bin/cli then reads and writes the
todos, requirements and decisions of the checkout the session is not in, and
nothing about the output looks wrong.
.checkouts/ is the opposite case and is symlinked on purpose. It is 861 MB,
it is gitignored, and a session working a todo only ever reads it. Only
bin/cli checkouts:update writes there, and that is not a claim's work.
Starting the sessions#
One session per worktree, started in that worktree, and all three get the same
message — the one todo:claim printed. It is not on this page, because a copy
here and a copy in the command are two things to keep in step and one of them
would be sent. How the session is launched at all — which build, from where,
with what switched on — is Driving a session nobody types in, and it is
the same launch a forward run uses.
Better: let the claim start them. Put the command line that starts a session
on this machine into .session-command at the root of the checkout, and
todo:claim runs it once per worktree — that worktree as the working
directory, the message on standard input, TODO_SESSION_ID in the
environment. The file is gitignored, because how a session is launched is a
property of the machine and not of the repository, and
Driving a session nobody types in is where what the launch has to get
right is written down. Each session reports into
.worktrees/.sessions/<name>.log.
That is the fourth step joining the other three, and it is here for the same reason they are. A step left over for somebody to carry out by reading is the one that breaks: the run of 2026-08-02 started every session in the directory that was already open, and three worktrees stood untouched while the sessions read a queue belonging to somebody else.
Where the file is absent nothing is started and the handover prints instead —
one absolute cd per worktree and the message under a line saying the rest of
the output is not part of it. Which directory a session is started in was a
sentence once, with that worktree as its working directory, and a property
somebody satisfies is a blank wearing prose.
Nothing in that message is filled in, and that is the whole of what it took
to fix. It was a template with the worktree path and the branch left as blanks,
and the run that broke sent it as it stood: the session read
<absolute path to the worktree> as a line it was being given rather than one
somebody had forgotten, and no check it could make would have told it otherwise.
A message with a blank in it is a message somebody fills in, so there is none —
every session is started with the same characters, and there is nothing to get
wrong.
What the message therefore cannot name is which todo is whose, and that half was
never the prompt's to answer. A path a session is handed is one it has no reason
to doubt: a worktree on a branch the claim never named — or one cut from a
main that did not carry the claim yet — passes every check the session can
make and then reads the queue, where it finds real work belonging to somebody
else. So it is read out of the checkout instead:
bin/cli todo:next --worktreebin/cli todo:next --worktree
The same command every session in this repository starts with, and the flag is
the sentence the prompt used to carry: this session is one of several.
Standing on a claim, it hands over that claim and names the branch it is
committed on. Standing on none — the wrong branch, a claim that was not on
main yet, or a session started in the main checkout at all — it says which
of those it is and stops. Where it refuses, that is the end of the session and
not a cue to find something else to do.
What the session does#
Nothing about it is special. It reads what the todo serves, settles what the step turns on, and leaves the file true — all of Working a todo, which the command names as usual.
Four things are different, and all of them are consequences of main being
elsewhere. The claim is handed over with them attached; this is why they are
there:
- Commit on the branch, never on ``main``. That includes the todo file itself. A finished claim is a deletion in the branch, and the merge is what carries it.
- Leave the group listings alone — the block at the foot of a
requirements/<group>/readme.mdordecisions/<group>/readme.md. It is generated from every file in the group, and a worktree can only see its own new entry. That means the command,bin/cli requirements:indexorbin/cli decisions:index, and it means the line: two sessions editing one listing by hand conflict where two sessions leaving it alone do not. The session that merges runs both commands once, andrequirements:checksays so if it forgets. Nothing incomposer cisays it, deliberately: a suite that held the listing would fail every branch that adds an entry, on the one line the branch is told not to touch —D-FBK-011. - Say nothing about what another branch has done. A sibling's state is the
one fact a worktree cannot check, and writing it down is how a run of ten
leaves two feedback answering for nothing. Twice now the same sentence has
been written by both halves of a pair — the feedback stays open behind the
sibling todo, which is another session's claim — and both times the sibling
was finished. Each session was right about its own half and wrong about the
half it could not see. What is true instead is shorter: this half is done, and
whether the entry closes depends on the other, which was not read here.
TodoTest::everyOpenFeedbackIsOnTheBoardis what catches the leftover, on the rebase, once both halves are onmain. - Point at entries, not at positions. In a file two sessions are both adding to, above, below, once, the first and the other are all claims about a layout that has one more section in it by the time anybody reads them. The run of 2026-08-02 wrote this entry has been cited once into an entry that ended the day with three citations, the paragraph above into one that gained three sections in between, and the **Wrong if** got its other answer twice, in two accounts that could not both be the other. Name the feedback, the requirement or the decision instead: those survive whatever lands beside them.
A question mid-work#
A todo that turns out to need an answer nobody here can give is the normal case, not the exception, and a session working alone asks and waits. One of several cannot: waiting blocks a worktree on a person who is answering three others.
So it does not ask, it records. The question goes into a **Waiting on:**
line on the claim in progress/, in the words it would have been asked in,
together with what the reading already established. Then the session commits
what it has and ends. The branch keeps the half that is done.
What happens next depends on whether the work behind the question stands on its own. Most of the time it does — the session settled one half of its todo and the question is about the other — and then the branch merges like any other and the trimmed claim comes with it. Where it does not, only the claim comes back:
git checkout <branch> -- todo/progress/<name>.mdgit checkout <branch> -- todo/progress/<name>.md
That keeps main free of a half-finished change while still saying, in one
place, what is open and where the work behind it is.
A claim whose branch is gone does not stay in ``progress/``. That state is
for as long as a branch is live. Once the work is merged and the branch deleted,
**Branch:** names something nobody can look at, and the todo is blocked on a
person — which is what waiting/ is. bin/cli todo:release reads that off
the claim: one carrying a **Waiting on:** goes to waiting/, one carrying
none goes to the end of the queue. Neither touches the branch.
A claim in progress/ with no **Waiting on:** and an old **Claimed:**
is the other thing to look for. Nobody is working it, and nothing will notice on
its own — bin/cli todo:list prints the date for exactly that reading.
Bringing the branches home#
A run that is finished comes home now. Checked, merged, worktree gone — and
none of it waits for the sessions still going. Nine branches held back until the
tenth reports are nine that each need a bigger rebase when they finally move,
against a main that kept going without them, and the only thing the waiting
bought was a tidier-looking moment. There is no batch here: there are ten
sequences of the same four steps, started whenever their session ends.
One at a time, rebased onto ``main`` and fast-forwarded — no merge commits.
bin/cli todo:home <worktree>bin/cli todo:home <worktree>
That command is the whole of it, the way todo:claim is the whole of the
setup, and it is here for the reason that one is: the steps below have to happen
in one order, and an order is the thing prose cannot hold anybody to. Asked with
no name it reports what is standing and which of those has a tree nobody
committed. It refuses in a worktree and it refuses off main, because a
fast-forward onto somebody's branch is the failure that looks like success.
Nothing here decides that a session has ended, and nothing can: the names are the caller's. What the command carries out for each of them is this, and a worktree made or merged some other way has to do the same.
git -C .worktrees/<name> rebase main
(cd .worktrees/<name> && composer ci)
git merge --ff-only todo/<name>
git worktree remove .worktrees/<name> && git branch -d todo/<name>git -C .worktrees/<name> rebase main
(cd .worktrees/<name> && composer ci)
git merge --ff-only todo/<name>
git worktree remove .worktrees/<name> && git branch -d todo/<name>
main moves while the sessions run, so a branch cut hours ago is behind it
and cannot be fast-forwarded as it stands. The rebase is what makes the merge a
fast-forward, and --ff-only is what says so: where it refuses, something is
not what this procedure assumes, and that is worth stopping for. What comes out
is one sequence of commits on main rather than a merge commit per claim
saying nothing but that a claim existed.
The four steps belong to one branch, and the next branch starts them again
from the top. main moved when the last merge landed, so a branch rebased
before it is behind again — running two merges from one rebase is the mistake
the run of 2026-08-02 made, and --ff-only caught it, which is what it is
for. And the worktree goes after the merge, never before: removed early it
takes the only checkout the rebase and the suite can run in, and getting it back
costs a fresh composer install.
``composer ci`` runs in the worktree, after the rebase. That is the first
moment the session's work stands on what main has become, and it is the only
run that says anything: one from before the rebase checked a tree that no longer
exists. One at a time for the same reason — a suite that fails after three
branches says nothing about which one broke it. The claims themselves never
conflict, each session having touched one file in todo/ and its own.
Where one branch fixes something the others are also failing on, that one goes first. Otherwise every merge behind it is checked against a suite that was already red, which is the one thing this order exists to avoid.
Then, on main, which todo:home also carries out once the branch is in:
bin/cli requirements:index && bin/cli decisions:index
bin/cli repository:checkbin/cli requirements:index && bin/cli decisions:index
bin/cli repository:check
That is something a per-branch run cannot see. The listing at the foot of a
group readme is generated from every file in that group, so entries merged from
two branches leave it short by one — bin/cli requirements:check says so and
names the command, and the index commands above are that command run before it
has to. What the rewrite changed is committed on the spot, exactly those files
and nothing beside them, because a main left dirty by a merge is read as a
half-finished change by whoever opens it next.
What used to stand here as well was the queue: two sessions that each queued new
work both read the same last number and both took it. There is no number now, so
there is nothing to collide — two todos are both normal and the older one is
older, whichever branch each arrived on.
An id still collides, and that is the one to expect. A requirement and a
decision are numbered, every session reads the same last number, and ten of them
reading it at once produce duplicates: the run of 2026-08-02 wrote D-ANS-009
twice and D-FBK-018 twice. Nothing can prevent it and nothing needs to —
composer ci in the second branch fails on two decision files claim the same
id once the first is on main, which is the rebase doing its job. That
failure names both files and the command below, so it is read where it lands
rather than here. Renumber the later one, fix what names it, amend, and the
check goes quiet. Whichever
branch merged first keeps the number, so the order is decided by the order the
work came home rather than by anybody arbitrating it.
What is dangerous is the renumbering, not the collision. Four runs of ten
produced seven of them, and twice the files naming the old number did not all
mean the same entry: R-PRJ-008 rested on the D-ANS-013 that kept it
while five other files meant the one that became D-ANS-015, and ans-006
named the D-ANS-016 that stayed while a requirement and a todo named the one
that became D-ANS-019. A search and replace over the id is wrong in exactly
those cases, it is silent, and no check fails afterwards — the entry it now
points at is real.
bin/cli decisions:renumber <decision>bin/cli decisions:renumber <decision>
That moves the entry and every reference whose own line names its file, and
prints the rest — the bare ones, which is what both mis-pointings were. Those
are read one at a time, and git diff main -- <file> is what settles an
ambiguous one: a line this branch added means this branch's entry. The list is
the whole of them, so a reference nobody read is one somebody skipped rather
than one nothing mentioned —
D-DOC-015.
A marker that survives the resolution is what nothing used to catch. A file
with a >>>>>>> left in it parses, lints, and passes every test that does not
happen to read it; the run of 2026-08-02 put one into a decision and
composer ci went green over it, in a commit whose diff looked deliberate.
StructureTest::noFileCarriesAConflictMarker reads every file this repository
keeps, so the suite the branch already runs after its rebase is where that now
surfaces.
Two sessions can also land on one entry where nothing declared it: the
overlap todo:claim reports is read off what the todos say, and this one is
created by the judging rather than written down before it. Two of the ten judged
different feedback into the same D-SKL-001, which the rebase surfaced as a
conflict in the file. Usually both paragraphs belong — each is an account of one
reading, which is what a Since then carries, so the resolution is a heading
each rather than a choice between them.
A claim left in ``progress/`` is released here, and it is the one thing the
merge does not carry. The session that ended on a question left its claim there
on purpose — the branch was live and held the half that is done — and deleting
that branch is what turns the same file into a lock on a todo with nothing
behind it. todo:home therefore releases what the merge left standing, on the
branch it has just deleted — to waiting/ where the claim carries a question,
to the queue where it carries none. bin/cli todo:release <name> is the same
move made by hand, for a claim whose branch went some other way, and
bin/cli todo:check reports a claim whose branch is gone so that forgetting
costs a line rather than a todo.