Building a virtual team system
People are hard to change, so I replaced the parts that ran on goodwill with rules, and a few sessions now work like one team. Four rules, five review stages, the pitfalls I hit, and the limits that are still there.
The conclusion first: this system is not there to make the AI faster. It is there so I deal with fewer people problems.
Two sessions in the same folder
Here is how the work splits: one of me opens tasks and reviews results, and a few sessions each take a task and do it. It sounds like a few extra people. What I actually hit is everything a few extra people would hit.
Once, two sessions were sitting in the same folder. One opened a new branch. The other had confirmed minutes earlier that it was on main and assumed it still was, so a note meant to land straight on main landed on someone else’s branch instead. It only found out at the very end, when it went back to update the task board and saw that the ground under it had been swapped.
The problem was not that one session made a mistake. The process itself had no way to tell the other side that things had changed. After this happened a few more times, I started building this system.
People problems, and what a few extra sessions run into
This system exists to cut down on people problems, so start with the places that have people: a startup or a large company, wherever there are people, the problems come from every direction. A team of one skips that layer, but the rules, the review process and the session splitting below still apply to it, minus the part about staying aligned with someone else.
My own experience is that when someone has not decided to change, no advice, however it is delivered, will change them. In the end the only person you can adjust is yourself. But the further you adjust yourself, the wider the gap grows between you and the people who are not moving, and not one of the team’s problems gets smaller.
Every team runs into this, and there are plenty of ways to cope. But what if there were a cheaper one? Take “waiting for someone to change” out of the process: hand the work to a few sessions that inherit how I work, keep only the rules and the review for myself, and let rules replace goodwill.
Sessions replace the people, though, not the problems of working together. Once I had a few sessions running, these are the four things I hit:
- Dependent tasks collided. Some tasks depend on each other and cannot be separated cleanly. Before the rules existed, session 1 did A and session 2 did B, neither noticed the other’s changes, both burned tokens, and nobody was checking the quality of what came out.
- The board is passive. The task board shows who claimed what, but another session only finds out when it goes back and looks. That is exactly how the same-folder collision at the top happened.
- Derived tasks got opened twice. Work uncovers an extra need that calls for a new task; each session opens its own without knowing about the other’s, two tasks may overlap, and the board keeps growing.
- Finished tasks were not cleared in time. Done work stayed on the board, so what gets poured into every new session at startup kept getting fatter.
Four rules, and a few built-in features
Replace the parts that ran on goodwill with rules that block. Four rules, one for each of the four problems.
Bind, for problem 1. Two tasks that depend on each other too tightly to separate do not go to two sessions. They are done on one branch, reviewed together and landed together, with at most one bound task at a time, so a chain never turns into one PR nobody can finish reviewing. Tasks that do separate stay one per session.
Communicate, for problem 2. First the board itself moved house: it used to live on main, and now it lives on its own git ref. As soon as a claim or a lock is pushed, every session can see it, and board operations stop crowding main. Before the move, most of what landed on main was claims, locks and the like, with the real engineering changes buried underneath; now main carries only the engineering.
But being visible does not mean anyone looks, and the board is still passive. So on claim and on lock, a small script builds one line stating the task, the branch, the territory it will touch and which folder it is in, and the session sends that line to every other session still running, instead of waiting for them to check the board. This is best effort: if it cannot be delivered, it is skipped, not retried, and it does not block the claim, because a claim has to be immediate.
Example: “claimed a task, on branch virtual-team-system-restore, touching engines/web, I am in the virtual-team-system-restore worktree”. The branch name is a short description of the task, and the path is the fixed area that product line owns, the same for every task on it, so anyone reading it knows at once whether it collides with their own work. In the same-folder collision at the top, the missing piece was the last part.
How it is implemented: building the announcement line
core/scripts/board_announce.py
def announce_claim(task_id):
root = board_root(ROOT)
tasks, errors = scan_tasks(root)
task = next((t for t in tasks if t["id"] == task_id), None)
if task is None:
detail = "; ".join(errors) if errors else "no such live task"
raise SystemExit(f"board-announce: {task_id}: {detail}")
if not task["branch"]:
raise SystemExit(f"board-announce: {task_id} has no branch recorded yet "
f"-- claim it (write branch + commit + push) before announcing")
return (f"board claim {task_id}: branch {task['branch']}, "
f"territory {territory_of(root, task)!r}, {checkout_location()}")
Number, for problem 3. A derived task goes on the board before any work starts. Every new session reads the board’s task list as soon as it opens, and when someone claims a task, the claim is broadcast under the same communicate rule. So before a new task gets opened, the board already shows whether the same thing is there, instead of the overlap turning up after the work is done.
Numbers come from a counter per product line that only moves forward, and the whole issuing step holds a file lock. It only advances once the number is confirmed unused, so even a collision does not burn a number for nothing. Delete a task that no longer applies and its number is not reused either: the same number in the history always means the same thing.
How it is implemented: a counter that only moves up
core/scripts/board_add.py
def allocate(prefix, trunks, tasks):
"""Return (id, trunk name) and bump the trunk's counter, under a file lock.
Everything happens inside the lock, and the counter is only written once the
id is known to be free, so a collision leaves the counter untouched instead
of burning a number.
"""
with trunks.open("r+", encoding="utf-8") as fh:
fcntl.flock(fh, fcntl.LOCK_EX)
lines = fh.read().splitlines(keepends=True)
rows = [(i, m) for i, line in enumerate(lines)
if (m := ROW_ANY.match(line)) and m.group("prefix") == prefix]
if not rows:
raise SystemExit(f"prefix '{prefix}' is not a trunk in {trunks}")
if len(rows) > 1:
raise SystemExit(f"prefix '{prefix}' has {len(rows)} rows in {trunks}, "
f"so the counter to bump is ambiguous; leave one")
i, m = rows[0]
raw = m.group("next")
counter = raw.strip()
if not counter.isdigit():
raise SystemExit(f"trunk '{prefix}' in {trunks} has no usable next "
f"counter (found '{counter}'); set it to the number "
f"after the highest id that trunk has ever used")
number = int(counter)
task_id = f"{prefix}-{number:02d}"
if (tasks / f"{task_id}.md").exists():
raise SystemExit(f"{tasks / f'{task_id}.md'} already exists, so the "
f"next counter for '{prefix}' is behind reality; "
f"raise it past every id this trunk has used")
lines[i] = line_with(lines[i], m, number + 1)
fh.seek(0)
fh.writelines(lines)
fh.truncate()
fh.flush()
return task_id, m.group("trunk").strip()
Check both directions, for problem 4. Check both sides of one habit together: one side catches “marked done with nothing delivered”, the other catches “done but still on the board”. board doctor checks both, and it is also one of the gates.
How it is implemented: checking both sides
core/scripts/board_doctor.py
elif status in FINISHED_STATUSES:
# [... 18 comment lines omitted ...]
if status == "done" and not t["output"]:
problems.append(f"{t['id']}: done but no output link")
# [... a second problems.append omitted: it reports "<status> and still on the board" and asks to prune the file ...]
The rules handle how sessions stay out of each other’s way. The other limit is the session limit. Four sessions working hard at the same time hit the cap easily, and then it is a five hour wait for the reset. When it hits, it is not one session stopping, it is four stopping at once.
Claude Code is my main tool and Codex the secondary, so these are Anthropic’s built-ins:
/contextfirst, to see what is in the context, then/clearto decide whether to clear it. Clearing without looking throws away things you still need./compact: especially effective in the fix rounds of a code review.- Switch models: pick the model that is good at what the task is, instead of running the most expensive one throughout.
- Do in code whatever code can do. If a script gives the answer in one run, do not make a model think it through.
- Keep prompts and documents meant for the AI short. Every session reads the startup material once, so a long document is a fixed cost every session pays.
These only lower how often the cap is hit. The cap itself is still there, and the conclusion comes back to it.
How review runs, and how many rounds
Every change goes through a PR, and review is written down as a process.
The old process had three steps. First, a subagent that had not written the code read it and ran it; findings got fixed and reviewed again until a round came back with nothing to fix. Then a model from another family reviewed it, for a different set of blind spots. Finally a person approved it, and it was merged. The person showed up once, at the very end.
Now it is five stages, run in order:
- architecture: people only. Before any AI looks at the diff, the intent, the scope, the invariants, which way it should fail when it fails, and whether the interface gets wider are written into a contract and frozen.
- adversarial: AI reviews against that frozen contract, round after round, until a round over the whole change comes back with only observations and no finding.
- regression: AI confirms that fixing the findings did not break behaviour that had already been verified.
- convergence: people only. Confirm the change still sits inside the contract, and that no reviewer slipped its own design preference in dressed up as a defect.
- gates: fixed commands: doc checks, board doctor, workspace doctor, the full test suite, lint.
The difference is where the person sits. Before, the person was only at the end. Now the person is at the start and at the end, and the middle belongs to the AI.
The problem I hit was the number of rounds. Fixing a finding is itself a change, so every fix goes back to adversarial for another review, and that backward arrow cannot be removed. One tiny PR went through six rounds, which took a long time and a lot of tokens, and several of those rounds were spent fixing my own bad prose from the round before.
So the stages themselves settle “who looks”, not “how many times”. What actually goes after the round count is two rules. First, a reviewer’s points get sorted: only one that names which part of the contract it violates, or proves a failure on a concrete path, is a finding and has to be fixed. Everything else, a different design, an extra feature, a style preference, is an observation: it is recorded on the task for a person to decide, and it does not hold up the end of the round. Second, merging main in when the diff has not changed by a single character and the gates and the full test suite still pass does not count as a change, and does not start a new round.
How it is written into the rules: finding versus observation, and the merge exception
core/rules/review.md
4. One mechanical exception for a merge of main, all three conditions being
commands rather than judgements: `git diff origin/main...HEAD` is character
for character what it was at the end of the last round to run, whichever
stage it belonged to; gates are still clean on the merged branch; the
test suite's full suite tier (`core/rules/testing.md`) still passes on
the merged branch. Fail any one and the merge
restarts the round at adversarial.
[... rules 5 to 6 omitted ...]
7. Every candidate issue a reader raises is classified before it is reported,
and the classification decides what it is allowed to do.
1. **Finding**: names the specific clause of the architecture contract it
violates, or shows an already-authorized behavior failing on a
concrete path, with evidence: a file, a rule interaction, a repro, a
test. A finding can block the stage that raised it and is fixed inside
the task under rule 12.
2. **Observation**: violates no frozen invariant. A different design, a
capability the contract did not ask for, a wider interface, a style
preference. An observation never blocks a stage from ending, never
authorizes a change on its own, and is never filed as a new board task
by the round that raised it (rule 13 already covers filing in
general). It is recorded in the observation ledger on the task
register:
[... the observation ledger format and the rest of rule 7 omitted ...]
Whether these two brought the round count down, I have not measured. Since they took effect, branches have still gone five rounds, and some have gone ten. So this is not the best version yet.
Several of me, and what is still open
Every session inherits the history, so each one works more and more the way I do. Sometimes it feels like the team is several of me.
What is gone are the people problems: no laziness, no lack of ability, nobody who found the problem but cannot or will not fix it, and no need to stand beside anyone teaching with endless care. What is new is a different kind of problem: those four problems were the machines’ collisions, and each one sent me back to fix a rule; of the six review rounds, several were my own bad prose, not the machine’s fault, because I cannot review my own work clean; and the session limit is set by the vendor, and no rule of mine removes it.
The limits are real too. In theory they can work 24 hours a day, but four virtual colleagues, or four virtual copies of me, working hard hit the session limit easily, and then it is a five hour wait for the reset. So even a virtual team run by one person cannot bring in too many at once.
same rules, times three
One expert person with three expert copies. Scale that pattern and three people have nine copies: a team needs only three people to have 12 experts at work. The people own the rules and the review, so this system turns “how many people do we need” into “how many people who can run copies do we need”.
Still open: the session limit itself, and how many review stages pay for themselves.
Once this system runs steadily, this is what I want it to look like: ask before you start, get the progress report, then take over.
Still using it?
Finishing up.I will report progress,
then you take over.
I write about this kind of thing regularly — follow on LinkedIn · subscribe via RSS
← Back to all posts
Comments
Comments are reviewed before they appear; your email is only used for replies and is never shown.
No comments yet — be the first.