The most valuable artifact from a pairing hour with a senior engineer is rarely the patch itself. It is the smaller decision surface the pair leaves behind: the questions that were worth asking, the attempts that died, and the one change both people agreed to keep. Agent-generated diffs actively hide that surface, because the diff arrives formatted like a finished answer with none of the reasoning attached. Writing the surface down during the session converts a loose review conversation into an artifact that a CI job can validate afterwards.
Three failure modes are responsible for most unproductive pairing sessions. An unbounded question list lets the junior ask eleven things while the senior answers only the three easiest ones. A dead end that never gets written down returns in the next session, usually with the same two people reaching the same conclusion at the same cost. A decision with no overturning condition turns into folklore, and six weeks later nobody remembers why the retry wrapper sits above the fixture rather than inside it.
The protocol, in seven steps
-
Open with a one-screen brief. The junior states the failing command, the diff stat, and at most three candidate hypotheses before the senior speaks.
git diff --statplus one failing test name fits on a single screen. - Rank five questions and cap the budget. Five is a working default because a forty-minute session can absorb roughly five real answers. Every question ranked sixth or lower goes into a parking list that the pair revisits only if the budget closes early.
- Attach the cheapest falsifying check to each question. A question without a check is an opinion request, not an engineering question, and it should be rejected on the spot. The check is usually a single test node id, a log grep, or a three-line script.
- Time-box each check. A ten-minute box forces the pair to notice when a promising line of inquiry is quietly eating the session. When the box expires, the question either produces a check result or becomes a dead end.
- Write dead ends into a register the moment they die. Each entry records what was tried, the falsifier that killed it, and the stop condition that ended it. The register prevents the same experiment from being rerun by the next pair.
- Close by keeping exactly one decision. Exactly one is the operative word: two decisions in one session means the pair has not finished deciding, it has deferred the conflict.
- Name the command that would overturn the kept decision. An honest decision statement includes its own falsifier, so a future contributor can reopen it with evidence rather than seniority.
A ledger format that survives the session
# Pair session 2026-09-15
## Question Q1 [budget: 10m]
ask: Does the retry wrapper double-count the attempt counter?
check: pytest -q tests/test_retry.py::test_attempt_counter -x
falsifier: counter reaches 2 after one injected timeout
status: answered
## Question Q2 [budget: 10m]
ask: Is the flake caused by the fixture's shared connection pool?
check: pytest -q tests/test_retry.py -p no:randomly --count 3
falsifier: failure rate unchanged across three ordered runs
status: parked
## Dead end D1
tried: pinning httpx to 0.27 to stop the intermittent failure
falsifier: three runs with the pin still failed once
stop: stop after 3 runs or 10 minutes, whichever came first
## Dead end D2
tried: adding a sleep before the assertion
falsifier: failure reproduced twice with the sleep in place
stop: two reproductions were enough
## Decision DEC1
keep: move the retry wrapper above the fixture, not inside it
evidence: pytest -q tests/test_retry.py green 5 of 5 runs
overturn-when: attempt counter mismatches on the second retry path
A validator that enforces the rules
The script below is a proposal rather than a battle-tested tool; it parses the heading style used above and exits non-zero when a section is missing a required field.
#!/usr/bin/env python3
"""Validate a pair-session ledger before the session closes."""
from __future__ import annotations
import json
import re
import sys
from pathlib import Path
SECTION = re.compile(r"^##\s+(Question|Dead end|Decision)\b(.*)$", re.M)
FIELD = re.compile(r"^\s*(check|falsifier|stop|keep|overturn-when|status|budget)\s*:\s*(.+)$", re.M)
REQUIRED = {
"Question": ("check", "falsifier"),
"Dead end": ("falsifier", "stop"),
"Decision": ("keep", "overturn-when"),
}
def parse(text: str) -> list[dict]:
marks = list(SECTION.finditer(text))
blocks = []
for i, m in enumerate(marks):
end = marks[i + 1].start() if i + 1 < len(marks) else len(text)
fields = dict(FIELD.findall(text[m.end():end]))
blocks.append({"kind": m.group(1), "label": m.group(2).strip(), "fields": fields})
return blocks
def validate(blocks: list[dict], budget: int) -> list[str]:
problems = []
for b in blocks:
missing = [f for f in REQUIRED[b["kind"]] if f not in b["fields"]]
if missing:
problems.append(f"{b['kind']} {b['label']}: missing {', '.join(missing)}")
asked = sum(b["kind"] == "Question" for b in blocks)
if asked > budget:
problems.append(f"{asked} questions opened, budget is {budget}")
kept = [b for b in blocks if b["kind"] == "Decision"]
if len(kept) != 1:
problems.append(f"expected exactly 1 kept decision, found {len(kept)}")
return problems
def main(argv: list[str]) -> int:
path = Path(argv[1] if len(argv) > 1 else "pair_session.md")
budget = int(argv[2]) if len(argv) > 2 else 5
blocks = parse(path.read_text())
problems = validate(blocks, budget)
print(json.dumps({
"questions": sum(b["kind"] == "Question" for b in blocks),
"dead_ends": sum(b["kind"] == "Dead end" for b in blocks),
"kept": [b["fields"].get("keep") for b in blocks if b["kind"] == "Decision"],
"problems": problems,
}, indent=2))
return 1 if problems else 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv))
A minimal test plan keeps the validator honest, and three cases cover most of its surface. Remove a falsifier: line and the exit code must be 1 with the missing field named. Duplicate the question section six times with a budget of five and the budget rule must fire. Add a second decision block and the one-decision rule must fire.
python3 pair_session.py pair_session.md 5; echo "exit=$?"
Wiring the session into a workflow
The ledger is most useful when it lives next to the diff, so the natural home is a docs/ file committed with the branch and a CI step that fails on an invalid ledger. Running the validator in CI costs nothing and catches the common case where someone opened seven questions and kept two decisions.
When the dead-end register needs a first draft, a hosted model can summarize the failed attempts from test logs. MonkeyCode's operator states that free model access and a free server option are available, and at the time of writing the operator lists a free token allowance of roughly 10 million tokens alongside that server option; check the project's own page for current numbers before planning around them. Disclosure: This article was prepared as part of MonkeyCode's product outreach. The script below reads its endpoint, key, and model name from environment variables on purpose, because model names and allowances change faster than articles do.
export MODEL_BASE_URL="<from the project's current docs>"
export MODEL_API_KEY="<your key>"
export MODEL_NAME="<a model the project currently lists>"
python3 - <<'PY'
import json, os, urllib.request
payload = {
"model": os.environ["MODEL_NAME"],
"messages": [{"role": "user", "content": "Draft dead-end entries from these logs."}],
}
req = urllib.request.Request(
os.environ["MODEL_BASE_URL"].rstrip("/") + "/chat/completions",
data=json.dumps(payload).encode(),
headers={"Authorization": f"Bearer {os.environ['MODEL_API_KEY']}",
"Content-Type": "application/json"},
)
print(urllib.request.urlopen(req, timeout=60).read().decode()[:2000])
PY
The free server option matters here for a mundane reason: the validator and the summarizer both run fine on a small remote box, so the session does not depend on one person's laptop staying awake. Nothing in the workflow requires the model to be reached from the same machine that holds the repository.
Two exchanges worth stealing
The exchanges below are an illustrative reconstruction of the pattern rather than a verbatim transcript, and they are included because the phrasing is reusable.
The senior asked, "What is the cheapest thing that would prove you wrong in the next ten minutes?" The junior proposed pinning a dependency, and the senior wrote the falsifier into the register before the command finished running. Three minutes later the pin was a dead end with a recorded reason instead of an argument.
The second exchange covered the kept decision. The senior asked, "If we are both wrong next month, what would tell us?" The answer became the overturn-when line, which is the field most often left empty in ledgers written without a second reviewer.
Decision table
| Situation | Use the ledger | Skip the ledger |
|---|---|---|
| Multi-hypothesis flake or intermittent failure | Yes | No |
| Agent diff touching more than three files | Yes | No |
| Single-file typo or documentation fix | No | Yes |
| Live incident with users affected | No | Yes |
| Second session on the same unfixed bug | Yes, mandatory | No |
| Solo work with no second reviewer | Modified form | No |
The solo row deserves a note, because a ledger without a second reader is a diary. A solo operator can still keep the same fields, but the falsifier must be a command rather than a question to another person, or the exercise decays into note-taking.
Limitations and who should skip this
The validator enforces shape, not truth: a fabricated falsifier passes every regex in the script. The one-decision rule is a heuristic that will occasionally hide two genuinely independent fixes, and the right response is to split the session rather than to weaken the rule. The ledger also adds fifteen to twenty minutes of writing to a session that already carries a cost.
Skip the approach entirely if the change is a one-line fix, if the deadline is measured in minutes, or if nobody will read the file again. It is likewise a poor fit for teams that cannot commit a docs/ file to a branch, since an unversioned ledger loses most of its value.
If the goal is to try the ledger on a real bug this week, the operator's stated free model access and free server option are one way to get the summarizer step running without a payment decision, and the validator above behaves identically against any endpoint that speaks the same API shape.
Top comments (0)